if got, want: A Simple Way to Write Better Go Tests

Michael Lynch

if got, want: 더 나은 Go 테스트를 작성하는 간단한 방법

훌륭한 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에서 gotwant라는 변수 이름을 재사용할 수 있습니다.

실제로 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에서 실제 값과 기대값을 빠르게 찾을 수 있습니다.

복사해서 붙여 넣기 쉽습니다

변수가 항상 gotwant라는 이름을 가지므로 assertion을 복사해 붙여 넣을 때 바꿀 부분이 많지 않습니다. 보통 할당 부분과 t.Errorf 안의 이름, 그리고 형식 지정자(예: %s%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 admin

if 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 :=를 보는 순간, 제가 테스트 중인 코드에 대해 무언가를 검증하고 있다는 사실을 확신할 수 있습니다:

if got, want := res.StatusCode, tt.statusExpected; got != want {
    t.Fatalf("httpStatus=%v, want=%v", got, want)
}

서드파티 테스트 assertion 라이브러리는 왜 사용하지 않을까요?

testifyis 같은 서드파티 테스트 라이브러리를 열렬히 사용하고 있다면 이 글이 터무니없게 들릴 수도 있습니다. 그런 라이브러리들은 표현력 있는 테스트 출력과 명확한 assertion을 모두 제공하는데, 왜 굳이 사용하지 않느냐고요?

저는 Python을 하다가 Go로 넘어왔기 때문에, Go에 Python의 unittest.assertEqual 같은 API가 없다는 사실이 터무니없게 느껴졌습니다. 그래서 바로 서드파티 라이브러리를 찾아 목(mock)을 만들고 assertion을 작성했는데, 경험이 더 많은 팀원들이 Go 표준 라이브러리의 테스트 API를 한번 써보라고 권했습니다.

결국 저는 서드파티 라이브러리보다 Go 표준 테스트 라이브러리의 미니멀함과 명시성을 더 선호하게 되었습니다. 라이브러리는 관리해야 할 의존성이 하나 더 늘고, 버그를 만들 수 있는 추상화 계층이 하나 더 추가되기 때문입니다.

크레딧

이 기법은 Litestream의 저자인 Ben Johnson에게서 배웠으며, 그는 다시 Go 표준 라이브러리에서 간간이 쓰이는 용법을 통해 배웠다고 합니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.