Testing in Go: philosophy and tools

Ben Hoyt

Go에서의 테스트: 철학과 도구

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Go 프로그래밍 언어는 테스트를 작성하고 실행하기 위한 도구를 기본으로 제공한다. 표준 라이브러리의 testing 패키지와 테스트 스위트를 실행하는 go test 명령이 그것이다. 언어 자체와 마찬가지로, Go의 테스트 작성 철학은 미니멀하다. 가벼운 testing 패키지와 순수 Go로 작성된 헬퍼 함수를 활용하라는 것이다. 테스트도 그냥 코드라는 발상이다. Go 개발자라면 이미 Go의 추상화와 타입을 이용해 코드를 작성하는 법을 알고 있으므로, 테스트 작성을 위해 별도의 특이한 도메인 특화 언어를 배울 필요가 없다는 것이 그 취지다.

Brian Kernighan과 Alan Donovan이 쓴 The Go Programming Language는 이러한 철학을 다음과 같이 요약한다. 테스트를 다룬 11장에서는 이렇게 말한다:

Go를 처음 접하는 많은 사람들은 Go 테스트 프레임워크의 미니멀리즘에 놀라곤 한다. 다른 언어의 프레임워크들은 테스트 함수를 식별하는 메커니즘(종종 리플렉션이나 메타데이터를 이용), 테스트 실행 전후에 “setup”과 “teardown” 작업을 수행하기 위한 훅, 그리고 일반적인 단언을 검증하고 값을 비교하며 오류 메시지를 형식화하고 실패한 테스트를 중단(종종 예외를 사용)하기 위한 유틸리티 함수 라이브러리를 제공한다. 이러한 메커니즘 덕분에 테스트를 매우 간결하게 만들 수는 있지만, 그 결과로 나온 테스트는 마치 외국어로 작성된 것처럼 느껴지는 경우가 많다.

이를 실제로 살펴보기 위해, testing 패키지와 순수 Go만을 이용해 절댓값 함수 Abs()를 테스트하는 간단한 예제를 보자:

    func TestAbs(t *testing.T) {
        got := Abs(-1)
        if got != 1 {
            t.Errorf("Abs(-1) = %d; want 1", got)
        }
    }

이와 대조되는 다음 버전은 Go에서 RSpec 스타일의 테스트를 작성할 수 있게 해주는 인기 있는(다만 필자가 보기엔 관용적이지 않은) Ginkgo 라이브러리를 사용한 것이다:

    Describe("Abs", func() {
        It("returns correct abs value for -1", func() {
            got := Abs(-1)
            Expect(got).To(Equal(1))
        })
    })

함수 Describe, Expect 등은 테스트를 “영어처럼 읽히게” 만들지만, 그만큼 전혀 새로운 하위 언어를 배워야 한다는 뜻이기도 하다. Donovan과 같은 Go 컨트리뷰터들의 생각은 언어 자체에 이미 ==!= 같은 도구가 내장되어 있는데, 굳이 To(Equal(x))가 왜 필요한가 하는 것이다.

그렇다고 Go가 이러한 라이브러리 사용을 막지는 않으므로, 다른 언어에서 넘어온 개발자들은 오히려 순수 testing보다 이런 라이브러리가 더 익숙하다고 느끼기도 한다. 비교적 가벼운 라이브러리 중 하나는 일반적인 단언 함수인 assert.Equal() 등을 추가하는 testify/assert와, setup 및 teardown 같은 테스트 스위트 유틸리티를 추가하는 testify/suite다. “Awesome Go” 웹사이트에서는 이러한 서드파티 패키지의 방대한 목록을 제공한다.

testing 패키지에 속하지 않지만 유용한 테스트 도구 중 하나는 reflect.DeepEqual()이다. 이는 리플렉션을 이용해 “깊은 동등성(deep equality)”, 즉 포인터를 따라가고 맵이나 배열 등 내부로 재귀적으로 들어가 비교한 동등성을 판단하는 표준 라이브러리 함수다. JSON 객체나 포인터를 포함한 구조체 같은 것을 비교할 때 유용하다. 이를 발전시킨 라이브러리로는 Google의 go-cmp 패키지와 Daniel Nichter의 deep이 있는데, DeepEqual과 유사하지만 단순히 불리언을 반환하는 대신 무엇이 다른지 사람이 읽기 쉬운 diff 형태로 보여준다. 예를 들어, go-cmp를 이용해 MakeUsers() 함수를 테스트하는(의도적으로 망가뜨린) 예제는 다음과 같다:

    func TestMakeUser(t *testing.T) {
        got := MakeUser("Bob Smith", "[email protected]", 42)
        want := &User{
            Name:  "Bob Smith",
            Email: "[email protected]",
            Age:   42,
        }
        if diff := cmp.Diff(want, got); diff != "" {
            t.Errorf("MakeUser() mismatch (-want +got):\n%s", diff)
        }
    }

