Using Subtests and Sub-benchmarks

1 개요[ | ]

Using Subtests and Sub-benchmarks
하위테스트 및 하위 벤치마크 사용
  • Marcel van Lohuizen 2016-10-03

2 소개[ | ]

Go 1.7의 testing 패키지에는 하위테스트 및 하위 벤치마크를 생성할 수 있는 T 및 B 타입에 대한 Run 메소드가 도입되었다. 하위테스트 및 하위 벤치마크의 도입으로 오류 처리, 명령줄에서 실행할 테스트에 대한 세밀한 제어, 병렬 처리 제어가 가능하며 종종 더 간단하고 유지관리하기 쉬운 코드가 된다.

3 테이블 드리븐 테스트 기본[ | ]

자세한 내용을 살펴보기 전에 Go에서 테스트를 작성하는 일반적인 방법에 대해 논의해 본다. 다음과 같이 테스트케이스 조각을 반복하여 일련의 점검을 구현할 수 있다.

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},     // 잘못된 위치 이름
        {"12:31", "America/New_York", "7:31"}, // 07:31이어야 한다
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        loc, err := time.LoadLocation(tc.loc)
        if err != nil {
            t.Fatalf("could not load location %q", tc.loc)
        }
        gmt, _ := time.Parse("15:04", tc.gmt)
        if got := gmt.In(loc).Format("15:04"); got != tc.want {
            t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)
        }
    }
}

일반적으로 테이블 드리븐 테스트라고 하는 이 접근방식은 각 테스트에 대해 동일한 코드를 반복하는 것과 비해, 반복 코드의 양이 줄고 많은 테스트케이스를 추가하는 것이 간단해진다.

4 테이블 드리븐 벤치마크[ | ]

Go 1.7 이전에는 벤치마크에 이러한 테이블 드리븐 접근방식을 사용할 수 없었다. 벤치마크는 전체 함수의 성능을 테스트하므로 벤치마크를 반복하면 모든 함수가 단일 벤치마크로 측정된다.

일반적인 해결 방법은 각각 다른 매개변수를 사용하여 공통 함수를 호출하는 별도의 최상위 벤치마크를 정의하는 것이다. 예를 들어 1.7 이전에는 strconv 패키지의 AppendFloat에 대한 벤치마크가 다음과 같았다.

func benchmarkAppendFloat(b *testing.B, f float64, fmt byte, prec, bitSize int) {
    dst := make([]byte, 30)
    b.ResetTimer() // Overkill here, but for illustrative purposes.
    for i := 0; i < b.N; i++ {
        AppendFloat(dst[:0], f, fmt, prec, bitSize)
    }
}

func BenchmarkAppendFloatDecimal(b *testing.B) { benchmarkAppendFloat(b, 33909, 'g', -1, 64) }
func BenchmarkAppendFloat(b *testing.B)        { benchmarkAppendFloat(b, 339.7784, 'g', -1, 64) }
func BenchmarkAppendFloatExp(b *testing.B)     { benchmarkAppendFloat(b, -5.09e75, 'g', -1, 64) }
func BenchmarkAppendFloatNegExp(b *testing.B)  { benchmarkAppendFloat(b, -5.11e-95, 'g', -1, 64) }
func BenchmarkAppendFloatBig(b *testing.B)     { benchmarkAppendFloat(b, 123456789123456789123456789, 'g', -1, 64) }
...

Go 1.7에서는 Run 메소드를 사용하여, 동일한 벤치마크 세트를 단일 최상위 벤치마크로 표현할 수 있다.

func BenchmarkAppendFloat(b *testing.B) {
    benchmarks := []struct{
        name    string
        float   float64
        fmt     byte
        prec    int
        bitSize int
    }{
        {"Decimal", 33909, 'g', -1, 64},
        {"Float", 339.7784, 'g', -1, 64},
        {"Exp", -5.09e75, 'g', -1, 64},
        {"NegExp", -5.11e-95, 'g', -1, 64},
        {"Big", 123456789123456789123456789, 'g', -1, 64},
        ...
    }
    dst := make([]byte, 30)
    for _, bm := range benchmarks {
        b.Run(bm.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                AppendFloat(dst[:0], bm.float, bm.fmt, bm.prec, bm.bitSize)
            }
        })
    }
}

Run 메소드를 호출할 때마다 별도의 벤치마크가 생성된다. Run 메소드를 호출하며, 그것을 둘러싸고 있는 벤치마크 함수는 한 번만 실행되며 측정되지 않는다.

새 코드에는 더 많은 행이 있지만 유지관리가 더 쉽고 가독성이 높으며 테스트에 일반적으로 사용되는 테이블 드리븐 접근방식과 같다. 또한 타이머를 리셋할 필요 없이 Run들 사이에 공통 셋업 코드가 공유된다.

5 하위테스트를 사용하는 테이블 드리븐 테스트[ | ]

