Ch15 실전 고수 팁 — 테스트 전략 완성
프로덕션 Go 코드의 테스트는 단순히 커버리지 수치를 채우는 것이 아닙니다. 빠르고 신뢰할 수 있는 테스트 스위트를 구성하는 고급 전략을 알아봅니다.
테스트 피라미드 전략
/\
/E2E\ ← 적게, 느리게, 비싸게
/──────\
/통합 테스트\ ← 중간
/────────────\
/ 단위 테스트 \ ← 많이, 빠르게, 저렴하게
/────────────────\
Go에서의 실천
// 단위 테스트: 순수 함수, 로직 검증 — 빠르고 많이
func TestCalculateDiscount(t *testing.T) { ... }
// 통합 테스트: DB/외부 API 포함 — 느리고 적게
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("통합 테스트 건너뜀 (-short)")
}
// ... testcontainers 또는 테스트 DB
}
// E2E 테스트: 전체 시나리오 — 가장 느리고 가장 적게
func TestUserFlow_E2E(t *testing.T) {
if os.Getenv("E2E_TEST") == "" {
t.Skip("E2E 테스트 건너뜀 (E2E_TEST 환경변수 없음)")
}
// ... 실제 서버 + 실제 DB + 실제 HTTP 클라이언트
}
# 단위 테스트만 (빠름)
go test ./...
# 통합 테스트 제외
go test -short ./...
# E2E 포함 (느림)
E2E_TEST=1 go test ./...
커버리지 전략
의미 있는 커버리지
// ✅ 행동을 테스트 — 커버리지는 부산물
func TestTransfer(t *testing.T) {
tests := []struct {
name string
from int64
to int64
amount float64
wantErr bool
}{
{"정상 이체", 1, 2, 100.0, false},
{"잔액 부족", 1, 2, 99999.0, true},
{"동일 계좌", 1, 1, 100.0, true},
{"음수 금액", 1, 2, -10.0, true},
}
// ...
}
// ❌ 커버리지를 위한 테스트 — 의미 없음
func TestGetterSetter(t *testing.T) {
u := User{}
u.SetName("test")
assert.Equal(t, "test", u.GetName()) // getter/setter 커버리지를 위해 작성
}
커버리지 리포트 분석
# 커버리지 측정
go test -coverprofile=coverage.out ./...
# 함수별 커버리지
go tool cover -func=coverage.out | tail -20
# HTML 시각화
go tool cover -html=coverage.out -o coverage.html
# 패키지별 최소 커버리지 검증 (CI용)
go test -coverprofile=coverage.out ./... && \
go tool cover -func=coverage.out | \
awk '/total:/{if ($3+0 < 80) {print "커버리지 부족: " $3; exit 1}}'
테스트 격리 패턴
각 테스트가 독립적이어야 하는 이유
// ❌ 공유 상태로 테스트 간 의존성 발생
var sharedDB *sql.DB
func TestA(t *testing.T) {
sharedDB.Exec("INSERT INTO users VALUES (1, 'A')")
// ...
}
func TestB(t *testing.T) {
// TestA가 먼저 실행되어야 통과 — 순서 의존성!
row := sharedDB.QueryRow("SELECT name FROM users WHERE id = 1")
// ...
}
// ✅ 각 테스트가 독자적인 상태 생성
func TestA(t *testing.T) {
db := setupTestDB(t) // t.Cleanup으로 자동 정리
db.Exec("INSERT INTO users VALUES (1, 'A')")
// ...
}
func TestB(t *testing.T) {
db := setupTestDB(t)
// ...
}
t.Cleanup으로 정리 자동화
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// 핸들러 등록...
ts := httptest.NewServer(mux)
t.Cleanup(func() {
ts.Close()
})
return ts
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
runMigrations(t, db)
return db
}
테스트 헬퍼 라이브러리 활용
testify 심화
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// 커스텀 assert 메시지
assert.Equal(t, expected, actual, "사용자 ID가 일치해야 함: user=%v", user)
// 에러 타입 검증
var notFoundErr *NotFoundError
assert.ErrorAs(t, err, ¬FoundErr)
assert.Equal(t, int64(42), notFoundErr.ID)
// 에러 메시지 포함 여부
assert.ErrorContains(t, err, "invalid email")
// Eventually — 비동기 상태 검증
assert.Eventually(t, func() bool {
return cache.Has("key")
}, 5*time.Second, 100*time.Millisecond)
// Never — 일정 시간 동안 조건이 거짓이어야 함
assert.Never(t, func() bool {
return len(errors) > 0
}, 2*time.Second, 50*time.Millisecond)
Suite 패턴 — 공통 setUp/tearDown
type UserServiceSuite struct {
suite.Suite
db *sql.DB
service *UserService
mock *MockEmailService
}
func (s *UserServiceSuite) SetupSuite() {
// 전체 스위트에서 한 번만 실행
s.db = setupTestDB(s.T())
}
func (s *UserServiceSuite) SetupTest() {
// 각 테스트 전에 실행
s.mock = new(MockEmailService)
repo := NewUserRepository(s.db)
s.service = NewUserService(repo, s.mock)
// DB 초기화
s.db.Exec("DELETE FROM users")
}
func (s *UserServiceSuite) TearDownTest() {
// 각 테스트 후 실행
s.mock.AssertExpectations(s.T())
}
func (s *UserServiceSuite) TestRegister_Success() {
user, err := s.service.Register(context.Background(), "김고랭", "go@example.com", "pass")
s.Require().NoError(err)
s.Equal("김고랭", user.Name)
}
func (s *UserServiceSuite) TestRegister_DuplicateEmail() {
// ...
}
// Suite 실행 진입점
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
골든 파일 테스트
복잡한 출력(HTML, JSON 리포트 등)을 파일로 저장해두고 비교합니다.
func TestGenerateReport(t *testing.T) {
report := GenerateReport(testData)
goldenFile := "testdata/report.golden"
// -update 플래그로 골든 파일 갱신
if *update {
os.WriteFile(goldenFile, []byte(report), 0644)
return
}
expected, err := os.ReadFile(goldenFile)
require.NoError(t, err)
assert.Equal(t, string(expected), report)
}
var update = flag.Bool("update", false, "골든 파일 갱신")
# 골든 파일 초기 생성 또는 갱신
go test -run TestGenerateReport -update
# 이후 회귀 테스트
go test -run TestGenerateReport
CI/CD 테스트 최적화
Makefile 테스트 타겟
.PHONY: test test-unit test-integration test-e2e test-cover
# 빠른 단위 테스트 (PR 시)
test-unit:
go test -short -race ./...
# 통합 테스트 포함
test-integration:
go test -race ./...
# E2E 테스트
test-e2e:
E2E_TEST=1 go test -race -timeout=10m ./...
# 커버리지 리포트
test-cover:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@go tool cover -func=coverage.out | grep total
# 전체 테스트
test: test-unit test-integration
GitHub Actions 통합
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Run unit tests
run: go test -short -race -coverprofile=coverage.out ./...
- name: Check coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "커버리지 80% 미만"
exit 1
fi
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.out
-race 플래그 — 레이스 컨디션 탐지
# 레이스 컨디션 감지 (CI에서 필수)
go test -race ./...
// ❌ 레이스 컨디션: 고루틴에서 공유 변수 접근
func TestRace(t *testing.T) {
count := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // DATA RACE!
}()
}
wg.Wait()
}
// ✅ 뮤텍스로 보호
func TestNoRace(t *testing.T) {
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
assert.Equal(t, 100, count)
}
핵심 정리
| 전략 | 원칙 |
|---|---|
| 테스트 피라미드 | 단위 > 통합 > E2E (비율 유지) |
| 격리 | 각 테스트는 독립적 상태 — t.Cleanup 활용 |
| 커버리지 | 80% 목표, 의미 있는 케이스 우선 |
| 레이스 감지 | CI에서 항상 -race 플래그 사용 |
| 속도 | -short로 느린 테스트 분리 |
| 병렬화 | t.Parallel()로 독립 테스트 가속 |
테스트 품질 체크리스트:
- 단위/통합/E2E 레이어 분리
-
t.Cleanup으로 리소스 정리 자동화 -
-race플래그로 레이스 컨디션 검증 - 커버리지 80% 이상 유지
- 골든 파일로 복잡한 출력 검증
- CI에서
go vet+-race함께 실행