Skip to content

테스트

개발자에게 있어良好的인 테스트는 프로그램의 오류를 미리 발견하여 유지보수가 제때 이루어지지 않아 발생하는 버그로 인한 정신적 부담을 줄여주므로 테스트를 잘 작성하는 것은 매우 중요합니다. Go 는 테스트 측면에서 매우 실용적인 명령줄 도구 go test 를 제공하며, 표준 라이브러리와 많은 오픈소스 프레임워크에서 테스트의 모습을 볼 수 있습니다. 이 도구는 사용이 매우 편리하며 현재 다음 몇 가지 테스트를 지원합니다.

  • 예시 테스트
  • 단위 테스트
  • 벤치마크 테스트
  • 퍼즈 테스트

Go 에서 대부분의 API 는 표준 라이브러리 testing 패키지를 통해 제공됩니다.

TIP

명령줄에서 go help testfunc 명령을 실행하면 위의 네 가지 테스트 유형에 대한 Go 공식 설명을 볼 수 있습니다.

작성 규약

테스트 작성을 시작하기 전에 몇 가지 규약에 유의해야 하며, 이를 통해 이후 학습이 더 편리해질 것입니다.

  • 테스트 패키지: 테스트 파일은 별도의 패키지에 두는 것이 좋으며, 이 패키지는 일반적으로 test 라고 명명합니다.
  • 테스트 파일: 테스트 파일은 일반적으로 _test.go 로 끝납니다. 예를 들어某一기능을 테스트하려면 function_test.go 로 명명하고, 테스트 유형에 따라 더 세분화하려면 테스트 유형을 파일 접두사로 사용할 수 있습니다. 예를 들어 benchmark_marshaling_test.go 또는 example_marshaling_test.go 와 같습니다.
  • 테스트 함수: 각 테스트 파일에는 서로 다른 테스트를 위한 여러 개의 테스트 함수가 있습니다. 서로 다른 테스트 유형의 경우 테스트 함수의 명명 스타일도 다릅니다. 예를 들어 예시 테스트는 ExampleXXXX, 단위 테스트는 TestXXXX, 벤치마크 테스트는 BenchmarkXXXX, 퍼즈 테스트는 FuzzXXXX 로, 이렇게 하면 주석이 없어도 어떤 유형의 테스트인지 알 수 있습니다.

TIP

패키지 이름이 testdata 일 때, 해당 패키지는 일반적으로 테스트를 위한 보조 데이터를 저장하는 데 사용되며, 테스트 실행 시 Go 는 testdata 라는 이름의 패키지를 무시합니다.

위의 규약을 준수하고 양호한 테스트 스타일을养成하면 향후 유지보수에 많은 번거로움을 덜 수 있습니다.

테스트 실행

테스트 실행은 주로 go test 명령을 사용하며, 아래에 실제 코드로 예시를 들겠습니다. /say/hello.go 파일이 테스트 대상 파일이고 코드는 다음과 같습니다.

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

그리고 테스트 파일 /test/example_test.go 의 코드는 다음과 같습니다.

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

이러한 테스트를 실행하는 방법은 여러 가지가 있습니다. 예를 들어 test 패키지 하위의 모든 테스트 케이스를 실행하려면 test 디렉토리에서 다음 명령을 직접 실행하면 됩니다.

sh
$ go test ./
PASS
ok      golearn/test    0.422s

./는 현재 디렉토리를 나타내며, Go 는 test 디렉토리 하위의 모든 테스트 파일을 다시 컴파일한 후 모든 테스트 케이스를 모두 실행합니다. 결과에서 모든 테스트 케이스가 통과되었음을 알 수 있습니다. 그 뒤의 매개변수는 여러 디렉토리를 따를 수도 있습니다. 예를 들어 아래 명령에서 프로젝트의 메인 디렉토리에는 분명히 실행할 테스트 파일이 없습니다.

sh
$ go test ./ ../
ok      golearn/test
?       golearn [no test files]

TIP

실행 매개변수가 여러 패키지일 때 Go 는 이미 성공적으로 통과한 테스트 케이스를 다시 실행하지 않으며, 실행 시 행 끝에 (cached) 를 추가하여 출력 결과가 이전 캐시임을 나타냅니다. 테스트 플래그 매개변수가 다음 집합에 있을 때 Go 는 테스트 결과를 캐시하며, 그렇지 않으면 캐시하지 않습니다.

-benchtime, -cpu,-list, -parallel, -run, -short, -timeout, -failfast, -v

캐시를 비활성화하려면 -count=1 매개변수를 추가하면 됩니다.

물론 특정 테스트 파일 하나만 지정하여 실행할 수도 있습니다.

sh
$ go test example_test.go
ok      command-line-arguments  0.457s

또는 특정 테스트 파일의 특정 테스트 케이스 하나만 지정할 수도 있습니다. 예를 들어

sh
$ go test -run ExampleSay
PASS
ok      golearn/test    0.038s

위의 세 가지 상황은 모두 테스트를 완료했지만 출력 결과가 너무 간결합니다. 이때 -v 매개변수를 추가하여 출력 결과를 더 상세하게 만들 수 있습니다. 예를 들어

sh
$ go test ./ -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.040s

이제 각 테스트 케이스의 실행 순서, 소요 시간, 실행 상황 및 전체 소요 시간을 명확하게 볼 수 있습니다.

TIP

