if got, want: 더 나은 Go 테스트를 작성하는 간단한 방법
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기
아는 사람이 너무 적은 훌륭한 Go 테스트 패턴이 있다. 30초 만에 알려주겠다.
Go 테스트를 이렇게 작성하는 대신:
// The common, unrefined way.
username := GetUser()
if username != "dummyUser" {
t.Errorf("unexpected username: got %s, want: %s", username, "dummyUser")
}다음과 같이 테스트를 작성해 보라. 각 assertion은 if got, want :=로 시작한다:
// The underused, elegant way.
if got, want := GetUser(), "dummyUser"; got != want {
t.Errorf("username=%s, want=%s", got, want)
}if got, want := 패턴은 테이블 기반 테스트에서 더욱 빛을 발한다. 소셜 미디어 핸들을 파싱하는 내 라이브러리에서 가져온 예시는 다음과 같다:
func TestParseTwitterHandle(t *testing.T) {
for _, tt := range []struct {
explanation string
input string
handleExpected social.TwitterHandle
errExpected error
}{
{
"regular handle on its own is valid",
"jerry",
social.TwitterHandle("jerry"),
nil,
},
{
"regular handle in URL is valid",
"https://twitter.com/jerry",
social.TwitterHandle("jerry"),
nil,
},
{
"handle with exactly 15 characters is valid",
"https://twitter.com/" + strings.Repeat("A", 15),
social.TwitterHandle(strings.Repeat("A", 15)),
nil,
},
{
"handle with more than 15 characters is invalid",
"https://twitter.com/" + strings.Repeat("A", 16),
social.TwitterHandle(""),
social.ErrInvalidTwitterHandle,
},
} {
t.Run(fmt.Sprintf("%s [%s]", tt.explanation, tt.input), func(t *testing.T) {
handle, err := social.ParseTwitterHandle(tt.input)
if got, want := err, tt.errExpected; got != want {
t.Fatalf("err=%v, want=%v", got, want)
}
if got, want := handle, tt.handleExpected; got != want {
t.Errorf("handle=%v, want=%v", got, want)
}
})
}
}이 패턴은 어떻게 동작할까?
Go의 단순한 if 문은 불리언 표현식을 평가한다:
// A simple if statement that evaluates a boolean expression.
if volume > maxVolume {
volume = maxVolume
}Go는 불리언 표현식을 평가하기 전에 문장을 실행할 수 있는 if의 두 번째 형태를 제공한다:
// Execute a statement before evaluating the boolean expression.
if volume := getRequestedVolume(); volume > maxVolume {
panic("requested volume is too high")
}핵심 트릭은 if 문 안에서 여러 변수를 선언하고 할당할 수 있다는 점이다:
// Declare and assign multiple variables within if statement.
if a, b, c := nextScore(), nextScore(), nextScore(); a + b + c == 300 {
fmt.Println("Congratulations! You got a perfect score!")
}if 문 스코프 안에서 선언한 변수는 그 if 문 안에서만 존재한다. 그래서 모든 assertion에서 got과 want라는 변수 이름을 이름 충돌 없이 재사용할 수 있다.
실제로 if 문 밖에서 got이나 want에 접근하려고 하면 Go 컴파일러가 해당 변수가 존재하지 않는다고 알려준다:
// got and want are only available within the if statement.
if got, want := GetUser(), "dummyUser"; got != want {
t.Errorf("username=%s, want=%s", got, want)
}
log.Printf("username was %s", got) // This won't compile이 기법이 뭐가 그리 좋을까?
눈이 중요한 정보를 빠르게 찾도록 훈련시킨다
Go 코드는 특히 테스트에서 장황해지는 경향이 있다.
다음 테스트 스니펫을 살펴보자:
users := GetAllUsers()
if len(users) != 1 {
t.Fatalf("expected only a single user, got %d", len(users))
}
if users[0].username != adminUsername {
t.Errorf("unexpected username: got %s, want: %s", users[0].username, adminUsername)
}언뜻 봐서 어떤 값이 기대값이고 어떤 값이 GetAllUsers가 반환한 값인지 바로 구분되는가? 내 눈에는 그렇지 않다.
위 스니펫을 if got, want := 패턴으로 다시 작성하면 이러한 모호함이 사라진다:
users := GetAllUsers()
if got, want := len(users), 1; got != want {
t.Fatalf("userCount=%d, want=%d", got, want)
}
if got, want := users[0].username, adminUsername; got != want {
t.Errorf("username=%s, want: %s", got, want)
}이 패턴을 알게 되면 assertion에서 중요한 정보를 눈으로 빠르게 찾을 수 있다:

