[Go] unit test
개요
Go는 테스팅을 언어 차원에서 지원하며, testing 패키지와 go test 도구를 제공합니다.
주요 특징:
- 내장 테스팅 프레임워크: 별도 설치 불필요
- go test 명령어: 자동 테스트 실행
- 벤치마크: 성능 측정 기능
- 커버리지: 코드 커버리지 분석
- Examples: 실행 가능한 문서화
- Table-driven tests: Go 스타일 테스트 패턴
- Subtests: 계층적 테스트 구조
- 병렬 실행: 테스트 병렬화 지원
테스트 기본
1. 테스트 파일 구조
// math.go
package math
func Add(a, b int) int {
return a + b
}
func Multiply(a, b int) int {
return a * b
}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
func TestMultiply(t *testing.T) {
result := Multiply(3, 4)
expected := 12
if result != expected {
t.Errorf("Multiply(3, 4) = %d; want %d", result, expected)
}
}
명명 규칙:
- 파일:
*_test.go - 함수:
Test*(첫 글자 대문자) - 파라미터:
t *testing.T
2. testing.T 메서드
func TestTMethods(t *testing.T) {
// 에러 기록 (계속 실행)
t.Error("error message")
t.Errorf("formatted error: %s", "detail")
// 테스트 실패 (즉시 중단)
if false {
t.Fatal("fatal error")
t.Fatalf("fatal error: %v", "reason")
}
// 테스트 건너뛰기
if testing.Short() {
t.Skip("skipping in short mode")
}
// 헬퍼 함수 표시 (스택 트레이스에서 제외)
t.Helper()
// 로그 출력 (verbose 모드에서만)
t.Log("log message")
t.Logf("log: %s", "formatted")
// 병렬 실행 표시
t.Parallel()
// 테스트 이름
t.Name()
// 임시 디렉토리
tmpDir := t.TempDir()
t.Logf("Temp dir: %s", tmpDir)
}
3. go test 플래그
# 기본 실행
go test
# 상세 출력
go test -v
# 특정 패키지
go test ./... # 모든 하위 패키지
go test ./pkg/math # 특정 패키지
# 특정 테스트만 실행
go test -run TestAdd
go test -run "TestAdd|TestMultiply"
go test -run TestDivide/zero # 서브테스트
# 짧은 테스트만 실행
go test -short
# 병렬 실행 제한
go test -parallel 4
# 타임아웃
go test -timeout 30s
# 캐시 비활성화
go test -count=1
# 여러 번 실행
go test -count=10
Table-Driven Tests
1. 기본 패턴
func TestAddTableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"mixed numbers", 2, -3, -1},
{"zero", 0, 0, 0},
}
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)
}
})
}
}
2. 에러 테스트
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
want int
wantErr bool
}{
{"valid division", 10, 2, 5, false},
{"division by zero", 10, 0, 0, true},
{"negative numbers", -10, -2, 5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Errorf("Divide() error = %v, wantErr %v",
err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Divide() = %d, want %d", got, tt.want)
}
})
}
}
3. 복잡한 테이블
type User struct {
Name string
Email string
Age int
}
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user User
wantValid bool
wantErr error
}{
{
name: "valid user",
user: User{"John", "john@example.com", 25},
wantValid: true,
wantErr: nil,
},
{
name: "empty name",
user: User{"", "john@example.com", 25},
wantValid: false,
wantErr: ErrEmptyName,
},
{
name: "invalid email",
user: User{"John", "invalid", 25},
wantValid: false,
wantErr: ErrInvalidEmail,
},
{
name: "underage",
user: User{"John", "john@example.com", 17},
wantValid: false,
wantErr: ErrUnderage,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid, err := ValidateUser(tt.user)
if valid != tt.wantValid {
t.Errorf("ValidateUser() valid = %v, want %v",
valid, tt.wantValid)
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("ValidateUser() error = %v, want %v",
err, tt.wantErr)
}
})
}
}
Subtests
1. 계층적 구조
func TestUser(t *testing.T) {
t.Run("Validation", func(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
user := User{"John", "john@example.com", 25}
if valid, _ := ValidateUser(user); !valid {
t.Error("expected valid user")
}
})
t.Run("Invalid", func(t *testing.T) {
user := User{"", "", 0}
if valid, _ := ValidateUser(user); valid {
t.Error("expected invalid user")
}
})
})
t.Run("Creation", func(t *testing.T) {
user := NewUser("John", "john@example.com", 25)
if user.Name != "John" {
t.Errorf("unexpected name: %s", user.Name)
}
})
}
// 실행: go test -run TestUser/Validation/Valid
2. 병렬 실행
func TestParallel(t *testing.T) {
tests := []struct {
name string
val int
}{
{"test1", 1},
{"test2", 2},
{"test3", 3},
}
for _, tt := range tests {
tt := tt // 클로저 캡처 방지
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 병렬 실행
time.Sleep(1 * time.Second)
if tt.val < 0 {
t.Errorf("unexpected value: %d", tt.val)
}
})
}
}
테스트 헬퍼
1. 헬퍼 함수
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // 스택 트레이스에서 이 함수 제외
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func assertError(t *testing.T, err error, wantErr bool) {
t.Helper()
if (err != nil) != wantErr {
t.Errorf("error = %v, wantErr %v", err, wantErr)
}
}
func TestWithHelpers(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5)
_, err := Divide(10, 0)
assertError(t, err, true)
}
2. 테스트 픽스처
func setup(t *testing.T) (*Database, func()) {
t.Helper()
db := NewDatabase()
if err := db.Connect(); err != nil {
t.Fatalf("setup failed: %v", err)
}
// 정리 함수 반환
teardown := func() {
db.Close()
}
return db, teardown
}
func TestDatabase(t *testing.T) {
db, teardown := setup(t)
defer teardown()
// 테스트 수행
err := db.Insert("key", "value")
if err != nil {
t.Fatalf("Insert failed: %v", err)
}
}
3. Cleanup
func TestCleanup(t *testing.T) {
// t.Cleanup은 defer와 유사하지만 서브테스트에서도 작동
t.Cleanup(func() {
fmt.Println("Cleanup 1")
})
t.Cleanup(func() {
fmt.Println("Cleanup 2")
})
t.Run("subtest", func(t *testing.T) {
t.Cleanup(func() {
fmt.Println("Subtest cleanup")
})
})
// 출력 순서:
// Subtest cleanup
// Cleanup 2
// Cleanup 1
}
4. TempDir
func TestFileOperations(t *testing.T) {
tmpDir := t.TempDir() // 자동으로 정리됨
filePath := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(filePath, []byte("test"), 0644)
if err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
// 테스트...
}
벤치마크
1. 기본 벤치마크
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
func BenchmarkMultiply(b *testing.B) {
for i := 0; i < b.N; i++ {
Multiply(3, 4)
}
}
// 실행: go test -bench=.
// 결과: BenchmarkAdd-8 1000000000 0.25 ns/op
2. 벤치마크 옵션
# 모든 벤치마크 실행
go test -bench=.
# 특정 벤치마크
go test -bench=BenchmarkAdd
# 실행 시간 지정
go test -bench=. -benchtime=10s
# 반복 횟수 지정
go test -bench=. -benchtime=1000000x
# 메모리 통계
go test -bench=. -benchmem
# CPU 프로파일
go test -bench=. -cpuprofile=cpu.prof
# 메모리 프로파일
go test -bench=. -memprofile=mem.prof
3. 고급 벤치마크
func BenchmarkStringConcat(b *testing.B) {
b.Run("plus operator", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "hello" + " " + "world"
}
})
b.Run("fmt.Sprintf", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%s %s", "hello", "world")
}
})
b.Run("strings.Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteString(" ")
sb.WriteString("world")
_ = sb.String()
}
})
}
4. 타이머 제어
func BenchmarkWithSetup(b *testing.B) {
// 셋업은 측정하지 않음
data := make([]int, 1000)
for i := range data {
data[i] = rand.Intn(1000)
}
b.ResetTimer() // 타이머 리셋
for i := 0; i < b.N; i++ {
sort.Ints(data)
}
}
func BenchmarkWithPause(b *testing.B) {
for i := 0; i < b.N; i++ {
// 측정할 코드
DoWork()
b.StopTimer()
// 측정하지 않을 코드
Setup()
b.StartTimer()
}
}
5. 메모리 벤치마크
func BenchmarkAllocations(b *testing.B) {
b.ReportAllocs() // 메모리 할당 보고
for i := 0; i < b.N; i++ {
s := make([]int, 100)
_ = s
}
}
// 결과:
// BenchmarkAllocations-8 5000000 250 ns/op 800 B/op 1 allocs/op
// ^^^ ^^^
// bytes/op allocations/op
커버리지
1. 커버리지 측정
# 커버리지 측정
go test -cover
# 커버리지 프로파일 생성
go test -coverprofile=coverage.out
# HTML 리포트 생성
go tool cover -html=coverage.out
# 함수별 커버리지
go tool cover -func=coverage.out
# 특정 패키지만
go test -coverprofile=coverage.out ./pkg/math
go tool cover -html=coverage.out
2. 커버리지 모드
# set: 라인 실행 여부만 (기본)
go test -covermode=set -coverprofile=coverage.out
# count: 라인 실행 횟수
go test -covermode=count -coverprofile=coverage.out
# atomic: count + 동시성 안전
go test -covermode=atomic -coverprofile=coverage.out
3. 커버리지 패키지 선택
# 현재 패키지만
go test -cover
# 특정 패키지
go test -coverpkg=./pkg/math -coverprofile=coverage.out
# 모든 패키지
go test -coverpkg=./... -coverprofile=coverage.out ./...
Examples
1. Example 함수
func ExampleAdd() {
result := Add(2, 3)
fmt.Println(result)
// Output: 5
}
func ExampleMultiply() {
result := Multiply(3, 4)
fmt.Println(result)
// Output: 12
}
// go test 시 자동으로 실행 및 검증
// godoc에 자동으로 포함됨
2. Example with Unordered Output
func ExamplePrintMap() {
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Printf("%s=%d\n", k, v)
}
// Unordered output:
// a=1
// b=2
}
3. Example for Method
type Calculator struct{}
func (c Calculator) Add(a, b int) int {
return a + b
}
func ExampleCalculator_Add() {
calc := Calculator{}
result := calc.Add(2, 3)
fmt.Println(result)
// Output: 5
}
4. Example for Package
func Example() {
// 패키지 전체 예제
result := Add(2, 3)
fmt.Println(result)
// Output: 5
}
func Example_second() {
// 추가 패키지 예제
result := Multiply(3, 4)
fmt.Println(result)
// Output: 12
}
모킹과 인터페이스
1. 인터페이스 기반 테스트
// production code
type UserRepository interface {
GetUser(id int) (*User, error)
SaveUser(user *User) error
}
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUserName(id int) (string, error) {
user, err := s.repo.GetUser(id)
if err != nil {
return "", err
}
return user.Name, nil
}
// test code
type mockUserRepository struct {
users map[int]*User
}
func (m *mockUserRepository) GetUser(id int) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, errors.New("user not found")
}
return user, nil
}
func (m *mockUserRepository) SaveUser(user *User) error {
m.users[user.ID] = user
return nil
}
func TestUserService_GetUserName(t *testing.T) {
repo := &mockUserRepository{
users: map[int]*User{
1: {ID: 1, Name: "John"},
},
}
service := &UserService{repo: repo}
name, err := service.GetUserName(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "John" {
t.Errorf("got %s, want John", name)
}
}
2. httptest 활용
import "net/http/httptest"
func HandleUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
fmt.Fprintf(w, `{"id": "%s", "name": "John"}`, id)
}
func TestHandleUser(t *testing.T) {
req := httptest.NewRequest("GET", "/user?id=1", nil)
w := httptest.NewRecorder()
HandleUser(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
expected := `{"id": "1", "name": "John"}`
if string(body) != expected {
t.Errorf("body = %s, want %s", body, expected)
}
}
3. testify 라이브러리
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
// assert: 실패해도 계속
assert.Equal(t, 5, Add(2, 3))
assert.NotNil(t, &User{})
// require: 실패하면 중단
result, err := Divide(10, 2)
require.NoError(t, err)
require.Equal(t, 5, result)
}
// Mock
type MockRepository struct {
mock.Mock
}
func (m *MockRepository) GetUser(id int) (*User, error) {
args := m.Called(id)
return args.Get(0).(*User), args.Error(1)
}
func TestWithMock(t *testing.T) {
repo := new(MockRepository)
repo.On("GetUser", 1).Return(&User{ID: 1, Name: "John"}, nil)
service := &UserService{repo: repo}
name, err := service.GetUserName(1)
assert.NoError(t, err)
assert.Equal(t, "John", name)
repo.AssertExpectations(t)
}
통합 테스트
1. 빌드 태그
//go:build integration
// +build integration
package integration
func TestDatabaseIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// 실제 데이터베이스 테스트
}
// 실행: go test -tags=integration
2. 환경 변수 활용
func TestWithEnv(t *testing.T) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
t.Skip("DATABASE_URL not set")
}
db, err := sql.Open("postgres", dbURL)
require.NoError(t, err)
defer db.Close()
// 테스트...
}
3. Docker 컨테이너 사용
func TestWithDocker(t *testing.T) {
if testing.Short() {
t.Skip("skipping docker test")
}
// testcontainers-go 사용 예시
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:14",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "password",
},
}
container, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
defer container.Terminate(ctx)
// 테스트...
}
일반적인 실수
1. 테스트 독립성 부족
var globalCounter int
func TestNonIsolated1(t *testing.T) {
globalCounter++ // ❌ 전역 상태 변경
if globalCounter != 1 {
t.Error("expected 1")
}
}
func TestNonIsolated2(t *testing.T) {
globalCounter++
if globalCounter != 1 { // 실행 순서에 따라 실패
t.Error("expected 1")
}
}
// ✅ 올바른 방법
func TestIsolated(t *testing.T) {
counter := 0 // 로컬 변수 사용
counter++
if counter != 1 {
t.Error("expected 1")
}
}
2. 에러 검증 누락
func TestErrorMissing(t *testing.T) {
result, _ := Divide(10, 0) // ❌ 에러 무시
if result != 0 {
t.Error("unexpected result")
}
}
// ✅ 올바른 방법
func TestErrorCheck(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error")
}
}
3. Table-driven test에서 클로저 문제
func TestClosureBug(t *testing.T) {
tests := []struct {
name string
val int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // ❌ tt 변수 공유
if tt.val < 0 {
t.Error("negative")
}
})
}
}
// ✅ 올바른 방법
func TestClosureFix(t *testing.T) {
tests := []struct {
name string
val int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
tt := tt // 섀도잉으로 복사
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.val < 0 {
t.Error("negative")
}
})
}
}
4. 타이밍 의존적 테스트
func TestTimingDependentBad(t *testing.T) {
go doAsync()
time.Sleep(100 * time.Millisecond) // ❌ 불안정
// 검증...
}
// ✅ 올바른 방법
func TestTimingDependentGood(t *testing.T) {
done := make(chan bool)
go func() {
doAsync()
done <- true
}()
select {
case <-done:
// 검증...
case <-time.After(1 * time.Second):
t.Fatal("timeout")
}
}
5. 테스트 이름 불명확
func TestFunc(t *testing.T) { // ❌ 불명확
// ...
}
func Test1(t *testing.T) { // ❌ 숫자만 사용
// ...
}
// ✅ 올바른 방법
func TestUserValidation_EmptyName_ReturnsError(t *testing.T) {
// ...
}
func TestDivideByZero_ReturnsError(t *testing.T) {
// ...
}
6. Cleanup 누락
func TestCleanupMissing(t *testing.T) {
file, _ := os.Create("/tmp/test.txt") // ❌ 정리 안함
// 테스트...
}
// ✅ 올바른 방법
func TestCleanupProper(t *testing.T) {
file, err := os.Create("/tmp/test.txt")
require.NoError(t, err)
t.Cleanup(func() {
os.Remove("/tmp/test.txt")
})
// 테스트...
}
7. 과도한 모킹
// ❌ 모든 것을 모킹
type MockEverything struct {
mock.Mock
}
// ✅ 필요한 것만 모킹, 실제 구현 사용
func TestWithRealDependencies(t *testing.T) {
// 외부 의존성만 모킹
// 내부 로직은 실제 코드 사용
}
베스트 프랙티스
1. 명확한 테스트 구조 (AAA)
func TestUserCreation(t *testing.T) {
// Arrange (준비)
name := "John"
email := "john@example.com"
age := 25
// Act (실행)
user := NewUser(name, email, age)
// Assert (검증)
assert.Equal(t, name, user.Name)
assert.Equal(t, email, user.Email)
assert.Equal(t, age, user.Age)
}
2. 테스트 이름 명명 규칙
// 패턴: Test<Function>_<Scenario>_<ExpectedResult>
func TestDivide_ByZero_ReturnsError(t *testing.T) {
_, err := Divide(10, 0)
assert.Error(t, err)
}
func TestDivide_ValidInput_ReturnsQuotient(t *testing.T) {
result, err := Divide(10, 2)
assert.NoError(t, err)
assert.Equal(t, 5, result)
}
3. Table-driven tests 활용
func TestValidation(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"valid email", "user@example.com", false},
{"no @", "userexample.com", true},
{"no domain", "user@", true},
{"empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("got error %v, wantErr %v", err, tt.wantErr)
}
})
}
}
4. Golden Files
func TestRenderHTML(t *testing.T) {
data := &PageData{Title: "Test", Content: "Hello"}
result := RenderHTML(data)
goldenFile := "testdata/output.golden"
if *update {
os.WriteFile(goldenFile, []byte(result), 0644)
}
expected, err := os.ReadFile(goldenFile)
require.NoError(t, err)
assert.Equal(t, string(expected), result)
}
// 실행: go test -update
5. 테스트 데이터 분리
// testdata/users.json
[
{"id": 1, "name": "John"},
{"id": 2, "name": "Jane"}
]
func TestLoadUsers(t *testing.T) {
data, err := os.ReadFile("testdata/users.json")
require.NoError(t, err)
var users []User
err = json.Unmarshal(data, &users)
require.NoError(t, err)
assert.Len(t, users, 2)
}
6. 테스트 커버리지 목표
# 80% 이상 권장
go test -cover
# CI/CD에서 최소 커버리지 강제
go test -cover | grep -E 'coverage: [0-9]+' | \
awk '{if ($2 < 80) exit 1}'
정리
- 기본:
*_test.go,Test*함수,testing.T - go test: 자동 테스트 실행, 다양한 플래그
- Table-driven: Go 스타일 테스트 패턴
- Subtests: 계층적 구조, 병렬 실행
- 헬퍼:
t.Helper(),t.Cleanup(),t.TempDir() - 벤치마크:
Benchmark*,-bench,-benchmem - 커버리지:
-cover,-coverprofile, HTML 리포트 - Examples: 실행 가능한 문서,
// Output:검증 - 모킹: 인터페이스 기반, testify, httptest
- 통합 테스트: 빌드 태그, 환경 변수, Docker
- 실수: 독립성, 에러 검증, 클로저, 타이밍, 정리
- 베스트: AAA 패턴, 명확한 이름, 데이터 분리
- 원칙: 빠르고, 독립적이며, 반복 가능한 테스트