go test 명령은 기본적으로 모든 단위 테스트, 예시 테스트, 퍼즈 테스트를 실행하며, -bench 매개변수를 추가하면 모든 유형의 테스트가 실행됩니다. 예를 들어 아래 명령과 같습니다.

sh
$ go test -bench .

따라서 -run 매개변수를 사용하여 지정해야 합니다. 예를 들어 모든 벤치마크 테스트만 실행하는 명령은 다음과 같습니다.

sh
$ go test -bench . -run ^$

일반 매개변수

Go 테스트에는 매우 많은 플래그 매개변수가 있으며, 아래에서는 일반적인 매개변수만 소개하겠습니다. 더 많은 세부사항을 알고 싶다면 go help testflag 명령을 사용하여 직접 확인하는 것을 권장합니다.

매개변수설명
-o file컴파일 후 바이너리 파일 이름 지정
-c테스트 파일만 컴파일하고 실행하지 않음
-json테스트 로그를 json 형식으로 출력
-exec xprogxprog 를 사용하여 테스트 실행, go run 과 동일
-bench regexpregexp 와 일치하는 벤치마크 테스트 선택
-fuzz regexpregexp 와 일치하는 퍼즈 테스트 선택
-fuzztime t퍼즈 테스트 자동 종료 시간, t 는 시간 간격, 단위가 x 일 때 횟수를 나타냄, 예: 200x
-fuzzminimizetime t퍼즈 테스트 실행 최소 시간, 규칙은 위와 동일
-count n테스트 n 회 실행, 기본값 1 회
-cover테스트 커버리지 분석 활성화
-covermode set,count,atomic커버리지 분석 모드 설정
-cpu테스트 실행을 위한 GOMAXPROCS 설정
-failfast첫 테스트 실패 후 새 테스트 시작 안 함
-list regexpregexp 와 일치하는 테스트 케이스 목록
-parallel nt.Parallel 을 호출한 테스트 케이스의 병렬 실행 허용, n 값은 병렬 최대 수
-run regexpregexp 와 일치하는 테스트 케이스만 실행
-skip regexpregexp 와 일치하는 테스트 케이스 건너뜀
-timeout d단일 테스트 실행 시간이 d 를 초과하면 panic. d 는 시간 간격, 예: 1s,1ms,1ns 등
-shuffle off,on,N테스트 실행 순서 섞기, N 은 랜덤 시드, 기본 시드는 시스템 시간
-v더 상세한 테스트 로그 출력
-benchmem벤치마크 테스트 메모리 할당 통계
-blockprofile block.out테스트 중 고루틴 블로킹 상황 통계 및 파일 기록
-blockprofilerate n고루틴 블로킹 통계 빈도 제어, go doc runtime.SetBlockProfileRate 명령으로 세부사항 확인
-coverprofile cover.out커버리지 테스트 상황 통계 및 파일 기록
-cpuprofile cpu.outCPU 상황 통계 및 파일 기록
-memprofile mem.out메모리 할당 상황 통계 및 파일 기록
-memprofilerate n메모리 할당 통계 빈도 제어, go doc runtime.MemProfileRate 명령으로 세부사항 확인
-mutexprofile mutex.out락 경쟁 상황 통계 및 파일 기록
-mutexprofilefraction nn 개의 고루틴이 하나의 뮤텍스 락을 경쟁하는 상황 통계 설정
-trace trace.out실행 추적 상황을 파일에 기록
-outputdir directory위의 통계 파일 출력 디렉토리 지정, 기본값은 go test 실행 디렉토리

예시 테스트

예시 테스트는 다른 세 가지 테스트와 달리 프로그램의 문제점을 발견하기 위한 것이 아니라 주로某一기능의 사용 방법을 보여주는 문서 역할을 합니다. 예시 테스트는 공식적으로 정의된 개념도 아니고 강제적인 규범도 아니며, 더 많이 엔지니어링 관례에 따른 약속에 가깝고 준수 여부는 개발자에게 달려 있습니다. 예시 테스트는 표준 라이브러리에서 매우 자주 볼 수 있으며, 대개 공식이 작성한 표준 라이브러리 코드 예시입니다. 예를 들어 표준 라이브러리 context/example_test.goExampleWithDeadline 테스트 함수는 DeadlineContext 의 기본 사용 방법을 보여줍니다.

go
// This example passes a context with an arbitrary deadline to tell a blocking
// function that it should abandon its work as soon as it gets to it.
func ExampleWithDeadline() {
   d := time.Now().Add(shortDuration)
   ctx, cancel := context.WithDeadline(context.Background(), d)

   // Even though ctx will be expired, it is good practice to call its
   // cancellation function in any case. Failure to do so may keep the
   // context and its parent alive longer than necessary.
   defer cancel()

   select {
   case <-time.After(1 * time.Second):
      fmt.Println("overslept")
   case <-ctx.Done():
      fmt.Println(ctx.Err())
   }

   // Output:
   // context deadline exceeded
}

겉으로 보기에는 이 테스트 함수가 일반 함수와 같지만, 예시 테스트는 주로 Output 주석으로 구현됩니다. 테스트 대상 함수에 출력이 한 줄만 있을 때 Output 주석을 사용하여 출력을 검사합니다. 먼저 hello.go 라는 파일을 생성하고 다음 코드를 작성합니다.

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

SayHello 함수가 테스트 대상 함수이며, 그 후 테스트 파일 example_test.go 를 생성하고 다음 코드를 작성합니다.

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