이 패턴을 알면 assertion의 실제 값과 기대값을 눈으로 빠르게 찾을 수 있다.
복사해서 붙여 넣기가 쉽다
변수가 항상 got과 want라는 이름을 사용할 때는 assertion을 복사해 붙여 넣을 때 많이 고칠 필요가 없다. 보통 할당 부분과 t.Errorf 안의 이름, 그리고 포맷 지정자(예: %s vs %v) 정도만 바꾸면 된다.
이 패턴은 내가 과거에 자주 저지르던 실수도 방지해 준다. 테스트 assertion을 복사해 붙여 넣고는 에러 메시지의 일부를 업데이트하는 것을 잊어버리는 실수인데, 예를 들면 다음과 같다:
username := GetUser()
if username != "admin" {
t.Errorf("wrong username: got %s, want %s", username, "admin")
}
email := GetEmail()
if email != "[email protected]" {
// Whoops, copy/pasted from above but forgot to update the error message.
t.Errorf("wrong username: got %s, want %s", username, "admin")
}테스트가 실패하기 전까지는 이 실수를 알아차리지 못했고, 결국 이런 혼란스러운 에러 메시지를 보게 되었다:
--- FAIL: TestUserProperties (0.00s)
users_test.go:24: wrong username: got admin, want adminif got, want := 패턴은 이러한 종류의 실수로부터 나를 보호해 준다. 테스트 assertion을 복사해 붙여 넣을 때 한 곳의 값만 업데이트하면 되기 때문이다.
테스트 assertion과 테스트 로직을 구분해 준다
Go로 HTTP 서버를 구현할 때 나는 종종 다음과 같은 유닛 테스트를 작성한다:
func TestUserHandler(t *testing.T) {
for _, tt := range []struct {
explanation string
payload string
statusExpected int
responseExpected string
}{
{
"valid request returns success",
"username=doug",
http.StatusOK,
"created user doug",
},
{
"reject username with angle brackets",
"username=d<script>oug",
http.StatusBadRequest,
"",
},
{
"reject empty username",
"username=",
http.StatusBadRequest,
"",
},
} {
t.Run(tt.explanation, func(t *testing.T) {
req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
if err != nil {
t.Fatal(err)
}
s := NewServer()
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
res := rec.Result()
if got, want := res.StatusCode, tt.statusExpected; got != want {
t.Fatalf("httpStatus=%v, want=%v", got, want)
}
// If this is not a test for valid input, ignore the rest of the
// server's response.
if tt.statusExpected != http.StatusOK {
return
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if got, want := string(body), tt.responseExpected; got != want {
t.Fatalf("response=%s, want=%s", got, want)
}
})
}
}해당 테스트 본문에는 두 가지 다른 유형의 if 문이 있다. 테스트 assertion과 테스트 로직 분기다.
if got, want := 패턴을 사용한 모든 if 문은 내가 테스트하는 코드에 대한 assertion이다. 그 외의 모든 if 문은 단순히 코드 흐름을 제어할 뿐 내 코드에 대한 assertion이 아니다.
예를 들어 테스트의 첫 번째 if 문은 HTTP 요청 객체를 제대로 생성했는지 확인하기 위한 것이다:
req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
if err != nil {
t.Fatal(err)
}이는 내 코드에 대한 assertion이 아니다. 아직 서버조차 호출하지 않았기 때문이다. 이 코드가 실패한다면 Go 표준 라이브러리 어딘가에서 뭔가 이상한 일이 일어난 것이다.
반면 독자가 if got, want :=를 보는 순간만큼은 내가 테스트 중인 코드에 대해 무언가를 assertion하고 있다는 것을 확신할 수 있다:
if got, want := res.StatusCode, tt.statusExpected; got != want {
t.Fatalf("httpStatus=%v, want=%v", got, want)
}왜 서드파티 테스트 assertion 라이브러리를 사용하지 않을까?
testify나 is 같은 서드파티 테스팅 라이브러리를 열렬히 사용하고 있다면 이 글이 터무니없게 들릴지도 모른다. 그런 라이브러리들은 표현력 있는 테스트 출력과 명확한 assertion을 모두 제공하는데, 왜 나는 사용하지 않을까?
나는 Python에서 Go로 넘어왔기 때문에 Go가 Python의 unittest.assertEqual 같은 API를 제공하지 않는다는 사실이 터무니없게 느껴졌다. 나는 바로 목(mock)을 만들고 assertion을 하기 위해 서드파티 라이브러리를 찾았지만, 더 경험 많은 팀원들이 Go 표준 라이브러리의 테스팅 API를 대신 써보라고 권했다.
나는 서드파티 라이브러리보다 Go 표준 테스팅 라이브러리의 미니멀리즘과 명시성을 더 선호하게 되었다. 라이브러리는 관리해야 할 의존성이 하나 더 늘어나고 버그를 유발할 수 있는 추상화 계층이 하나 더 추가되기 때문이다.
크레딧
나는 이 기법을 Litestream의 저자인 Ben Johnson에게서 배웠고, 그는 다시 Go 표준 라이브러리에서 가끔 사용되는 것을 보고 배웠다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기