Go 1.7에는 Run 하위테스트를 만드는 방법도 도입되었다. 이 테스트는 하위테스트를 사용하여 이전 예제를 다시 작성한 것이다.

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},
        {"12:31", "America/New_York", "7:31"},
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        t.Run(fmt.Sprintf("%s in %s", tc.gmt, tc.loc), func(t *testing.T) {
            loc, err := time.LoadLocation(tc.loc)
            if err != nil {
                t.Fatal("could not load location")
            }
            gmt, _ := time.Parse("15:04", tc.gmt)
            if got := gmt.In(loc).Format("15:04"); got != tc.want {
                t.Errorf("got %s; want %s", got, tc.want)
            }
        })
    }
}

가장 먼저 주목해야 할 것은 두 출력의 차이이다. 원래 것은 다음과 같이 출력된다.

--- FAIL: TestTime (0.00s)
    time_test.go:62: could not load location "Europe/Zuri"

2개의 오류가 있더라도, Fatalf에 대한 호출에서 테스트 실행이 중단되고 두 번째 테스트는 실행되지 않는다.

다음은 Run을 사용하여 둘다 프린트하도록 한 것이다.

--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:84: could not load location
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:88: got 07:31; want 7:31

Fatal 및 파생함수들은 하위테스트를 건너뛰게 하지만, 상위 또는 후속 하위테스트까지 건너뛰게 하지는 않는다.

주목해야 할 또 다른 사항은 새 구현에서 더 짧은 오류 메시지이다. 하위테스트 이름은 하위테스트를 고유하게 식별하므로 오류 메시지 내에서 테스트를 다시 식별할 필요가 없다.

다음 섹션에서 설명하는 것처럼 하위테스트 또는 하위 벤치마크를 사용하면 여러가지 다른 이점이 있다.

6 특정 테스트 또는 벤치마크 실행[ | ]

하위테스트와 하위 벤치마크는 모두 -run 또는 -bench 플래그를 사용하여 명령줄에서 구분할 수 있다 . 두 플래그 모두 하위테스트 또는 하위 벤치마크의 전체 이름 중 해당 부분과 일치하는 슬래시로 구분된 정규식 목록을 사용한다.

하위테스트 또는 하위 벤치마크의 전체 이름은 슬래시로 구분된 해당 이름과 최상위 레벨부터 시작하는 모든 상위 이름의 목록이다. 이름은 최상위 테스트 및 벤치마크에 해당하는 함수 이름이며 그렇지 않은 경우의 Run의 첫 번째 인수이다. 표시 및 구문 분석 문제를 방지하기 위해 공백을 밑줄로 바꾸고 인쇄할 수 없는 문자를 이스케이프 처리하여 이름을 정제한다. -run 또는 -bench 플래그에 전달된 정규식에 동일하게 정제가 적용된다.

몇 가지 예:

유럽 ​​시간대를 사용하는 테스트 실행:

$ go test -run=TestTime/"in Europe"
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:85: could not load location

정오 이후 시간에만 테스트 실행:

$ go test -run=Time/12:[0-9] -v
=== RUN   TestTime
=== RUN   TestTime/12:31_in_Europe/Zuri
=== RUN   TestTime/12:31_in_America/New_York
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
        time_test.go:85: could not load location
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:89: got 07:31; want 7:31

약간 특이한 점은 -run=TestTime/New_York을 사용해도 어떤 테스트와도 매칭되지 않는다는 것이다. 위치 이름에 있는 슬래시도 구분 기호로 취급되기 때문이다. 대신 다음과 같이 사용하자.

$ go test -run=Time//New_York
--- FAIL: TestTime (0.00s)
    --- FAIL: TestTime/12:31_in_America/New_York (0.00s)
        time_test.go:88: got 07:31; want 7:31

-run에 전달된 문자열의 //에 유의하자. 시간대 이름 America/New_York에 있는 /는 하위테스트에서 구분자인 것처럼 처리된다. 패턴(TestTime)의 첫 번째 정규표현식은 최상위 테스트에 매칭된다. 두 번째 정규식(빈 문자열)은 무엇이든 매칭된며, 이 경우에는 Time과 대륙 부분이다. 세 번째 정규표현식(New_York)은 위치의 도시 부분과 매칭된다.

이름의 슬래시를 구분자로 취급하면 사용자가 이름을 변경할 필요 없이 테스트 계층 구조를 리팩터링할 수 있다. 또한 이스케이프 규칙을 단순화한다. 사용자는 이름에서 슬래시를 이스케이프 처리해야 한다. 예를 들어 이것이 문제가 될 경우 백슬래시로 교체해야 한다.

고유하지 않은 테스트 이름에는 고유한 시퀀스 번호가 추가된다. 따라서 하위테스트에 대한 명확한 명명 체계가 없고 하위테스트를 시퀀스 번호로 쉽게 식별할 수 있는 경우라는 빈 문자열을 전달해도 된다.

7 셋업 및 해체[ | ]

