TDD로 Golang 시작하기

새로운 언어를 공부할땐 책을 보면서 syntax를 익히거나 간단한 프로젝트를 진행했었다.
이번엔 Go를 공부하게됬는데 TDD를 통해서 언어에 익숙해지는 방법을 시도해봤다. 이런 방법은 언어에 익숙해지면서 Test, Refactoring에도 자연스럽게 익숙해질 수 있는 장점이 있다. 이 글에선 TDD를 통해 간단하게 Go를 시작할 수 있는 방법에 대해서 정리해보려 한다.

Go 개발환경 셋팅하기

Go를 설치하는 방법은 두 가지가 있다.

  1. Go Download 에서 다운 받아서 설치
  2. GVM을 이용해서 설치

나는 버전을 유연하게 관리할 수 있는 GVM 방식을 선호한다.
GVM을 통한 자세한 설치방법은 아래 링크를 참고하도록 하자.
https://select995.netlify.com/go/module/gvm

Go 프로젝트 생성

이전에 Go를 잠깐 배웠을땐 GOPATH를 워크스페이스 처럼 사용해야 해서 불편하게 개발을 했던 기억이 있다. Go Modules를 사용하면 GOPATH 바깥에서도 Go 프로젝트를 만들고 개발할 수 있다.

go mod 명령을 통해 프로젝트를 생성한다.

1
go mod init <Repo/프로젝트 명>

Hello, World TDD

아래 Repo에서는 Test를 작성하면서 Go에 대해서 알아갈 수 있도록 여러가지 토픽에 튜토리얼을 제공한다.

https://github.com/quii/learn-go-with-tests

가장 첫 번째 토픽인 Hello, World를 시작해보자.
단순히 Hello, World를 찍는 프로그램을 TDD way로 진행해 볼 수 있다.

먼저 위에서 생성한 프로젝트에 hello.go 를 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func Hello() string {
return "Hello, world"
}

func main() {
fmt.Println(Hello())
}

go 에서 entrypoint는 main package 의 main 함수이다.
이렇게 작성하고 go run 을 하면 Hello, world 문구가 출력된다.

Hello라는 함수를 분리한 이유는 Test를 돌리기위해서이다.
fmt.Println은 stdout에 출력하는 side-effect를 가지고 있기 떄문에 Test하기 어려운 함수가된다.

이제 Test code를 작성해보자.
내가 해본 다른언어들은 Test를 돌리기위해서 다른 라이브러리나 프레임워크를 필요로 했다.
(Java - Junit, Javascript - Jest, Mocha)

Go에서는 Test를 위해서 Test Runner를 추가할 필요가 없다.
단지 파일명을 xxx_test.go 형식으로 만들어주면 된다. 이 예제에서는 hello.go로 작성했기때문에 hello_test.go 로 만들어주면 된다.

hello_test.go

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "testing"

func TestHello(t *testing.T) {
got := Hello()
want := "Hello, world"

if got != want {
t.Errorf("got %q want %q", got, want)
}
}

여기서 got은 Test할 함수가 반환한 결과값이고 want는 Test에서 기대하는 값(expect)라고 볼 수 있다.
내장 모듈인 testing에는 Test를 위한 여러 유틸리티 함수들이 있다.
(Error, Log, Run…)
위 Test Code는 기대값과 실제 결과가 다르면 error를 내는 코드이다.
Assertion을 사용하려면 다른 라이브러리를 추가해야한다.

go test 명령을 통해서 작성해본 test가 정상적으로 동작하는지 알 수 있다.

일반적으로 개발할때는 실제 코드를 먼저 작성하고 정상적으로 동작하는지 실행을 해보거나 디버그로 버그를 찾는 식이다. TDD로 개발을 한다면 실제 코드보다 먼저 테스트를 작성하도록 권장한다.

전체적인 프로세스를 정리해보면 이런식일 것이다.

  1. 실패하는 Test 작성
  2. Test를 Pass할 수 있는 최소한의 코드를 작성
  3. 리팩토링

(RED -> GREEN -> REFACTOR)
만약 TDD 프로세스를 잘 모르겠다면 이 부분에 대해서 먼저 공부해는 것도 좋을 것 같다.

요구사항이 추가되었다. 단순히 Hello, world를 찍지 않고 인자로 이름을 받아서 처리하도록 변경해보자. 위 TDD 프로세스대로 먼저 테스트를 변경한다.

1
2
3
4
5
6
got := Hello("Chris")
want := "Hello, Chirs"

if got != want {
t.Errorf("got %q want %q", got, want)
}

이와 같이 먼저 어떤식으로 Hello라는 함수를 사용하고 어떤 결과를 기대하는지 작성한다.
IDE를 사용한다면 바로 lint error가 뜨는 것을 볼 수 있을것이다. (RED)