함수에서 Output 주석은 함수 출력이 hello 인지 검사함을 나타냅니다. 이제 테스트 명령을 실행하여 결과를 확인합니다.

sh
$ go test -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.448s

결과에서 모든 테스트가 통과되었음을 알 수 있습니다. Output 에는 몇 가지 작성 방식이 있습니다. 첫 번째는 출력이 한 줄만 있는 경우로, 해당 함수의 출력이 hello 인지 검사함을 의미합니다.

// Output:
// hello

두 번째는 여러 줄 출력으로, 순서대로 출력이 일치하는지 검사합니다.

// Output:
// hello
// bye

세 번째는 순서 없는 출력으로, 순서 없이 여러 줄 출력을 매칭합니다.

// Unordered output:
// bye
// hello

주의할 점은 테스트 함수의 경우 마지막 몇 줄이 Output 주석일 때만 예시 테스트로 간주되며, 그렇지 않으면 일반 함수에 불과하여 Go 가 실행하지 않습니다.

단위 테스트

단위 테스트는 소프트웨어의 최소 테스트 가능 유닛을 테스트하는 것으로, 유닛의 크기 정의는 개발자에 따라 달라지며, 구조체 하나일 수도 있고 패키지 하나일 수도 있으며, 함수 하나일 수도 있고 타입 하나일 수도 있습니다. 아래는 여전히 예시를 통해 설명하겠습니다. 먼저 /tool/math.go 파일을 생성하고 다음 코드를 작성합니다.

go
package tool

type Number interface {
  ~int8 | ~int16 | ~int32 | ~int64 | ~int
}

func SumInt[T Number](a, b T) T {
  return a + b
}

func Equal[T Number](a, b T) bool {
  return a == b
}

그 후 테스트 파일 /tool_test/unit_test.go 를 생성합니다. 단위 테스트의 경우 파일명은 unit_test 로 하거나 테스트할 패키지나 기능을 파일 접두사로 사용할 수 있습니다.

go
package test_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

단위 테스트의 경우 각 테스트 케이스의 명명 스타일은 TestXXXX 이며, 함수의 입력 매개변수는 반드시 t *testing.T 여야 합니다. testing.Ttesting 패키지가 제공하는 테스트를 편리하게 하기 위한 구조체로, 많은 사용 가능한 메서드를 제공합니다. 예제의 t.Errorft.Logf 와 동일하며, 테스트 실패 로그 정보를 포맷팅하여 출력하는 데 사용됩니다. 그 외 일반적으로 사용되는 것으로 t.Fail 이 있으며, 현재 케이스를 테스트 실패로 표시하는 데 사용됩니다. 기능상 유사한 것으로 t.FailNow 도 있으며, 이 역시 테스트 실패로 표시하지만 전자는 실패 후에도 계속 실행하고 후자는 직접 실행을 중지합니다. 아래 예제를 보면 예상 결과를 잘못된 결과로 수정합니다.

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 110

  actual := tool.SumInt(a, b)
  if actual != expected {
        // Errorf 내부使用的是 t.Fail()
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
  t.Log("test finished")
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := true

  actual := tool.Equal(a, b)
  if actual != expected {
        // Fatalf 내부使用的是 t.FailNow()
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
  t.Log("test finished")
}

위의 테스트를 실행하면 출력은 다음과 같습니다.

sh
$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
    tool_test.go:16: test finished
--- FAIL: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.037s

테스트 로그에서 TestSum 케이스는 실패했음에도 test finished 를 출력한 반면, TestEqual 은 그렇지 않았음을 알 수 있습니다. t.SkipNow 도 있으며, 현재 케이스를 SKIP 으로 표시한 후 실행을 중지하고 다음 라운드 테스트에서 계속 실행합니다.

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func TestSum(t *testing.T) {
   a, b := 10, 101
   expected := 110

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }
   t.Log("test finished")
}

func TestEqual(t *testing.T) {
   a, b := 10, 101
   expected := true

   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
   t.Log("test finished")
}

테스트 실행 시 테스트 횟수를 2 로 수정합니다.

go
$ go test tool_test.go -v -count=2
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.468s

위의 예제에서 마지막 줄에 test finished 를 출력하여 테스트 완료를 나타냈는데, 실제로는 t.Cleanup 을 사용하여 마무리 함수를 등록하여 이 작업을专门으로 처리할 수 있습니다. 이 함수는 테스트 케이스가 종료될 때 실행됩니다. 아래와 같습니다.

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func finished(t *testing.T) {
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

테스트 실행 후 출력은 다음과 같습니다.

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:9: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:9: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.462s

Helper

t.Helper() 를 통해 현재 함수를 헬퍼 함수로 표시할 수 있습니다. 헬퍼 함수는 실행을 위한 별도의 테스트 케이스로 사용되지 않으며, 로그를 기록할 때 출력되는 행 번호도 헬퍼 함수의 호출자의 행 번호이므로, 로그를 분석할 때 위치 추적이 더 정확해지고 불필요한 기타 정보를 피할 수 있습니다. 예를 들어 위의 t.Cleanup 예제는 헬퍼 함수로 수정할 수 있습니다. 아래와 같습니다.

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func CleanupHelper(t *testing.T) {
   t.Helper()
   t.Log("test finished")
}

func TestSum(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := 111

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }

}

func TestEqual(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := false

   t.Helper()
   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
}

테스트 실행 후 출력 정보는 다음과 같으며, 이전과의 차이점은 test finished 의 행 번호가 호출자의 행 번호로 바뀐 것입니다.

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:15: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:30: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.464s