그리고 사람이 읽기 쉬운 출력은 다음과 같다:

    user_test.go:16: MakeUser() mismatch (-want +got):
          &main.User{
            Name:  "Bob Smith",
        -   Email: "[email protected]",
        +   Email: "[email protected]",
            Age:   42,
          }

내장 testing 기능

내장 testing 패키지에는 정보를 기록하고 실패를 보고하거나, 런타임에 테스트를 건너뛰거나, “short” 모드에서만 테스트를 실행하는 등 다양한 함수가 포함되어 있다. short 모드는 실행 시간이 길거나 설정 과정이 많은 테스트를 건너뛸 수 있게 해 주어 개발 중에 유용하다. 이 모드는 -test.short 커맨드라인 인수를 이용해 활성화한다.

Go의 테스트 러너는 기본적으로 테스트를 순차적으로 실행하지만, 명시적으로 표시된 테스트를 여러 코어에서 동시에 실행할 수 있게 해주는 선택적 Parallel() 함수를 제공한다.

Go 1.14에서는 testing 패키지에 테스트가 완료될 때 호출될 함수를 등록하는 Cleanup() 함수가 추가됐다. 이는 테스트가 끝난 뒤 데이터베이스 테이블을 삭제하는 등 teardown 과정을 단순화하는 내장 방법이다:

    func createDatabase(t *testing.T) {
        // ... code to create a test database
        t.Cleanup(func() {
            // ... code to delete the test database
            // runs when the test finishes (success or failure)
        })
    }

    func TestFetchUser(t *testing.T) {
        createDatabase(t) // creates database and registers cleanup
        user, err := FetchUser("[email protected]")
        if err != nil {
            t.Fatalf("error fetching user: %v", err)
        }
        expected := &User{"Bob Smith", "[email protected]", 42}
        if !reflect.DeepEqual(user, expected) {
            t.Fatalf("expected user %v, got %v", expected, user)
        }
    }

Go 1.15에서는 현재 테스트를 위한 임시 디렉터리를 생성(하고 정리)하는 테스트 헬퍼인 TempDir()가 추가될 예정이다. testing 패키지에 무언가를 추가하는 기준은 매우 높지만, Go 코어 팀의 Russ Cox는 이 추가에 대해 다음과 같이 승인 의사를 밝혔다: “임시 디렉터리는 충분히 다양한 테스트에서 등장하므로 testing 패키지에 포함될 만한 것 같다.

테이블 주도 테스트

Go에서 다양한 엣지 케이스를 테스트하면서 반복을 피하기 위해 흔히 쓰이는 관용구를 “테이블 주도 테스트(table-driven tests)”라고 한다. 이 기법은 테스트 케이스를 “slice”(Go에서 크기 조절이 가능한 배열에 대한 뷰를 일컫는 용어)에 담아 순회하면서 각 반복마다 실패를 보고하는 방식이다:

    func TestAbs(t *testing.T) {
        tests := []struct {
            input    int
            expected int
        }{
            {1, 1},
            {0, 0},
            {-1, 1},
            {-maxInt, maxInt},
            {maxInt, maxInt},
        }
        for _, test := range tests {
            actual := Abs(test.input)
            if actual != test.expected {
                t.Errorf("Abs(%d) = %d; want %d", test.input, actual, test.expected)
            }
        }
    }

t.Errorf() 호출은 실패를 보고하지만 테스트 실행을 중단하지 않으므로 여러 실패를 한 번에 보고할 수 있다. 이러한 테이블 주도 테스트 스타일은 표준 라이브러리 테스트 전반에서 흔히 볼 수 있다(예를 들어 fmt 테스트). Go 1.7에서 도입된 Subtests 기능은 커맨드라인에서 개별 서브 테스트를 실행할 수 있게 하고, 실패와 병렬 실행에 대한 제어도 더 세밀하게 할 수 있게 해준다.

Mock과 인터페이스

