2026-01-06
이 글에서는 Go의 testing 패키지를 내부 메서드 차이부터 실무 패턴까지 체계적으로 다룹니다. 단순히 if result != expected를 확인하는 수준을 넘어, 테이블 드리븐 테스트로 케이스를 체계화하고, 인터페이스 Mock으로 외부 의존성을 분리하며, httptest로 HTTP 핸들러를 격리 테스트하고, 벤치마크로 성능을 측정하고, 커버리지로 테스트 완성도를 수치화하는 — 실제 프로덕션 코드베이스에서 바로 적용할 수 있는 테스트 기법을 모두 담았습니다.
testing.T의 메서드 중 헷갈리는 것들의 차이를 먼저 정리합니다.
| 메서드 | 동작 | 테스트 중단 여부 |
|---|---|---|
t.Log(args...) | 테스트 실패 시에만 출력 | 계속 실행 |
t.Logf(fmt, args...) | 포맷팅 버전 | 계속 실행 |
t.Error(args...) | 실패 표시 + 메시지 출력 | 계속 실행 |
t.Errorf(fmt, args...) | 포맷팅 버전 | 계속 실행 |
t.Fatal(args...) | 실패 표시 + 즉시 중단 | 즉시 중단 |
t.Fatalf(fmt, args...) | 포맷팅 버전 | 즉시 중단 |
t.Skip(args...) | 테스트 건너뜀 | 즉시 건너뜀 |
t.Helper() | 이 함수를 헬퍼로 표시 (에러 위치 개선) | - |
func TestUserService(t *testing.T) {
db, err := connectTestDB()
if err != nil {
t.Fatal("DB 연결 실패:", err) // DB 없으면 더 진행 불가 → Fatal
}
defer db.Close()
user, err := db.FindUser(1)
if err != nil {
t.Errorf("FindUser 실패: %v", err) // 실패 기록 후 계속
}
if user.Name == "" {
t.Error("사용자 이름이 비어 있습니다") // 다음 검증도 계속
}
if user.Email == "" {
t.Error("이메일이 비어 있습니다")
}
}
🛠️ 언제 Fatal, 언제 Error? 이후 검증이 앞 결과에 의존한다면
Fatal을, 독립적인 여러 항목을 검증한다면Error를 사용하세요. DB 연결 실패처럼 “이 이후로 아무것도 할 수 없는” 상황이Fatal의 전형적인 사례입니다.
// calculator.go
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("0으로 나눌 수 없습니다")
}
return a / b, nil
}
// calculator_test.go
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"양수 나눗셈", 10, 2, 5, false},
{"음수 나눗셈", -6, 3, -2, false},
{"소수 결과", 1, 3, 1.0 / 3.0, false},
{"0으로 나누기", 5, 0, 0, true},
{"0 나누기 0", 0, 0, 0, true},
}
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.Fatalf("Divide(%v, %v) 에러 = %v, wantErr %v",
tt.a, tt.b, err, tt.wantErr)
}
if err == nil && got != tt.want {
t.Errorf("Divide(%v, %v) = %v, want %v",
tt.a, tt.b, got, tt.want)
}
})
}
}
type TestCase struct {
name string
input string
wantValue int
wantErr error // 기대하는 에러 (nil이면 성공)
}
func TestParsePositive(t *testing.T) {
tests := []TestCase{
{"정상 숫자", "42", 42, nil},
{"빈 문자열", "", 0, ErrEmptyInput},
{"음수 입력", "-5", 0, ErrNegativeInput},
{"숫자가 아닌 값", "abc", 0, ErrInvalidFormat},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePositive(tt.input)
if !errors.Is(err, tt.wantErr) {
t.Errorf("에러 = %v, 기대 = %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("값 = %d, 기대 = %d", got, tt.wantValue)
}
})
}
}
독립적인 테스트는 병렬로 실행하면 전체 테스트 시간을 줄일 수 있습니다.
func TestUserCreation(t *testing.T) {
t.Parallel() // 이 테스트를 다른 Parallel 테스트와 병렬 실행
// ...
}
func TestUserDeletion(t *testing.T) {
t.Parallel()
// ...
}
테이블 드리븐 테스트에서 병렬 실행 시 클로저 변수 캡처 문제를 주의해야 합니다.
func TestProcessItems(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"case1", 1, 2},
{"case2", 2, 4},
{"case3", 3, 6},
}
for _, tt := range tests {
tt := tt // Go 1.22 미만에서 클로저 캡처 버그 방지 (복사 필수)
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 서브테스트 병렬 실행
got := doubleIt(tt.input)
if got != tt.want {
t.Errorf("doubleIt(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
🛠️ Go 1.22부터
for루프 변수가 반복마다 새로 생성되므로tt := tt복사가 불필요합니다. 하지만 Go 1.21 이하를 지원해야 한다면 여전히 필요합니다.
func TestWithDB(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(func() {
db.Close() // 테스트 종료 시 자동 호출
dropTestDB(db) // 서브테스트 포함, 모두 끝난 후 실행
})
// ... 테스트 로직
}
// 헬퍼 함수에서도 사용 가능
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // 에러 발생 시 이 함수 줄 번호 대신 호출자 줄 번호 표시
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal("테스트 DB 연결 실패:", err)
}
t.Cleanup(func() { db.Close() }) // 헬퍼 내에서 등록
return db
}
패키지 전체 테스트의 시작·종료 시점에 공통 작업이 필요하면 TestMain을 사용합니다.
// main_test.go
var testDB *sql.DB
func TestMain(m *testing.M) {
// 전역 설정 — 패키지 내 모든 테스트 전 1회 실행
var err error
testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "테스트 DB 연결 실패: %v\n", err)
os.Exit(1)
}
if err := migrateTestDB(testDB); err != nil {
fmt.Fprintf(os.Stderr, "마이그레이션 실패: %v\n", err)
os.Exit(1)
}
// 테스트 실행
exitCode := m.Run()
// 전역 정리
testDB.Close()
os.Exit(exitCode)
}
실무 테스트의 핵심은 외부 의존성(DB, HTTP API, 파일 시스템)을 Mock으로 교체하여 빠르고 격리된 테스트를 만드는 것입니다.
// internal/service/user.go
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id int64) error
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
if id <= 0 {
return nil, ErrValidationApp("id", "양수여야 합니다")
}
return s.repo.FindByID(ctx, id)
}
func (s *UserService) DeactivateUser(ctx context.Context, id int64) error {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return fmt.Errorf("사용자 조회 실패: %w", err)
}
user.Active = false
return s.repo.Save(ctx, user)
}
// internal/service/user_test.go
// MockUserRepository: UserRepository 인터페이스를 구현하는 Mock
type MockUserRepository struct {
FindByIDFn func(ctx context.Context, id int64) (*User, error)
SaveFn func(ctx context.Context, user *User) error
DeleteFn func(ctx context.Context, id int64) error
// 호출 추적
FindByIDCalls []int64
SaveCalls []*User
}
func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
m.FindByIDCalls = append(m.FindByIDCalls, id)
if m.FindByIDFn != nil {
return m.FindByIDFn(ctx, id)
}
return nil, nil
}
func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
m.SaveCalls = append(m.SaveCalls, user)
if m.SaveFn != nil {
return m.SaveFn(ctx, user)
}
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
if m.DeleteFn != nil {
return m.DeleteFn(ctx, id)
}
return nil
}
func TestUserService_GetUser(t *testing.T) {
tests := []struct {
name string
userID int64
mockSetup func(*MockUserRepository)
wantUser *User
wantErr bool
}{
{
name: "정상 조회",
userID: 1,
mockSetup: func(m *MockUserRepository) {
m.FindByIDFn = func(_ context.Context, id int64) (*User, error) {
return &User{ID: 1, Name: "홍길동", Active: true}, nil
}
},
wantUser: &User{ID: 1, Name: "홍길동", Active: true},
wantErr: false,
},
{
name: "존재하지 않는 사용자",
userID: 999,
mockSetup: func(m *MockUserRepository) {
m.FindByIDFn = func(_ context.Context, id int64) (*User, error) {
return nil, ErrNotFound
}
},
wantUser: nil,
wantErr: true,
},
{
name: "유효하지 않은 ID (0)",
userID: 0,
mockSetup: func(m *MockUserRepository) {}, // Mock 호출 안 됨
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := &MockUserRepository{}
tt.mockSetup(mockRepo)
svc := NewUserService(mockRepo)
got, err := svc.GetUser(context.Background(), tt.userID)
if (err != nil) != tt.wantErr {
t.Fatalf("에러 = %v, wantErr = %v", err, tt.wantErr)
}
if !tt.wantErr && got.Name != tt.wantUser.Name {
t.Errorf("이름 = %q, want %q", got.Name, tt.wantUser.Name)
}
})
}
}
func TestUserService_DeactivateUser(t *testing.T) {
mockRepo := &MockUserRepository{
FindByIDFn: func(_ context.Context, id int64) (*User, error) {
return &User{ID: id, Name: "홍길동", Active: true}, nil
},
}
svc := NewUserService(mockRepo)
err := svc.DeactivateUser(context.Background(), 1)
if err != nil {
t.Fatalf("예상치 못한 에러: %v", err)
}
// Save가 1번 호출되었고, Active가 false인지 확인
if len(mockRepo.SaveCalls) != 1 {
t.Fatalf("Save 호출 횟수 = %d, want 1", len(mockRepo.SaveCalls))
}
if mockRepo.SaveCalls[0].Active {
t.Error("비활성화 후에도 Active가 true입니다")
}
}
net/http/httptest 패키지를 사용하면 실제 서버를 띄우지 않고 HTTP 핸들러를 테스트할 수 있습니다.
// internal/handler/user_test.go
func TestUserHandler_GetProfile(t *testing.T) {
tests := []struct {
name string
userID string
mockSetup func(*MockUserService)
wantStatus int
wantBody string
}{
{
name: "정상 조회",
userID: "1",
mockSetup: func(m *MockUserService) {
m.GetUserFn = func(_ context.Context, id int64) (*User, error) {
return &User{ID: 1, Name: "홍길동"}, nil
}
},
wantStatus: http.StatusOK,
wantBody: `"name":"홍길동"`,
},
{
name: "잘못된 ID",
userID: "abc",
mockSetup: func(m *MockUserService) {},
wantStatus: http.StatusBadRequest,
},
{
name: "존재하지 않는 사용자",
userID: "999",
mockSetup: func(m *MockUserService) {
m.GetUserFn = func(_ context.Context, id int64) (*User, error) {
return nil, &AppError{HTTPStatus: 404, Code: "NOT_FOUND", Message: "사용자 없음"}
}
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSvc := &MockUserService{}
tt.mockSetup(mockSvc)
handler := NewUserHandler(mockSvc)
// httptest.NewRecorder: ResponseWriter를 흉내내는 레코더
w := httptest.NewRecorder()
// httptest.NewRequest: 실제 HTTP 요청 없이 Request 생성
r := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil)
// Go 1.22 PathValue 사용 시 mux를 통해 라우팅
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", handler.GetProfile)
mux.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != tt.wantStatus {
t.Errorf("상태 코드 = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if tt.wantBody != "" {
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), tt.wantBody) {
t.Errorf("응답 본문에 %q 없음\n본문: %s", tt.wantBody, body)
}
}
})
}
}
Benchmark로 시작하는 함수는 성능 측정에 사용됩니다.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ { // b.N은 벤치마크 프레임워크가 자동 결정
Add(1, 2)
}
}
// 비교 벤치마크: 두 구현의 성능 비교
func BenchmarkMarshalJSON(b *testing.B) {
user := User{ID: 1, Name: "홍길동", Email: "hong@example.com"}
b.ResetTimer() // 셋업 시간 제외
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(user)
}
}
func BenchmarkEncodeJSON(b *testing.B) {
user := User{ID: 1, Name: "홍길동", Email: "hong@example.com"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(user)
}
}
# 벤치마크 실행
go test -bench=. -benchmem
# 특정 벤치마크만
go test -bench=BenchmarkMarshal -benchtime=5s
# 결과 예시:
# BenchmarkMarshalJSON-8 3000000 450 ns/op 64 B/op 1 allocs/op
-benchmem 옵션은 메모리 할당 횟수(allocs/op)와 할당량(B/op)을 함께 출력합니다.
# 커버리지 비율 출력
go test -cover ./...
# HTML 리포트 생성
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# 패키지별 커버리지
go test -coverprofile=coverage.out -covermode=count ./...
go tool cover -func=coverage.out
🛠️ 커버리지 100%가 목표가 아닙니다. 중요한 비즈니스 로직과 에러 처리 경로에 집중하세요. main 함수, 로깅 코드, 단순 getter/setter까지 테스트하는 것은 비용 대비 효과가 낮습니다. 일반적으로 70~80%를 실용적인 목표로 삼습니다.
Example로 시작하는 함수는 문서화와 테스트를 동시에 합니다. // Output: 주석과 실제 출력이 일치해야 테스트가 통과합니다.
// 함수명: ExampleFunctionName
// 메서드명: ExampleType_MethodName
func ExampleAdd() {
result := Add(3, 4)
fmt.Println(result)
// Output:
// 7
}
func ExampleDivide() {
result, err := Divide(10, 3)
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Printf("%.4f\n", result)
// Output:
// 3.3333
}
예제 함수는 go doc 명령어나 pkg.go.dev에서 자동으로 문서에 포함됩니다.
테스트에서 반복되는 검증 로직은 헬퍼 함수로 추출합니다.
// testutil/assert.go (테스트 전용 유틸리티)
func AssertEqual(t *testing.T, got, want any) {
t.Helper() // 이 함수를 호출한 곳의 줄 번호를 에러에 표시
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func AssertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("예상치 못한 에러: %v", err)
}
}
func AssertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("에러가 발생해야 하지만 nil이 반환되었습니다")
}
}
func AssertErrorIs(t *testing.T, err, target error) {
t.Helper()
if !errors.Is(err, target) {
t.Errorf("에러 = %v, 기대 = %v", err, target)
}
}
// ❌ 테스트 간 상태 공유 — 실행 순서에 따라 결과 달라짐
var sharedCounter int
func TestIncrease(t *testing.T) {
sharedCounter++
// ...
}
// ✅ 각 테스트에서 독립된 상태 생성
func TestIncrease(t *testing.T) {
counter := 0
counter++
// ...
}
// ❌ 비결정적 — 느린 환경에서 실패
go processAsync()
time.Sleep(100 * time.Millisecond) // "충분히 기다리겠지?"
assertDone()
// ✅ 채널이나 sync.WaitGroup으로 명시적 동기화
done := make(chan struct{})
go func() {
processAsync()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("타임아웃: processAsync가 완료되지 않음")
}
// ❌ 외부 API 호출 — 느리고 네트워크 의존적
func TestGetWeather(t *testing.T) {
result, err := http.Get("https://api.weather.com/v1/current")
// ...
}
// ✅ httptest.NewServer로 Mock 서버 사용
func TestGetWeather(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"temp": 25, "condition": "sunny"}`)
}))
defer server.Close()
client := NewWeatherClient(server.URL)
result, err := client.GetCurrent()
// ...
}
t.Fatal vs t.Error: 이후 진행 불가면 Fatal, 독립 검증은 Errort.Helper(): 헬퍼 함수에 필수 — 에러 위치를 호출자로 표시[]struct{...} + t.Run(tt.name, ...) 패턴t.Parallel(): 독립 테스트 병렬화. Go 1.21 이하는 tt := tt 복사 필요t.Cleanup(): defer보다 안전한 정리. 서브테스트 포함 후 실행TestMain: 패키지 전역 설정/정리. m.Run() 반드시 호출httptest: NewRecorder + NewRequest로 실제 서버 없이 HTTP 핸들러 테스트go test -bench=. -benchmem으로 성능과 메모리 할당 측정go test -coverprofile=coverage.out + HTML 리포트로 시각화time.Sleep 대기, 외부 서비스 직접 호출다음 글에서는 Go 패키지와 모듈 구조 설계를 다룹니다. 프로젝트 레이아웃, 패키지 분리 전략, 의존성 관리, 내부 패키지까지 살펴보겠습니다.