Menu

Golang 테스트: 단위 테스트, 테이블 테스트, 벤치마크

표준 testing 패키지와 go test로 Go 코드를 테스트하는 방법: _test.go 파일, TestXxx 함수, t.Errorf와 t.Fatalf의 차이, t.Run을 쓴 테이블 기반 테스트, 헬퍼와 임시 디렉터리, 커버리지, b.Loop를 쓴 벤치마크, 예제 테스트를 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

테스트할 코드

Go의 테스트 도구는 표준 툴체인의 일부입니다. testing 패키지와 go test 명령입니다. 설치할 프레임워크도, 필요한 단언 라이브러리도 없습니다.

이 페이지의 예제는 제목을 URL 슬러그로 바꾸는 작은 함수 Slugify 하나를 테스트합니다. main에서 직접 작성한 확인 코드와 함께 실행할 수 있게 보여 드리면 다음과 같습니다:

브라우저 에디터는 main을 실행하며, go test는 실행할 수 없습니다. 아래 내용은 모두 실제 프로젝트에 있는 모습 그대로 작성했고, 각 명령 뒤에 터미널 출력을 보여 줍니다. 모두 Go 1.24로 실행했습니다.

첫 번째 테스트

프로젝트에서 함수는 패키지 안에 있고, 테스트는 그 옆의 _test.go로 끝나는 파일에 있습니다:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

go test가 쓰는 규칙:

  • _test.go로 끝나는 파일만 테스트 파일입니다. go build는 이를 무시하므로 테스트 코드가 바이너리에 들어가지 않습니다.
  • 테스트는 *testing.T 하나를 받는 TestXxx라는 이름의 함수입니다(Test 뒤의 부분은 소문자로 시작하면 안 됩니다).
  • 테스트는 실패 메서드 중 하나를 호출하거나 패닉을 일으키지 않는 한 통과합니다.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

실패 메시지 형식 Func(input) = got, want expected는 Go의 관례입니다. 메시지에 입력을 넣으세요. got "a", want "b"만 적힌 실패는 어떤 입력이었는지 알기 위해 코드로 되돌아가게 만듭니다.

t.Errorf와 t.Fatalf

메서드실패로 표시테스트를 멈춤
t.Error, t.Errorf아니요, 계속 실행
t.Fatal, t.Fatalf예, 즉시
t.Log, t.Logf아니요아니요, -v나 실패 시에만 출력
t.Skip, t.Skipf아니요예, 건너뜀으로 보고

한 번의 실행으로 틀린 필드를 모두 보고하도록 기본적으로 Errorf를 쓰세요. 테스트의 나머지가 동작할 수 없을 때, 보통 예상 밖의 오류 뒤에는 Fatalf를 쓰세요:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatalruntime.Goexit를 호출해서 테스트를 멈추므로, 테스트 자신의 고루틴에서 호출해야 합니다. 직접 시작한 고루틴에서는 t.Error로 보고하고 반환하세요.

t.Run을 쓴 테이블 기반 테스트

대부분의 Go 테스트는 테이블입니다. 케이스 슬라이스 하나, 반복문 하나, 그리고 각 케이스에 이름을 주는 t.Run입니다.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

케이스를 추가하는 데는 한 줄이면 됩니다. 각 하위 테스트는 따로 보고되고, 하나 안의 Fatal은 그 하위 테스트만 끝내며, 이름으로 하나만 실행할 수 있습니다. -v로 전부 실행해 보세요:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

하위 테스트 이름의 공백은 밑줄이 됩니다. 케이스가 실패하면 출력이 하위 테스트와 줄 번호를 알려 줍니다:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

Go 1.22부터는 반복마다 자기 tt를 가지므로, 하위 테스트가 병렬로 실행되어도 t.Run 안의 클로저가 올바른 케이스를 봅니다. 예전 코드에는 그 이유로 반복문 본문 맨 위에 tt := tt가 있는 경우가 많은데, 더 이상 필요하지 않습니다.

하위 테스트를 병렬로 실행하려면 하위 테스트 함수의 시작에서 t.Parallel()을 호출하세요. 케이스들이 서로 독립적이고 그럴 가치가 있을 만큼 느릴 때만 그렇게 하세요.

자주 쓰게 될 go test 플래그

