테스트
Node.js에서 테스트를 작성하려면 Jest, Mocha, Vitest 같은 프레임워크를 선택하고 설치해야 한다. Go는 테스트 도구가 언어에 내장되어 있다. go test 한 줄이면 된다. 별도의 프레임워크도, 설정 파일도, assert 라이브러리도 필요 없다. 테스트 파일 관례부터 fuzzing까지 Go의 테스트 시스템 전체를 다룬다.
_test.go 파일 관례
Go에서 테스트 파일은 _test.go로 끝나야 한다. 이것은 관례가 아니라 도구가 강제하는 규칙이다. go build는 _test.go 파일을 무시하고, go test만 이 파일을 포함한다:
math/
calc.go # 프로덕션 코드
calc_test.go # 테스트 코드
같은 패키지 안에 테스트 파일을 함께 둔다. Node.js처럼 __tests__/ 디렉토리를 따로 만들 필요가 없다.
testing.T 기본
테스트 함수는 Test로 시작하고, *testing.T를 인자로 받는다:
// calc.go
package math
func Add(a, b int) int {
return a + b
}
// calc_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
go test로 실행한다:
$ go test
PASS
ok example.com/math 0.001s
Jest의 expect(x).toBe(y)에 해당하는 것이 if와 t.Errorf다:
// Jest
test("add", () => {
expect(add(2, 3)).toBe(5);
});
Go에는 assert 함수가 없다. 의도적인 설계다. if문이면 충분하고, 실패 메시지를 직접 작성하면 디버깅할 때 더 유용한 정보를 담을 수 있다.
t.Errorf는 실패를 기록하되 테스트를 계속 진행한다. 즉시 중단하려면 t.Fatalf를 쓴다:
func TestDivide(t *testing.T) {
result, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error for division by zero")
}
// t.Fatal 이후 코드는 실행되지 않는다
result, err = Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %d, want 5", result)
}
}
테이블 드리븐 테스트
Go에서 가장 흔한 테스트 패턴이다. 여러 입력과 기대값을 슬라이스로 정의하고, 반복문으로 순회한다:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"zero", 0, 0, 0},
{"negative", -1, -2, -3},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Jest의 describe/it 구조와 비교하면:
// Jest
describe("add", () => {
it.each([
[2, 3, 5],
[0, 0, 0],
[-1, -2, -3],
[-1, 5, 4],
])("add(%i, %i) = %i", (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
});
테이블 드리븐 테스트의 장점:
- 케이스 추가가 쉽다. 슬라이스에 한 줄만 추가하면 된다.
- 반복 코드가 없다. 검증 로직을 한 번만 작성한다.
- 실패 시 어떤 케이스인지 명확하다.
t.Run의 이름이 출력된다.
실행하면 각 subtest가 독립적으로 보고된다:
$ go test -v
=== RUN TestAdd
=== RUN TestAdd/positive
=== RUN TestAdd/zero
=== RUN TestAdd/negative
=== RUN TestAdd/mixed
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive (0.00s)
--- PASS: TestAdd/zero (0.00s)
--- PASS: TestAdd/negative (0.00s)
--- PASS: TestAdd/mixed (0.00s)
PASS
Subtests — t.Run
위에서 이미 t.Run을 사용했다. t.Run은 테이블 드리븐 테스트뿐 아니라 테스트를 논리적으로 그룹화할 때도 쓴다:
func TestUser(t *testing.T) {
t.Run("Create", func(t *testing.T) {
// 사용자 생성 테스트
})
t.Run("Update", func(t *testing.T) {
// 사용자 수정 테스트
})
t.Run("Delete", func(t *testing.T) {
// 사용자 삭제 테스트
})
}
특정 subtest만 실행할 수도 있다:
$ go test -run TestUser/Create
Jest의 describe 중첩과 비슷한 역할이다. 하지만 Go에서는 깊은 중첩보다 평탄한 구조를 선호한다.
테스트 헬퍼 — t.Helper()
테스트 유틸리티 함수에서 t.Helper()를 호출하면, 실패 시 헬퍼 함수가 아닌 호출한 쪽의 파일명과 줄 번호가 출력된다:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestAdd(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // 실패 시 이 줄이 보고된다
assertEqual(t, Add(0, 0), 0)
}
t.Helper() 없이 실행하면 실패 위치가 assertEqual 함수 내부로 표시된다. 디버깅할 때 실제 테스트 코드가 어디서 실패했는지 찾기 어려워진다. 테스트 헬퍼를 작성할 때는 항상 t.Helper()를 첫 줄에 넣는다.
Benchmark
testing.B를 사용하여 성능을 측정한다. 함수 이름이 Benchmark로 시작해야 한다:
func BenchmarkAdd(b *testing.B) {
for b.Loop() {
Add(2, 3)
}
}
b.Loop()은 Go 1.24에서 추가된 방식이다. 프레임워크가 반복 횟수를 자동으로 조절하여 안정적인 측정값을 만든다. 이전 버전에서는 for i := 0; i < b.N; i++ 패턴을 사용했다.
$ go test -bench=.
goos: darwin
goarch: arm64
pkg: example.com/math
cpu: Apple M1
BenchmarkAdd-8 1000000000 0.2500 ns/op
PASS
-bench=.은 모든 벤치마크를 실행한다. -benchmem을 추가하면 메모리 할당 정보도 출력된다:
$ go test -bench=. -benchmem
BenchmarkAdd-8 1000000000 0.2500 ns/op 0 B/op 0 allocs/op
Node.js에서 벤치마크를 하려면 benchmark.js 같은 외부 패키지를 설치해야 한다. Go는 표준 도구에 포함되어 있다.
Fuzzing
Go 1.18부터 fuzz testing이 내장되었다. 무작위 입력을 자동 생성하여 예상치 못한 엣지 케이스를 찾는다:
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
func FuzzReverse(f *testing.F) {
// seed corpus: 초기 입력값
f.Add("hello")
f.Add("world")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
doubleRev := Reverse(rev)
if s != doubleRev {
t.Errorf("double reverse of %q = %q", s, doubleRev)
}
})
}
일반 테스트처럼 실행하면 seed corpus만 검사한다. -fuzz 플래그를 주면 무작위 입력을 계속 생성한다:
$ go test -fuzz=FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 0
^C
크래시를 발견하면 testdata/fuzz/ 디렉토리에 실패 입력을 저장한다. 이후 일반 go test에서도 이 입력이 자동으로 포함된다.
Node.js에는 내장 fuzzer가 없다. fast-check 같은 property-based testing 라이브러리가 비슷한 역할을 한다.
testdata 디렉토리
테스트에 필요한 파일(JSON, 텍스트, 바이너리 등)은 testdata/ 디렉토리에 둔다:
parser/
parser.go
parser_test.go
testdata/
input.json
expected.json
testdata는 Go 도구가 인식하는 특별한 이름이다. go build와 go test가 이 디렉토리를 패키지로 취급하지 않는다. 테스트 코드에서 상대 경로로 접근한다:
func TestParse(t *testing.T) {
input, err := os.ReadFile("testdata/input.json")
if err != nil {
t.Fatal(err)
}
expected, err := os.ReadFile("testdata/expected.json")
if err != nil {
t.Fatal(err)
}
got := Parse(input)
if string(got) != string(expected) {
t.Errorf("output mismatch")
}
}
go test는 테스트 파일이 있는 디렉토리를 working directory로 설정하므로 "testdata/..."처럼 상대 경로를 쓸 수 있다.
앞서 다룬 fuzzing의 실패 입력도 testdata/fuzz/ 아래에 저장된다. testdata는 테스트 관련 파일의 표준 위치다.
go test 명령어
기본 사용법:
$ go test # 현재 패키지 테스트
$ go test ./... # 모든 하위 패키지 테스트
$ go test -v # 각 테스트 함수명과 결과 출력
$ go test -run TestAdd # 이름이 매칭되는 테스트만 실행
$ go test -count=1 # 캐시 무시하고 강제 실행
go test ./...는 프로젝트 전체를 테스트하는 관용적 명령이다. Node.js에서 npm test가 하는 역할과 같다.
-race 플래그는 race condition detector를 활성화한다. 14편에서 다뤘던 동시성 버그를 테스트 단계에서 잡을 수 있다:
$ go test -race ./...
CI에서 -race를 기본으로 켜두는 것이 권장된다.
_test.go 파일을 만들고, Test로 시작하는 함수를 작성하고, go test를 실행한다. 프레임워크 선택, 설정 파일, 플러그인 조합 같은 의사결정이 필요 없다. 이 단순함이 Go 테스트의 핵심이다.