TIP

위의 작업은 메인 테스트에서만 수행할 수 있으며, 직접 실행되는 테스트 케이스입니다. 서브 테스트에서 사용하면 panic 이 발생합니다.

서브 테스트

어떤 경우에는 한 테스트 케이스 내에서 다른 테스트 케이스를 테스트해야 할 때가 있습니다. 이러한 중첩된 테스트 케이스를 일반적으로 서브 테스트라고 하며, t.Run() 메서드를 통해 수행합니다. 해당 메서드 시그니처는 다음과 같습니다.

go
// Run 메서드는 서브 테스트를 실행하기 위한 새 고루틴을 시작하며, 함수 f 가 실행完毕될 때까지 블록합니다.
// 반환 값은 테스트 통과 여부입니다.
func (t *T) Run(name string, f func(t *T)) bool

아래는 예제입니다.

go
func TestTool(t *testing.T) {
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
}

실행 후 결과는 다음과 같습니다.

sh
$ go test -run TestTool -v
=== RUN   TestTool
=== RUN   TestTool/tool.Sum(10,101)
    tool_test.go:15: test finished
=== RUN   TestTool/tool.Equal(10,101)
    tool_test.go:30: test finished
--- PASS: TestTool (0.00s)
    --- PASS: TestTool/tool.Sum(10,101) (0.00s)
    --- PASS: TestTool/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.449s

출력을 통해父子层级 구조를 매우 명확하게 볼 수 있습니다. 위의 예제에서 첫 번째 서브 테스트가 완료될 때까지 두 번째 서브 테스트는 실행되지 않습니다. t.Parallel() 을 사용하여 테스트 케이스를 병렬 실행 가능으로 표시할 수 있으며, 이렇게 하면 출력 순서가 결정되지 않습니다.

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func CleanupHelper(t *testing.T) {
  t.Helper()
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
  t.Log("teardown")
}

테스트 실행 후 출력은 다음과 같습니다.

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/tool.Sum(10,101)
=== PAUSE TestToolParallel/tool.Sum(10,101)
=== RUN   TestToolParallel/tool.Equal(10,101)
=== PAUSE TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel
    tool_test.go:49: teardown
=== CONT  TestToolParallel/tool.Sum(10,101)
=== CONT  TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/tool.Equal(10,101)
    tool_test.go:32: test finished
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/tool.Sum(10,101) (0.00s)
    --- PASS: TestToolParallel/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.444s

테스트 결과에서 블로킹 대기 과정이 매우 명확하게 드러납니다. 테스트 케이스를 병렬 실행할 때 위의 예제는 분명히 정상적으로 진행될 수 없습니다. 후속 코드가 동기화되어 실행되는 것을 보장할 수 없기 때문입니다. 이때 t.Run() 을 한 층 더 중첩할 수 있습니다. 아래와 같습니다.

go
func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("process", func(t *testing.T) {
    t.Run("tool.Sum(10,101)", TestSum)
    t.Run("tool.Equal(10,101)", TestEqual)
  })
  t.Log("teardown")
}

다시 실행하면 정상적인 실행 결과를 볼 수 있습니다.

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/process
=== RUN   TestToolParallel/process/tool.Sum(10,101)
=== PAUSE TestToolParallel/process/tool.Sum(10,101)
=== RUN   TestToolParallel/process/tool.Equal(10,101)
=== PAUSE TestToolParallel/process/tool.Equal(10,101)
=== CONT  TestToolParallel/process/tool.Sum(10,101)
=== CONT  TestToolParallel/process/tool.Equal(10,101)
=== NAME  TestToolParallel/process/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/process/tool.Equal(10,101)
    tool_test.go:32: test finished
=== NAME  TestToolParallel
    tool_test.go:51: teardown
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/process (0.00s)
        --- PASS: TestToolParallel/process/tool.Sum(10,101) (0.00s)
        --- PASS: TestToolParallel/process/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.450s

테이블 스타일

위의 단위 테스트에서 테스트 입력 데이터는 모두 수동으로 선언한 변수였습니다. 데이터 양이 적을 때는 괜찮지만, 여러 그룹의 데이터를 테스트하려면 변수를 선언하여 테스트 데이터를 생성하는 것은 현실적이지 않습니다. 따라서 일반적으로 구조체 슬라이스 형태를 사용하는 것이 좋으며, 구조체는 임시로 선언한 익명 구조체입니다. 이러한 코딩 스타일은 테이블처럼 보이기 때문에 table-driven 이라고 합니다. 아래에 예시를 들겠습니다. 이는 수동으로 여러 개의 변수를 선언하여 테스트 데이터를 생성하는 예제입니다. 데이터가 여러 그룹일 경우 직관적이지 않으므로 테이블 스타일로 수정합니다.

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false
  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

수정 후 코드는 다음과 같습니다.

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  // table driven style
  testData := []struct {
    a, b int
    exp  bool
  }{
    {10, 101, false},
    {5, 5, true},
    {30, 32, false},
    {100, 101, false},
    {2, 3, false},
    {4, 4, true},
  }

  for _, data := range testData {
    if actual := tool.Equal(data.a, data.b); actual != data.exp {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", data.a, data.b, data.exp, actual)
    }
  }
}

이러한 테스트 데이터는 훨씬 더 직관적으로 보입니다.

벤치마크 테스트

