Go 단위 테스트: 구조 및 최고의 실천 방법
Go 테스트: 기초부터 고급 패턴까지
Go의 내장 테스트 패키지 는 외부 의존성을 사용하지 않고 단위 테스트를 작성하기 위한 강력하고 최소주의적인 프레임워크를 제공합니다. 이 문서에서는 테스트의 기본 개념, 프로젝트 구조, 그리고 신뢰할 수 있는 Go 애플리케이션을 구축하기 위한 고급 패턴을 설명합니다.

Go에서 테스트가 중요한 이유
Go의 철학은 간결성과 신뢰성을 강조합니다. 표준 라이브러리에는 testing 패키지가 포함되어 있어, 단위 테스트는 Go 생태계에서 일등 시민으로 자리 잡고 있습니다. 잘 테스트된 Go 코드는 유지보수성을 향상시키고, 오류를 조기에 발견하며, 예제를 통해 문서화를 제공합니다. Go에 새로 시작하신 분이라면, 언어의 기본 개념을 빠르게 확인할 수 있는 Go 체크시트를 참고하시기 바랍니다.
Go 테스트의 주요 이점:
- 내장 지원: 외부 프레임워크가 필요하지 않음
- 빠른 실행: 기본적으로 테스트가 병렬로 실행됨
- 간단한 문법: 최소한의 보일러플레이트 코드
- 풍부한 도구: 커버리지 보고서, 벤치마크, 프로파일링
- CI/CD 친화적: 자동화된 파이프라인과 쉽게 통합 가능
Go 테스트의 프로젝트 구조
Go 테스트는 명확한 이름 규칙을 따르며, 프로덕션 코드와 함께 존재합니다:
myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│ ├── helper.go
│ └── helper_test.go
└── models/
├── user.go
└── user_test.go
주요 규칙:
- 테스트 파일은
_test.go로 끝나야 함 - 테스트는 코드와 같은 패키지에 있어야 하거나, 블랙박스 테스트를 위해
_test접두사를 사용해야 함 - 각 소스 파일에는 대응하는 테스트 파일이 있을 수 있음
패키지 테스트 접근 방식
화이트박스 테스트 (같은 패키지):
package calculator
import "testing"
// 비공개 함수와 변수에 접근 가능
블랙박스 테스트 (외부 패키지):
package calculator_test
import (
"testing"
"myproject/calculator"
)
// 공개된 함수만 접근 가능 (공개 API에 권장됨)
기본 테스트 구조
모든 테스트 함수는 다음 패턴을 따릅니다:
package calculator
import "testing"
// 테스트 함수는 "Test"로 시작해야 함
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
testing.T 메서드:
t.Error()/t.Errorf(): 테스트 실패 표시하지만 계속 진행t.Fatal()/t.Fatalf(): 테스트 실패 표시하고 즉시 중단t.Log()/t.Logf(): 로그 출력 (-v플래그로만 표시됨)t.Skip()/t.Skipf(): 테스트 건너뛰기t.Parallel(): 다른 병렬 테스트와 병렬 실행
테이블 기반 테스트: Go의 방식
테이블 기반 테스트는 여러 시나리오를 테스트하는 Go의 표준 방식입니다. Go 제네릭을 사용하면, 다양한 데이터 타입에 걸쳐 타입 안전한 테스트 헬퍼를 생성할 수 있습니다:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 5, 3, "-", 2, false},
{"multiplication", 4, 3, "*", 12, false},
{"division", 10, 2, "/", 5, false},
{"division by zero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
장점:
- 여러 시나리오에 대한 단일 테스트 함수
- 새로운 테스트 케이스 추가가 쉬움
- 예상되는 동작에 대한 명확한 문서화
- 더 나은 테스트 조직 및 유지보수
테스트 실행
기본 명령
# 현재 디렉토리의 테스트 실행
go test
# 상세한 출력으로 테스트 실행
go test -v
# 모든 하위 디렉토리의 테스트 실행
go test ./...
# 특정 테스트 실행
go test -run TestAdd
# 패턴에 맞는 테스트 실행
go test -run TestCalculate/addition
# 병렬로 테스트 실행 (기본값은 GOMAXPROCS)
go test -parallel 4
# 시간 제한으로 테스트 실행
go test -timeout 30s
테스트 커버리지
# 커버리지와 함께 테스트 실행
go test -cover
# 커버리지 프로필 생성
go test -coverprofile=coverage.out
# 브라우저에서 커버리지 보기
go tool cover -html=coverage.out
# 함수별 커버리지 보기
go tool cover -func=coverage.out
# 커버리지 모드 설정 (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out
유용한 플래그
-short:if testing.Short()체크가 있는 테스트 실행-race: 경쟁 조건 문제를 찾기 위한 레이스 디텍터 활성화-cpu: GOMAXPROCS 값 지정-count n: 각 테스트를 n회 실행-failfast: 첫 번째 테스트 실패 시 중단
테스트 헬퍼 및 설정/정리
헬퍼 함수
t.Helper()로 헬퍼 함수를 표시하여 오류 보고를 개선할 수 있습니다:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 이 줄이 호출자로 표시됨
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestMath(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5) // 오류 줄이 여기로 표시됨
}
설정 및 정리
func TestMain(m *testing.M) {
// 설정 코드
setup()
// 테스트 실행
code := m.Run()
// 정리 코드
teardown()
os.Exit(code)
}
테스트 테스트 케이스
func setupTestCase(t *testing.T) func(t *testing.T) {
t.Log("테스트 케이스 설정")
return func(t *testing.T) {
t.Log("테스트 케이스 정리")
}
}
func TestSomething(t *testing.T) {
teardown := setupTestCase(t)
defer teardown(t)
// 테스트 코드
}
모킹 및 의존성 주입
인터페이스 기반 모킹
데이터베이스와 상호작용하는 코드를 테스트할 때, 인터페이스를 사용하면 모킹 구현을 쉽게 만들 수 있습니다. PostgreSQL과 Go를 사용하는 경우, Go ORM 비교를 참고하여 테스트 가능성이 높은 데이터베이스 라이브러리를 선택하시기 바랍니다.
// 프로덕션 코드
type Database interface {
GetUser(id int) (*User, error)
}
type UserService struct {
db Database
}
func (s *UserService) GetUserName(id int) (string, error) {
user, err := s.db.GetUser(id)
if err != nil {
return "", err
}
return user.Name, nil
}
// 테스트 코드
type MockDatabase struct {
users map[int]*User
}
func (m *MockDatabase) GetUser(id int) (*User, error) {
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, errors.New("user not found")
}
func TestGetUserName(t *testing.T) {
mockDB := &MockDatabase{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
},
}
service := &UserService{db: mockDB}
name, err := service.GetUserName(1)
if err != nil {
t.Fatalf("예상치 못한 오류: %v", err)
}
if name != "Alice" {
t.Errorf("got %s, want Alice", name)
}
}
인기 있는 테스트 라이브러리
Testify
단위 테스트와 모킹을 위한 가장 인기 있는 Go 테스트 라이브러리:
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestWithTestify(t *testing.T) {
result := Add(2, 3)
assert.Equal(t, 5, result, "값이 같아야 함")
assert.NotNil(t, result)
}
// 모킹 예제
type MockDB struct {
mock.Mock
}
func (m *MockDB) GetUser(id int) (*User, error) {
args := m.Called(id)
return args.Get(0).(*User), args.Error(1)
}
기타 도구
- gomock: 코드 생성을 사용하는 Google의 모킹 프레임워크
- httptest: HTTP 핸들러 테스트를 위한 표준 라이브러리
- testcontainers-go: Docker 컨테이너와의 통합 테스트
- ginkgo/gomega: BDD 스타일의 테스트 프레임워크
외부 서비스(예: AI 모델)와의 통합 테스트를 수행할 때는 해당 의존성을 모킹하거나 스텁으로 만들어야 합니다. 예를 들어, Go에서 Ollama 사용을 사용하는 경우, 인터페이스 래퍼를 생성하여 코드가 더 테스트 가능하도록 만드는 것이 좋습니다.
벤치마크 테스트
Go는 벤치마크에 대한 내장 지원을 제공합니다:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// 벤치마크 실행
// go test -bench=. -benchmem
출력은 초당 반복 횟수와 메모리 할당량을 보여줍니다.
최고의 실천 방법
- 테이블 기반 테스트 작성: 여러 테스트 케이스를 위해 구조체 슬라이스 패턴 사용
- t.Run을 사용하여 서브테스트 실행: 더 나은 조직화 및 선택적 서브테스트 실행 가능
- 먼저 공개된 함수 테스트: 공개 API의 동작에 초점
- 테스트를 간단하게 유지: 각 테스트는 하나의 것을 검증해야 함
- 의미 있는 테스트 이름 사용: 테스트하는 내용과 예상 결과를 설명
- 구현 세부 사항 테스트하지 않음: 동작을 테스트, 내부 구현은 테스트하지 않음
- 의존성에 대한 인터페이스 사용: 모킹이 더 쉬움
- 높은 커버리지 목표, 하지만 품질 우선: 100% 커버리지는 버그 없는 것을 의미하지 않음
- -race 플래그로 테스트 실행: 동시성 문제를 조기에 발견
- 비용이 많이 드는 설정에 TestMain 사용: 각 테스트에서 설정 반복 피함
예제: 완전한 테스트 스위트
package user
import (
"errors"
"testing"
)
type User struct {
ID int
Name string
Email string
}
func ValidateUser(u *User) error {
if u.Name == "" {
return errors.New("이름은 비워둘 수 없음")
}
if u.Email == "" {
return errors.New("이메일은 비워둘 수 없음")
}
return nil
}
// 테스트 파일: user_test.go
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user *User
wantErr bool
errMsg string
}{
{
name: "유효한 사용자",
user: &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
wantErr: false,
},
{
name: "빈 이름",
user: &User{ID: 1, Name: "", Email: "alice@example.com"},
wantErr: true,
errMsg: "이름은 비워둘 수 없음",
},
{
name: "빈 이메일",
user: &User{ID: 1, Name: "Alice", Email: ""},
wantErr: true,
errMsg: "이메일은 비워둘 수 없음",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && err.Error() != tt.errMsg {
t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
}
})
}
}
유용한 링크
- 공식 Go 테스트 패키지 문서
- Go 블로그: 테이블 기반 테스트
- Testify GitHub 저장소
- GoMock 문서
- 테스트를 통해 Go 학습
- Go 코드 커버리지 도구
- Go 체크시트
- PostgreSQL용 Go ORM 비교: GORM vs Ent vs Bun vs sqlc
- Go용 Ollama SDK - 예제 포함 비교
- Cobra & Viper를 사용한 Go CLI 애플리케이션 개발
- Go 제네릭: 사용 사례 및 패턴
결론
Go의 테스트 프레임워크는 최소한의 설정으로 포괄적인 단위 테스트를 위해 필요한 모든 것을 제공합니다. 테이블 기반 테스트와 같은 Go의 관용구를 따르고, 인터페이스를 사용하여 모킹을 수행하며, 내장 도구를 활용함으로써, 코드베이스와 함께 성장할 수 있는 유지보수가 가능한, 신뢰할 수 있는 테스트 스위트를 생성할 수 있습니다.
이러한 테스트 실천 방법은 웹 서비스부터 Cobra & Viper를 사용한 CLI 애플리케이션까지 모든 종류의 Go 애플리케이션에 적용됩니다. 명령줄 도구의 테스트는 입력/출력과 플래그 파싱에 대한 추가적인 집중이 필요하지만, 유사한 패턴을 사용합니다.
간단한 테스트부터 시작하여 점차 커버리지를 확장하고, 테스트는 코드 품질과 개발자 자신감에 대한 투자임을 기억하세요. Go 커뮤니티의 테스트에 대한 강조는 프로젝트를 장기적으로 유지하고 팀원들과 효과적으로 협업하는 것을 더 쉽게 만들어 줍니다.