Go의 잘 알려진 언어적 특징 중 하나는 구조적 타이핑(structurally-typed) 인터페이스로, 흔히 “컴파일 타임 덕 타이핑(compile-time duck typing)”이라고도 불린다. “Go의 인터페이스는 객체의 동작을 지정하는 방법을 제공한다. 무언가가 이것을 할 수 있다면, 여기에서 사용할 수 있다.” 인터페이스는 런타임에 동작을 달리해야 할 때마다 중요하며, 물론 테스트도 예외가 아니다. 예를 들어, Go 코어 컨트리뷰터인 Andrew Gerrand가 2014년 “Testing Techniques” 발표 슬라이드에서 말했듯이, 파일 형식 파서는 다음과 같이 구체적인 파일 타입을 인자로 받아서는 안 된다:

    func Parse(f *os.File) error { ... }

대신 Parse()는 필요한 기능만을 구현하는 작은 인터페이스를 받는 것이 좋다. 이런 경우에는 어디서나 쓰이는 io.Reader가 좋은 선택이다:

    func Parse(r io.Reader) error { ... }

이렇게 하면 파서에는 io.Reader를 구현하는 모든 것을 전달할 수 있는데, 여기에는 파일, 문자열 버퍼, 네트워크 연결 등이 포함된다. 또한 테스트하기도 훨씬 쉬워진다(아마도 strings.Reader를 사용하게 될 것이다).

테스트에서 거대한 인터페이스 중 일부분만 사용한다면, 예를 들어 여러 메서드를 가진 API 중 하나의 메서드만 사용한다면, 인터페이스를 임베드(embed)하여 API 계약을 충족시키는 새로운 구조체 타입을 만들고 호출되는 메서드만 오버라이드할 수 있다. 이 기법의 전체 예제는 이 Go Playground 코드에서 볼 수 있다.

GoMock이나 mockery 같은 다양한 서드파티 도구들은 인터페이스 정의로부터 mock 코드를 자동으로 생성해 준다. 하지만 Gerrand는 직접 작성한 fake를 선호한다:

[gomock 같은 mocking 라이브러리]도 괜찮지만, 전체적으로 보면 직접 작성한 fake가 대체로 이해하기 쉽고 무슨 일이 일어나는지 더 명확하게 볼 수 있다고 생각한다. 하지만 나는 엔터프라이즈 Go 프로그래머가 아니니 어쩌면 사람들이 그런 라이브러리를 필요로 할지도 모르겠다. 잘 모르겠지만, 어쨌든 내 조언은 그렇다.

테스트 가능한 예제

Go의 패키지 문서는 소스 코드의 주석으로부터 생성된다. 코드 주석에 마크업을 많이 사용하는 Javadoc이나 C#의 문서 시스템과 달리, Go의 접근 방식은 소스 코드의 주석이 소스 자체에서도 여전히 읽기 쉬워야 하며 마크업으로 어지럽혀져서는 안 된다는 것이다.

문서 예제에서도 비슷한 접근을 취한다. 이는 테스트가 실행될 때 자동으로 실행되고 생성된 문서에 포함되는 실행 가능한 코드 조각이다. Python의 doctest와 마찬가지로, 테스트 가능한 예제는 표준 출력으로 내용을 쓰고 그 출력을 기대 출력과 비교하여 문서화된 예제에서 회귀가 발생하지 않도록 한다. 다음은 Abs() 함수에 대한 테스트 가능한 예제다:

    func ExampleAbs() {
        fmt.Println(Abs(5))
        fmt.Println(Abs(-42))
        // Output:
        // 5
        // 42
    }

Example 함수는 *_test.go 파일 안에 있어야 하며 Example 접두사를 붙여야 한다. 테스트 러너가 실행될 때 Output: 주석이 파싱되어 실제 출력과 비교되고, 두 값이 다르면 테스트 실패가 발생한다. 이러한 예제는 생성된 문서에 실행 가능한 Go Playground 스니펫으로 포함되는데, 예를 들어 strings 패키지에서 볼 수 있다.

벤치마킹

테스트 외에도 testing 패키지는 시간 측정을 동반한 벤치마크를 실행할 수 있게 해준다. 이는 표준 라이브러리 전반에서 실행 속도 회귀가 발생하지 않도록 하는 데 많이 사용된다. 벤치마크는 go test-bench= 옵션을 붙여 자동으로 실행할 수 있다. 유명한 Go 저자인 Dave Cheney는 “How to write benchmarks in Go”라는 글에서 이에 대해 잘 정리해 놓았다.

예제로, strings.TrimSpace() 함수에 대한 표준 라이브러리의 벤치마크를 보자(테이블 주도 접근 방식과 서브 벤치마크를 만들기 위한 b.Run() 사용에 주목하라):

    func BenchmarkTrimSpace(b *testing.B) {
        tests := []struct{ name, input string }{
            {"NoTrim", "typical"},
            {"ASCII", "  foo bar  "},
            {"SomeNonASCII", "    \u2000\t\r\n x\t\t\r\r\ny\n \u3000    "},
            {"JustNonASCII", "\u2000\u2000\u2000☺☺☺☺\u3000\u3000\u3000"},
        }
        for _, test := range tests {
            b.Run(test.name, func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                    TrimSpace(test.input)
                }
            })
        }
    }