벤치마크 테스트는 성능 테스트라고도 하며, 일반적으로 프로그램의 메모리 점유율, CPU 사용 상황, 실행 소요 시간 등 성능 지표를 테스트하는 데 사용됩니다. 벤치마크 테스트의 경우 테스트 파일은 일반적으로 bench_test.go 로 끝나며, 테스트 케이스의 함수는 반드시 BenchmarkXXXX 형식이어야 합니다.

아래는 문자열拼接 예제의 성능 비교를 벤치마크 테스트 예제로 들겠습니다. 먼저 /tool/strConcat.go 파일을 생성합니다. 잘 알다시피 문자열을 직접 + 로拼接하는 것은 성능이 매우 낮으며, strings.Builder 를 사용하는 것이 훨씬 좋습니다. /tool/strings.go 파일에 두 가지 방식의 문자열拼接을 위한 두 개의 함수를 생성합니다.

go
package tool

import "strings"


func ConcatStringDirect(longString string) {
   res := ""
   for i := 0; i < 100_000.; i++ {
      res += longString
   }
}

func ConcatStringWithBuilder(longString string) {
   var res strings.Builder
   for i := 0; i < 100_000.; i++ {
      res.WriteString(longString)
   }
}

그 후 테스트 파일 /tool_test/bench_tool_test.go 를 생성합니다. 코드는 다음과 같습니다.

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

var longString = "longStringlongStringlongStringlongStringlongStringlongStringlongStringlongString"

func BenchmarkConcatDirect(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringDirect(longString)
  }
}

func BenchmarkConcatBuilder(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringWithBuilder(longString)
  }
}

테스트 명령을 실행합니다. 명령에서 상세 로그와 메모리 분석을 활성화하고, 사용할 CPU 코어 수 목록을 지정하며, 각 테스트 케이스를 두 번 실행합니다. 출력은 다음과 같습니다.

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=2
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         277771375 ns/op        4040056736 B/op    10000 allocs/op
BenchmarkConcatDirect-2                4         278500125 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                1        1153796000 ns/op        4040068784 B/op    10126 allocs/op
BenchmarkConcatDirect-4                1        1211017600 ns/op        4040073104 B/op    10171 allocs/op
BenchmarkConcatDirect-8                2         665460800 ns/op        4040077760 B/op    10219 allocs/op
BenchmarkConcatDirect-8                2         679774450 ns/op        4040080064 B/op    10243 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            3428            344530 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3579            351858 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            2448            736177 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1688            662993 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1958            550333 ns/op         4128199 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2174            552113 ns/op         4128196 B/op         29 allocs/op
PASS
ok      golearn/tool_test       21.381s

아래에서 벤치마크 테스트의 출력 결과를 설명하겠습니다. goos 는 실행 중인 운영체제를, goarh 는 CPU 아키텍처를, pkg 는 테스트가 위치한 패키지를, cpu 는 CPU 에 대한 일부 정보를 나타냅니다. 아래의 각 테스트 케이스 결과는 각 벤치마크 테스트 이름으로 구분됩니다. 첫 번째 줄 BenchmarkConcatDirect-2 의 2 는 사용된 CPU 코어 수를, 두 번째 줄의 4 는 코드에서 b.N 의 크기, 즉 벤치마크 테스트의 루프 횟수를, 세 번째 줄 277771375 ns/op 는 각 루프에 소요된 시간을 나타내며, ns 는 나노초입니다. 네 번째 줄 4040056736 B/op 는 각 루프에 할당된 메모리의 바이트 크기를, 다섯 번째 줄 10000 allocs/op 는 각 루프의 메모리 할당 횟수를 나타냅니다.

분명히 테스트 결과에 따르면 strings.Builder 를 사용하는 것이 + 로 문자열을拼接하는 것보다 성능이 훨씬 높습니다. 직관적인 데이터 비교를 통해 성능을 확인하는 것이 바로 벤치마크 테스트의 목적입니다.

benchstat

benchstat 은 오픈소스 성능 테스트 분석 도구입니다. 위의 성능 테스트 샘플 수는 두 그룹뿐이며, 샘플이 많아지면 인공 분석은 매우费时费力합니다. 이 도구는 바로 성능 분석 문제를 해결하기 위해 탄생했습니다.

먼저 해당 도구를 다운로드해야 합니다.

sh
$ go install golang.org/x/perf/benchstat

벤치마크 테스트를 두 번 실행합니다. 이번에는 샘플 수를 5 개로 수정하고, 비교를 위해 각각 old.txtnew.txt 파일에 출력합니다. 첫 번째 실행 결과

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a old.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         290535650 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         298974625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         299637800 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         276487000 ns/op        4040056784 B/op    10001 allocs/op
BenchmarkConcatDirect-2                4         356465275 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                2         894723200 ns/op        4040077424 B/op    10216 allocs/op
BenchmarkConcatDirect-4                2         785830400 ns/op        4040078288 B/op    10225 allocs/op
BenchmarkConcatDirect-4                2         743634000 ns/op        4040077568 B/op    10217 allocs/op
BenchmarkConcatDirect-4                2         953802700 ns/op        4040075408 B/op    10195 allocs/op
BenchmarkConcatDirect-4                2         953028750 ns/op        4040077520 B/op    10217 allocs/op
BenchmarkConcatDirect-8                2         684023150 ns/op        4040086784 B/op    10313 allocs/op
BenchmarkConcatDirect-8                2         634380250 ns/op        4040090528 B/op    10352 allocs/op
BenchmarkConcatDirect-8                2         685030600 ns/op        4040090768 B/op    10355 allocs/op
BenchmarkConcatDirect-8                2         817909650 ns/op        4040089808 B/op    10345 allocs/op
BenchmarkConcatDirect-8                2         600078100 ns/op        4040095664 B/op    10406 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2925            419651 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2961            423899 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2714            422275 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2848            452255 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2612            454452 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4             974           1158000 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1098           1068682 ns/op         4128192 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1042           1056570 ns/op         4128194 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1280            978213 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1538           1162108 ns/op         4128190 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1744            700824 ns/op         4128203 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2235            759537 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1556            736455 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1592            825794 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2263            717285 ns/op         4128203 B/op         29 allocs/op
PASS
ok      golearn/tool_test       56.742s