명령하는 일
go test -v모든 테스트 이름, 결과, t.Log 출력을 보여 줌
go test -run TestSlugify이름이 정규 표현식과 일치하는 테스트 실행
go test -run 'TestSlugifyTable/empty'하위 테스트 하나 실행
go test -count=1캐시된 결과를 무시하고 다시 실행
go test -race데이터 레이스 탐지기와 함께 실행
go test -short긴 테스트가 스스로 건너뛰게 함(if testing.Short() { t.Skip() })
go test -failfast첫 번째 실패 테스트 후 멈춤
go test -timeout 30s실행이 더 오래 걸리면 실패(기본 10분)
go test -cover문장 커버리지 출력
go test -bench=.벤치마크도 실행

패키지를 지정하면(go test ., go test ./...) go test는 코드와 입력이 바뀌지 않은 패키지의 결과를 캐시하고 뒤에 (cached)를 출력합니다. 인자 없는 그냥 go test는 절대 캐시하지 않습니다. 캐시는 테스트가 os 패키지로 읽는 파일과 환경 변수는 추적하지만 데이터베이스나 네트워크 서비스 같은 외부 상태는 추적하지 않으므로, 그런 것에 의존하는 테스트는 지금이라면 실패했을 것을 캐시로 통과할 수 있습니다. -count=1로 실제 실행을 강제하세요.

헬퍼, 임시 디렉터리, 정리

위의 TestCountWords에 있는 writeFile 호출은 테스트 헬퍼입니다:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper()는 함수를 헬퍼로 표시하므로, 실패가 헬퍼 안이 아니라 헬퍼를 호출한 테스트의 줄에서 보고됩니다.
  • t.TempDir()은 새 디렉터리를 만들고 테스트가 끝나면 지웁니다. 테스트가 파일을 직접 정리할 필요가 없습니다.
  • t.Cleanup(func() { ... })는 그 밖의 해체 작업(서버 닫기, 테이블 삭제)을 등록합니다. 정리 함수는 테스트와 하위 테스트가 끝난 뒤, 마지막에 등록한 것부터 실행됩니다.
  • t.Setenv("KEY", "value")는 이 테스트에서만 환경 변수를 설정하고 끝나면 되돌립니다.
  • t.Context()(Go 1.24)는 정리 함수가 실행되기 직전에 취소되는 컨텍스트를 반환합니다.

테스트 픽스처 파일은 테스트 옆의 testdata라는 디렉터리에 둡니다. go 도구는 이를 패키지로 취급하지 않고, 테스트는 패키지 디렉터리를 작업 디렉터리로 삼아 실행되므로 os.ReadFile("testdata/input.json")이 동작합니다.

오류와 HTTP 핸들러 테스트하기

오류는 호출하는 코드가 하는 것과 똑같이 errors.Iserrors.As로 확인하세요:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

오류 문자열을 비교하면 누군가 메시지를 바꾸는 순간 깨집니다.

HTTP 핸들러라면 net/http/httptest가 가짜 ResponseWriter를 주므로 네트워크 없이 핸들러를 직접 호출할 수 있습니다:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer는 클라이언트 코드를 테스트하기 위해 localhost에 실제 서버를 시작합니다. HTTP 클라이언트 페이지의 모든 예제가 이것을 씁니다.

커버리지

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

커버리지는 한 번도 실행되지 않은 줄을 알려 주므로, 테스트되지 않은 분기를 찾는 데 유용합니다. 높은 숫자가 단언이 좋다는 뜻은 아닙니다. 모든 함수를 호출하고 아무것도 확인하지 않는 테스트도 100%에 도달합니다.

벤치마크

벤치마크는 func BenchmarkXxx(b *testing.B)입니다. Go 1.24에 b.Loop가 추가되었고, 이제 이것이 권장되는 형태입니다:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop는 안정적인 측정에 필요한 만큼 본문을 실행하고, 반복문 앞의 준비 코드를 측정에서 빼며, 컴파일러가 호출을 최적화로 없애지 못하게 합니다. Go 1.24 이전에 작성된 코드는 for i := 0; i < b.N; i++를 쓰는데, 여전히 동작하지만 비싼 준비 작업 뒤에는 b.ResetTimer()가 필요하고 죽은 코드 제거에 속을 수 있습니다.

벤치마크는 그냥 go test로는 실행되지 않습니다. 명시적으로 요청하고, -run='^$'로 일반 테스트는 건너뛰세요:

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

열은 차례로 GOMAXPROCS가 붙은 이름, 실행 횟수, 호출당 시간, 호출당 할당 바이트, 호출당 할당 횟수입니다. 숫자는 머신에 따라 다르므로 같은 머신에서 비교하세요. 변경 전후를 믿을 만하게 비교하려면 양쪽을 -count=10으로 여러 번 실행하고 두 출력을 benchstat(golang.org/x/perf/cmd/benchstat)에 넣으세요.

예제도 테스트다