하위테스트 및 하위 벤치마크를 사용하여 공통 셋업 및 해체 코드를 관리할 수 있다.

func TestFoo(t *testing.T) {
    // <setup code>
    t.Run("A=1", func(t *testing.T) { ... })
    t.Run("A=2", func(t *testing.T) { ... })
    t.Run("B=1", func(t *testing.T) {
        if !test(foo{B:1}) {
            t.Fail()
        }
    })
    // <tear-down code>
}

감싸고 있는 하위테스트 중 하나라도 실행되면 설정 및 해체 코드가 실행되며 최대 한 번만 실행된다. 이는 하위테스트 중 하나가 Skip, Fail, Fatal를 호출하는 경우에도 적용된다.

8 병렬성 제어[ | ]

하위테스트를 통해 병렬 처리를 세밀하게 제어할 수 있다. 하위테스트를 사용하는 방법을 이해하려면 병렬 테스트의 의미를 이해하는 것이 중요하다.

각 테스트는 테스트 함수과 연관된다. 테스트 함수가 testing.T 인스턴스의 Parallel 메소드를 호출하는 경우, 그 테스트를 병렬 테스트라고 한다. 병렬 테스트는 순차 테스트와 동시에 실행되지 않으며 상위 테스트의 호출 테스트 함수가 반환될 때까지 실행이 일시 중단된다. -parallel 플래그는 병렬로 실행할 수 있는 최대 병렬 테스트 수를 정의한다.

테스트는, 테스트 함수가 반환되고 모든 하위테스트가 완료될 때까지 차단된다. 이는 순차 테스트에 의해 실행되는 병렬 테스트가 다른 후속 순차 테스트가 실행되기 전에 완료될 것이라는 것을 의미한다.

이러한 동작은 Run과 최상위 테스트가 생성하는 테스트에 대해서도 동일하다. 실제로 최상위 테스트는 내부적으로 숨겨진 마스터 테스트의 하위테스트로 구현된다.

8.1 테스트 그룹을 병렬로 실행[ | ]

위의 시맨틱으로 테스트 그룹을 서로 병렬로 실행할 수 있지만, 다른 병렬 테스트와 함께 실행할 수는 없다.

func TestGroupedParallel(t *testing.T) {
    for _, tc := range testCases {
        tc := tc // capture range variable
        t.Run(tc.Name, func(t *testing.T) {
            t.Parallel()
            if got := foo(tc.in); got != tc.out {
                t.Errorf("got %v; want %v", got, tc.out)
            }
            ...
        })
    }
}

외부 테스트는, Run에 의해 시작된 모든 병렬 테스트가 완료될 때까지 완료되지 않는다. 결과적으로 다른 병렬 테스트는 이러한 병렬 테스트와 병렬로 실행할 수 없다.

tc가 올바른 인스턴스에 바인딩되도록 하려면 범위 변수를 캡처해야 한다.

8.2 병렬 테스트 그룹 후 정리[ | ]

이전 예제에서 다른 테스트를 시작하기 전에 병렬 테스트 그룹이 완료되기를 기다리는 시맨틱을 사용했다. 동일한 기법을 사용하여 공통 리소스를 공유하는 병렬 테스트 그룹을 정리할 수 있다.

func TestTeardownParallel(t *testing.T) {
    // <setup code>
    // This Run will not return until its parallel subtests complete.
    t.Run("group", func(t *testing.T) {
        t.Run("Test1", parallelTest1)
        t.Run("Test2", parallelTest2)
        t.Run("Test3", parallelTest3)
    })
    // <tear-down code>
}

병렬 테스트 그룹을 기다리는 동작은 이전 예제와 동일하다.

9 결론[ | ]

Go 1.7에 추가된 하위테스트 및 하위 벤치마크를 통해, 기존 도구와 잘 어울리는 자연스러운 방식으로 구조화된 테스트 및 벤치마크를 작성할 수 있다. 이에 대해 생각할 수 있는 한 가지 방법은 테스트 패키지의 이전 버전에 1레벨 계층 구조가 있다는 것이다. 즉, 패키지 레벨 테스트는 일련의 개별 테스트 및 벤치마크로 구성되었다. 이제 해당 구조가 재귀적으로 개별 테스트 및 벤치마크로 확장되었다. 실제로 구현 시 최상위 테스트 및 벤치마크는 암시적 마스터 테스트 및 벤치마크의 하위테스트 및 하위 벤치마크인 것처럼 추적된다. 처리는 실제로 모든 수준에서 동일하다.

이 구조를 정의하는 테스트 기능을 통해 특정 테스트 케이스의 세분화된 실행, 공유 셋업 및 해체, 테스트 병렬 처리에 대한 더 나은 제어가 가능하다.사람들이 찾은 다른 활용사례를 보게 되어 기쁘다. 잘 사용해 보자.

10 같이 보기[ | ]

11 참고[ | ]

문서 댓글 ({{ doc_comments.length }})
{{ comment.name }} {{ comment.created | snstime }}