두 번째 실행 결과

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         285074900 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         291517150 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         281901975 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         292320625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         286723000 ns/op        4040056952 B/op    10002 allocs/op
BenchmarkConcatDirect-4                1        1188983000 ns/op        4040071856 B/op    10158 allocs/op
BenchmarkConcatDirect-4                1        1080713900 ns/op        4040070800 B/op    10147 allocs/op
BenchmarkConcatDirect-4                1        1203622300 ns/op        4040067344 B/op    10111 allocs/op
BenchmarkConcatDirect-4                1        1045291300 ns/op        4040070224 B/op    10141 allocs/op
BenchmarkConcatDirect-4                1        1123163300 ns/op        4040070032 B/op    10139 allocs/op
BenchmarkConcatDirect-8                2         790421300 ns/op        4040076656 B/op    10208 allocs/op
BenchmarkConcatDirect-8                2         659047300 ns/op        4040079488 B/op    10237 allocs/op
BenchmarkConcatDirect-8                2         712991800 ns/op        4040077184 B/op    10213 allocs/op
BenchmarkConcatDirect-8                2         706605350 ns/op        4040078000 B/op    10222 allocs/op
BenchmarkConcatDirect-8                2         656195700 ns/op        4040085248 B/op    10297 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2726            386412 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3439            335358 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3376            338957 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3870            326301 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            4285            339596 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1663            671535 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1507            744885 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1353           1097800 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1388           1006019 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1635            993764 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1332            783599 ns/op         4128198 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1818            729821 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1398            780614 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1526            750513 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2164            704798 ns/op         4128204 B/op         29 allocs/op
PASS
ok      golearn/tool_test       50.387s

그 후 benchstat 을 사용하여 비교합니다.

sh
$ benchstat old.txt new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
    old.txt               new.txt
    sec/op    sec/op      vs base
ConcatDirect-2     299.0m ± ¹    286.7m ± ¹        ~ (p=0.310 n=5)
ConcatDirect-4     894.7m ± ¹   1123.2m ± ¹  +25.53% (p=0.008 n=5)
ConcatDirect-8     684.0m ± ¹    706.6m ± ¹        ~ (p=0.548 n=5)
ConcatBuilder-2    423.9µ ± ¹    339.0µ ± ¹  -20.04% (p=0.008 n=5)
ConcatBuilder-4   1068.7µ ± ¹    993.8µ ± ¹        ~ (p=0.151 n=5)
ConcatBuilder-8    736.5µ ± ¹    750.5µ ± ¹        ~ (p=0.841 n=5)
geomean            19.84m          19.65m         -0.98%
¹ need >= 6 samples for confidence interval at level 0.95

    old.txt                new.txt
     B/op     B/op       vs base
ConcatDirect-2    3.763Gi ± ¹   3.763Gi ± ¹       ~ (p=1.000 n=5)
ConcatDirect-4    3.763Gi ± ¹   3.763Gi ± ¹  -0.00% (p=0.008 n=5)
ConcatDirect-8    3.763Gi ± ¹   3.763Gi ± ¹  -0.00% (p=0.008 n=5)
ConcatBuilder-2   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=0.079 n=5)
ConcatBuilder-8   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=0.952 n=5)
geomean           123.2Mi         123.2Mi        -0.00%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

   old.txt               new.txt
  allocs/op  allocs/op    vs base
ConcatDirect-2    9.999k ± ¹   9.999k ± ¹       ~ (p=1.000 n=5)
ConcatDirect-4    10.22k ± ¹   10.14k ± ¹  -0.74% (p=0.008 n=5)
ConcatDirect-8    10.35k ± ¹   10.22k ± ¹  -1.26% (p=0.008 n=5)
ConcatBuilder-2    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-8    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
geomean            543.6          541.7        -0.33%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

결과에서 benchstat 이 이를 세 그룹으로 나눈 것을 볼 수 있습니다. 각각 소요 시간, 메모리 점유율 및 메모리 할당 횟수이며, geomean 은 평균값이고, p 는 샘플의 유의수준으로, 임계 구간은 일반적으로 0.05 이며, 0.05 보다 높으면 그다지 신뢰할 수 없습니다. 그 중 하나의 데이터를 추출하면 다음과 같습니다.

          │    sec/op     │    sec/op      vs base               │
ConcatDirect-4     894.7m ± ∞ ¹   1123.2m ± ∞ ¹  +25.53% (p=0.008 n=5)

old 실행 소요 시간이 894.7ms, new 실행 소요 시간이 1123.2ms 임을 볼 수 있으며, 비교해보면 25.53% 의 소요 시간이 증가했습니다.

퍼즈 테스트