go test 도구는 수치를 보고하고, benchstat 같은 프로그램을 이용해 변경 전후의 시간을 비교할 수 있다. benchstat의 출력은 성능 향상을 보여주기 위해 Go 커밋 메시지에 흔히 포함된다. 예를 들어 change 152917에서는 다음과 같다:

    name                      old time/op  new time/op  delta
    TrimSpace/NoTrim-8        18.6ns ± 0%   3.8ns ± 0%  -79.53%  (p=0.000 n=5+4)
    TrimSpace/ASCII-8         33.5ns ± 2%   6.0ns ± 3%  -82.05%  (p=0.008 n=5+5)
    TrimSpace/SomeNonASCII-8  97.1ns ± 1%  88.6ns ± 1%   -8.68%  (p=0.008 n=5+5)
    TrimSpace/JustNonASCII-8   144ns ± 0%   143ns ± 0%     ~     (p=0.079 n=4+5)

이는 TrimSpace의 ASCII 고속 경로 덕분에 ASCII만으로 이루어진 입력이 약 5배 빨라졌지만, “SomeNonASCII” 서브 테스트는 약 9% 느려졌음을 보여준다.

무엇이 느리게 동작하는지 진단하려면 테스트 실행 시 -cpuprofile 옵션과 같은 내장 프로파일링 도구를 사용할 수 있다. 내장된 go tool pprof플레임 그래프를 비롯한 다양한 형식으로 프로파일 결과를 보여준다.

go test 명령

Go는 테스트가 어디에 위치해야 하는지(*_test.go라는 이름의 파일)나 테스트 함수의 이름을 어떻게 지어야 하는지(반드시 Test 접두사를 붙여야 함)에 대해 분명한 의견을 가지고 있다. 하지만 이렇게 의견이 분명한 덕분에 go test 도구는 어디를 찾아 어떻게 테스트를 실행해야 할지 정확히 알 수 있다. 테스트가 어디에 있는지를 설명하는 makefile이나 메타데이터가 필요 없다. 파일과 함수가 표준 방식으로 이름지어져 있다면 Go는 이미 어디를 봐야 할지 알고 있다.

go test 명령은 겉보기에는 단순하지만 테스트와 벤치마크를 실행하고 필터링하기 위한 다양한 옵션을 제공한다. 몇 가지 예시는 다음과 같다:

    go test             # run tests in current directory
    go test package     # run tests for given package
    go test ./...       # run tests for current dir and all sub-packages
    go test -run=foo    # run tests matching regex "foo"
    go test -cover      # run tests and output code coverage
    go test -bench=.    # also run benchmarks
    go test -bench=. -cpuprofile cpu.out
                        # run benchmarks, record profiling info

Go test의 -cover 모드는 go tool cover -html=coverage.out을 이용해 HTML로 볼 수 있는 코드 커버리지 프로파일을 생성한다. Go의 코드 커버리지 도구가 어떻게 동작하는지 설명하면서, Go 공동 창시자인 Rob Pike는 이렇게 말했다:

Go의 새로운 테스트 커버리지 도구를 위해 우리는 동적 디버깅을 피하는 다른 접근 방식[바이너리를 계측하는 방식과 다른]을 택했다. 아이디어는 간단하다. 컴파일 전에 패키지의 소스 코드를 재작성하여 계측 코드를 추가하고, 수정된 소스를 컴파일 및 실행한 뒤 통계를 덤프하는 것이다. go 명령이 소스에서 테스트를 거쳐 실행까지의 흐름을 제어하기 때문에 이러한 재작성은 쉽게 마련할 수 있다.

정리하며

Go의 testing 라이브러리는 단순하지만 확장 가능하며, go test 러너는 테스트 실행, 벤치마킹, 프로파일링, 코드 커버리지 리포팅을 통해 이를 잘 보완한다. 순수 testing 패키지만으로도 go 멀리 갈 수 있다 — 필자는 Go의 미니멀한 접근 방식이 테스트에 대해 다르게 생각하고 인터페이스나 구조체 합성 같은 언어 고유의 기능을 최대한 활용하도록 만드는 강제 장치라고 생각한다. 하지만 서드파티 라이브러리가 필요하다면 go get 한 번이면 바로 가져올 수 있다.

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

댓글