예제 함수는 무언가를 출력하고, 기대하는 출력을 주석으로 선언합니다. go test는 이를 실행해서 출력이 다르면 실패하고, go doc과 pkg.go.dev는 이를 문서로 보여 줍니다:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

패키지 이름 textutil_test가 이것을 외부 테스트로 만듭니다. 실제 호출자처럼 공개된 API만 쓸 수 있습니다. 이런 파일은 패키지와 같은 디렉터리에 둘 수 있습니다. // Output: 주석이 없으면 예제는 컴파일되지만 실행되지 않습니다. 줄이 어떤 순서로든 나올 수 있다면 // Unordered output:을 쓰세요.

퍼징 간단히 보기

Go 1.18에는 입력을 생성해서 충돌과 깨진 불변식을 찾는 퍼즈 테스트가 추가되었습니다:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

그냥 go test는 시드 입력(f.Add 값과 testdata/fuzz 아래 저장된 파일)만 실행합니다. go test -fuzz=FuzzSlugify는 실패를 찾거나 멈출 때까지 새 입력을 계속 생성하고, 실패한 입력을 testdata/fuzz 아래에 저장해서 일반 테스트 케이스로 만듭니다.

흔한 실수

  • 테스트 파일의 위치나 이름이 잘못됨. _test.go로 끝나야 하고 패키지 디렉터리에 있어야 합니다. slug_tests.go는 테스트로 실행되지 않고 패키지에 컴파일됩니다.
  • Test 뒤가 소문자. Testslugify는 테스트가 아닙니다. TestSlugifyTest_slugify는 테스트입니다.
  • 다른 고루틴에서 t.Fatal. 거기서는 t.Error로 보고하고, 테스트가 반환되기 전에 그 고루틴을 기다리세요.
  • 입력이 없는 메시지. 무엇을 넣었는지, 무엇이 나왔는지, 무엇을 기대했는지 포함하세요.
  • 캐시된 통과를 믿음. 테스트가 패키지 밖의 무언가에 의존한다면 -count=1을 쓰세요.
  • 순서나 공유 전역 변수에 서로 의존하는 테스트. 각 테스트는 자기 상태를 스스로 준비해야 하며, t.TempDir, t.Setenv, t.Cleanup이 바로 그 용도입니다.

자주 묻는 질문

Go에서 단위 테스트는 어떻게 작성하나요?

코드와 같은 디렉터리에 _test.go로 끝나는 파일을 만들고, testing을 import하고, 코드를 호출해서 불일치를 t.Errorf로 보고하는 func TestName(t *testing.T) 함수를 작성합니다. 그 디렉터리에서 go test를, 모듈 전체라면 go test ./...를 실행하세요. 프레임워크나 단언 라이브러리는 필요 없습니다.

Go에서 t.Error와 t.Fatal의 차이는 무엇인가요?

t.Errort.Errorf는 테스트를 실패로 표시하고 계속 실행하므로 한 번의 실행으로 여러 문제를 보고할 수 있습니다. t.Fatalt.Fatalf는 실패로 표시하고 테스트를 즉시 멈춥니다. 결과를 쓸 수 없게 만드는 예상 밖의 오류 뒤처럼, 계속 진행하는 것이 의미 없을 때 Fatal을 쓰세요.

Go에서 테스트 하나만 실행하려면 어떻게 하나요?

-run에 정규 표현식을 넘깁니다. go test -run TestSlugify는 이름이 일치하는 모든 테스트를 실행하고, go test -run 'TestSlugifyTable/punctuation'은 하위 테스트 하나를 실행합니다(하위 테스트 이름의 공백은 밑줄이 됩니다). 각 테스트의 이름과 결과를 보려면 -v를, 테스트 캐시를 건너뛰려면 -count=1을 추가하세요.

Go에서 벤치마크는 어떻게 작성하나요?

_test.go 파일에 func BenchmarkName(b *testing.B)를 작성하고, 측정할 코드를 for b.Loop() { ... } 반복문에 넣습니다(Go 1.24, 예전 코드는 for i := 0; i < b.N; i++를 씀). go test -bench=. -benchmem으로 실행하면 연산당 나노초, 바이트, 할당 횟수를 보고합니다.

Go에서 테스트 커버리지는 어떻게 보나요?

go test -cover는 테스트가 실행한 문장의 비율을 출력합니다. 자세히 보려면 go test -coverprofile=cover.out으로 프로필을 쓴 뒤, 함수별 숫자는 go tool cover -func=cover.out으로, 브라우저에서 실행된 줄과 실행되지 않은 줄은 go tool cover -html=cover.out으로 확인하세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기