퍼즈 테스트는 GO1.18 에서 출시된 새로운 기능으로, 단위 테스트와 벤치마크 테스트의 일종적인 강화입니다. 차이점은 전자 두 가지의 테스트 데이터는 개발자가 수동으로 작성해야 하지만, 퍼즈 테스트는 코퍼스를 통해 무작위 테스트 데이터를 생성할 수 있다는 점입니다. Go 의 퍼즈 테스트에 대한 자세한 개념은 Go Fuzzing 에서 확인할 수 있습니다. 퍼즈 테스트의 장점은 고정된 테스트 데이터에 비해 무작위 데이터가 프로그램의 경계 조건을 더 잘 테스트할 수 있다는 것입니다. 아래는 공식 튜토리얼의 예시를 들어 설명하겠습니다. 이번에는 문자열을 반전하는 함수를 테스트합니다. 먼저 /tool/strings.go 파일을 생성하고 다음 코드를 작성합니다.

go
package tool

func Reverse(s string) string {
  b := []byte(s)
  for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
    b[i], b[j] = b[j], b[i]
  }
  return string(b)
}

퍼즈 테스트 파일 /tool_test/fuzz_tool_test.go 를 생성하고 다음 코드를 작성합니다.

go
package tool

import (
  "golearn/tool"
  "testing"
  "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
  testdata := []string{"hello world!", "nice to meet you", "good bye!"}
  for _, data := range testdata {
    f.Add(data)
  }

  f.Fuzz(func(t *testing.T, str string) {
    first := tool.Reverse(str)
    second := tool.Reverse(first)
    t.Logf("str:%q,first:%q,second:%q", str, first, second)
    if str != second {
      t.Errorf("before: %q, after: %q", str, second)
    }
    if utf8.ValidString(str) && !utf8.ValidString(first) {
      t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
    }
  })
}

퍼즈 테스트에서는 먼저 코퍼스 시드 라이브러리에 데이터를 추가해야 합니다. 예시에서는 f.Add() 를 사용하여 추가하며, 이는 후속 무작위 테스트 데이터 생성에 도움이 됩니다. 그 후 f.Fuzz(fn) 을 사용하여 테스트를 수행합니다. 함수 시그니처는 다음과 같습니다.

go
func (f *F) Fuzz(ff any)

func (f *F) Add(args ...any)

fn 은 단위 테스트 함수의 로직과 유사하며, 함수의 첫 번째 입력 매개변수는 반드시 t *testing.T 여야 하며, 그 뒤에 생성하려는 매개변수가 옵니다. 전달된 문자열은 예측할 수 없으므로 여기서는 두 번 반전하는 방법을 사용하여 검증합니다. 다음 명령을 실행합니다.

sh
$ go test -run Fuzz -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/seed#0
    fuzz_tool_test.go:18: str:"hello world!",first:"!dlrow olleh",second:"hello world!"
=== RUN   FuzzReverse/seed#1
    fuzz_tool_test.go:18: str:"nice to meet you",first:"uoy teem ot ecin",second:"nice to meet you"
=== RUN   FuzzReverse/seed#2
    fuzz_tool_test.go:18: str:"good bye!",first:"!eyb doog",second:"good bye!"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/seed#0 (0.00s)
    --- PASS: FuzzReverse/seed#1 (0.00s)
    --- PASS: FuzzReverse/seed#2 (0.00s)
PASS
ok      golearn/tool_test       0.539s