실제코드를 간단히 변경해서 Test를 Pass하도록 변경할 수 있다. (GREEN)

1
2
3
func Hello(name string) string {
return "Hello, " + name
}

Test를 돌려보자. OK 문구가 뜨는걸 볼수 있을것이다.
(만약 FAIL이 난다면 타이핑 실수를 하지 않았는지 확인해보자)

이제 리팩토링할 단계이다. 사실 이 코드에서는 리팩토링할 부분이 많지않다.
단순히 “Hello, “ 부분을 상수로 분리한다.

1
2
3
4
const englishHelloPrefix = "Hello, "
func Hello(name string) string {
return englishHelloPrefix + name
}

다시 Test를 돌려보자. (go test)
Success가 나오는 걸 확인할 수 있다. 만약 어떤 실수를 했다면 FAIL과 함께 Test code가 피드백을 줄 것이다.

다음 요구사항은 name 인자에 empty가 넘어올경우 기본값을 world로 설정해주는것이다.
테스트 해야될 사항이 두가지로 늘어났다. empty string(“”) 이 넘어올경우 Hello, world를 출력하는지 그리고 empty가 아닌 string이 넘어올 경우

하나의 Test case에서는 한 가지에 대해서만 명확히 Test를 작성하는 것이 좋다.
두가지 Test case로 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestHello(t *testing.T) {

t.Run("saying hello to people", func(t *testing.T) {
got := Hello("Chris")
want := "Hello, Chris"

if got != want {
t.Errorf("got %q want %q", got, want)
}
})

t.Run("say 'Hello, World' when an empty string is supplied", func(t *testing.T) {
got := Hello("")
want := "Hello, World"

if got != want {
t.Errorf("got %q want %q", got, want)
}
})

}

TestHello 라는 함수는 Test Suite로 t.Run은 하나의 Test Case로 볼 수 있을 것 같다.
아직 empty string에 대해 처리하지 않았기 때문에 에러가 날 것이다. 위에서 진행헀던 프로세스그대로 Test를 Pass할 수 있는 코드를 작성하고 리팩토링을 하면 된다.

Test를 Pass 시키기 전에 Test Code를 리팩토링 해보자. 지금은 got, want의 값이 다를 경우 error를 내는 부분이 중복되어있고 정확히 어떤 역할을 하는지 한 눈에 파악하기 어렵다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
assertCorrectMessage := func(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}

t.Run("saying hello to people", func(t *testing.T) {
got := Hello("Chris")
want := "Hello, Chris"

assertCorrectMessage(t, got, want)
})

t.Run("say 'Hello, World' when an empty string is supplied", func(t *testing.T) {
got := Hello("")
want := "Hello, World"

assertCorrectMessage(t, got, want)
})

assertCorrectMessage 라는 함수로 assert부분을 분리한다. 중복이 없어지고 가독성도 훨씬 좋아질 것이다. t.Helper() 함수는 error가 난 라인을 추적할 수 있도록 도와준다. 사용하지 않아도 라인을 알려주지만 정확히 에러가 난 라인이 아닌 assertCorrectMessage 함수가 있는 라인넘버를 알려줄 것이다.

Test를 Pass하도록 코드를 작성한다.

1
2
3
4
5
6
7
const englishHelloPrefix = "Hello, "
func Hello(name string) string {
if name == "" {
name = "world"
}
return englishHelloPrefix + name
}

다음 단계는 리팩토링이지만 여기선 그럴부분이 딱히 없어서 그대로 두면 될 것 같다.

위에서 소개한 Repo의 원문글을 보면 뒤에 2가지 단계 정도를 더 진행한다.
끝까지 진행해보면 TDD cycle을 익히는데 도움이 될 것 같다.

Tip

  • go test -v

    • go test는 실패하면 실패한 Test Case를 알려주지만 성공시에는 성공했다는 OK 문구 하나만 나온다. test case를 같이 출력하고 싶다면 -v 옵션을 추가해보자
  • testify/assert

    • 위 라이브러리를 사용하면 assert관련 여러 함수를 사용할 수 있다. 이런 함수들은 Test 실패시 실제 결과값과 어떤식으로 다른지 diff checking과 같은 부분도 제공을 하기 때문에 편리하다.

마치며

위 Hello, World는 정말 간단한 예제이지만 TDD 개발의 cycle에 익숙해질 수 있고 복잡하지 않은 코드를 연습하면서 언어를 익히는데도 도움이 많이 되었던 것 같다. go 를 새로 공부해야 한다면 TDD를 통해서 배우는 것도 괜찮은 방법일 것 같다.

Ref