매개변수에 -fuzz 가 없을 때는 무작위 테스트 데이터를 생성하지 않으며, 코퍼스의 데이터만 테스트 함수에 전달합니다. 결과에서 모든 테스트가 통과되었음을 볼 수 있으며, 이렇게 사용하는 것은 단위 테스트와 동일합니다. 하지만 실제로는 문제가 있습니다. 아래에 -fuzz 매개변수를 추가하여 다시 실행합니다.

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/217 completed
fuzz: minimizing 91-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 15/217 completed
--- FAIL: FuzzReverse (0.13s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"𐑄",first:"\x84\x91\x90\xf0",second:"𐑄"
        fuzz_tool_test.go:23: Reverse produced invalid UTF-8 string "𐑄" "\x84\x91\x90\xf0"

    Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
    To re-run:
    go test -run=FuzzReverse/d856c981b6266ba2
=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.697s

TIP

퍼즈 테스트에서 실패한 케이스는 현재 테스트 폴더 하위의 testdata 디렉토리 하위의 특정 코퍼스 파일에 출력됩니다. 예를 들어 위 예제에서

Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
To re-run:
go test -run=FuzzReverse/d856c981b6266ba2

testdata\fuzz\FuzzReverse\d856c981b6266ba2 가 출력된 코퍼스 파일 경로이며, 파일 내용은 다음과 같습니다.

go test fuzz v1
string("𐑄")

이번에는 통과하지 못했음을 볼 수 있습니다. 이유는 문자열이 반전된 후 비 utf8 형식이 되었기 때문에 퍼즈 테스트를 통해 이 문제점을 발견할 수 있었습니다. 일부 문자는 1 바이트 이상을 차지하므로 바이트 단위로 반전하면 당연히 깨진 문자가 됩니다. 따라서 테스트 대상 소스 코드를 다음과 같이 수정하여 문자열을 []rune 으로 변환하면 위와 같은 문제가 발생하지 않습니다.

go
func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

다음으로 이전 퍼즈 테스트에서 실패한 케이스를 직접 실행합니다.

sh
$ go test -run=FuzzReverse/d856c981b6266ba2 -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/d856c981b6266ba2
    fuzz_tool_test.go:18: str:"𐑄",first:"𐑄",second:"𐑄"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/d856c981b6266ba2 (0.00s)
PASS
ok      golearn/tool_test       0.033s

이번에는 테스트가 통과되었음을 볼 수 있습니다. 다시 퍼즈 테스트를 실행하여 여전히 문제가 있는지 확인합니다.

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: minimizing 70-byte failing input file
failure while testing seed corpus entry: FuzzReverse/d97214ce235bfcf5
fuzz: elapsed: 0s, gathering baseline coverage: 2/219 completed
--- FAIL: FuzzReverse (0.15s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"\xe4",first:"",second:""
        fuzz_tool_test.go:20: before: "\xe4", after: ""

=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.184s

다시 오류가 발생했음을 발견할 수 있습니다. 이번 문제는 문자열을 두 번 반전한 후 같지 않다는 것입니다. 원본 문자는 \xe4 이고, 기대하는 결과는 4ex\ 이지만 결과는 깨진 문자입니다.

go
func main() {
  fmt.Println("\xe4")
  fmt.Println([]byte("\xe4"))
  fmt.Println([]rune("\xe4"))
  fmt.Printf("%q\n", "\xe4")
  fmt.Printf("%x\n", "\xe4")
}

실행 결과는 다음과 같습니다.


[65533]
"\xe4"
e4

그 원인은 \xe4 가 1 바이트를 나타내지만 유효한 UTF-8 시퀀스가 아니기 때문입니다 (UTF-8 인코딩에서 \xe4 는 3 바이트 문자의 시작이지만 뒤 2 바이트가 부족합니다). []rune 으로 변환할 때 Golang 이 자동으로 이를 단일 Unicode 문자가 포함된 []rune{"\uFFFD"}로 변경하며, 반전 후에도 여전히 []rune{"\uFFFD"}입니다. string 으로 다시 변환할 때 해당 Unicode 문자는 UTF-8 인코딩 \xef\xbf\xbd 로 대체됩니다. 따라서 하나의 해결 방법은传入되는 것이 비 utf8 문자열일 때 직접 오류를 반환하는 것입니다.

go
func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

테스트 코드도 약간 수정해야 합니다.

go
func FuzzReverse(f *testing.F) {
  testdata := []string{"hello world!", "nice to meet you", "good bye!"}
  for _, data := range testdata {
    f.Add(data)
  }

  f.Fuzz(func(t *testing.T, str string) {
    first, err := tool.Reverse(str)
    if err != nil {
      t.Skip()
    }
    second, err := tool.Reverse(first)
    if err != nil {
      t.Skip()
    }
    t.Logf("str:%q,first:%q,second:%q", str, first, second)
    if str != second {
      t.Errorf("before: %q, after: %q", str, second)
    }
    if utf8.ValidString(str) && !utf8.ValidString(first) {
      t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
    }
  })
}

반전 함수가 error 를 반환할 때 테스트를 건너뜁니다. 다시 퍼즈 테스트를 실행합니다.

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: elapsed: 0s, gathering baseline coverage: 219/219 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 895571 (297796/sec), new interesting: 32 (total: 251)
fuzz: elapsed: 6s, execs: 1985543 (363120/sec), new interesting: 37 (total: 256)
fuzz: elapsed: 9s, execs: 3087837 (367225/sec), new interesting: 38 (total: 257)
fuzz: elapsed: 12s, execs: 4090817 (335167/sec), new interesting: 40 (total: 259)
fuzz: elapsed: 15s, execs: 5132580 (346408/sec), new interesting: 44 (total: 263)
fuzz: elapsed: 18s, execs: 6248486 (372185/sec), new interesting: 45 (total: 264)
fuzz: elapsed: 21s, execs: 7366827 (373305/sec), new interesting: 46 (total: 265)
fuzz: elapsed: 24s, execs: 8439803 (358059/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 27s, execs: 9527671 (361408/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 30s, execs: 10569473 (348056/sec), new interesting: 48 (total: 267)
fuzz: elapsed: 30s, execs: 10569473 (0/sec), new interesting: 48 (total: 267)
--- PASS: FuzzReverse (30.16s)
=== NAME
PASS
ok      golearn/tool_test       30.789s

이제 비교적 완전한 퍼즈 테스트 출력 로그를 얻을 수 있습니다. 그 중 일부 개념에 대한 설명은 다음과 같습니다.

  • elapsed: 한 라운드 완료 후 경과된 시간
  • execs: 실행된 입력 총수, 297796/sec 는 초당多少个 입력을 나타냄
  • new interesting: 테스트 중 코퍼스에 추가된"흥미로운"입력의 총수. (흥미로운 입력은 해당 입력이 기존 코퍼스가 커버할 수 있는 범위를 넘어 코드 커버리지를 확장할 수 있는 입력을 의미하며, 커버리지 범위가 지속적으로 확장됨에 따라 증가 추세는 전반적으로 지속적으로 둔화됩니다)

TIP

시간을 제한하는 -fuzztime 매개변수가 없으면 퍼즈 테스트는 영원히 실행됩니다.

타입 지원

Go Fuzz 에서 지원하는 타입은 다음과 같습니다.

  • string, []byte
  • int, int8, int16, int32/rune, int64
  • uint, uint8/byte, uint16, uint32, uint64
  • float32, float64
  • bool

Golang by www.golangdev.cn edit