From ecc
테이블 주도 테스트, 서브테스트, 벤치마크, 퍼징, 테스트 커버리지를 포함한 Go 테스팅 패턴. 관용적 Go 관행과 함께 TDD 방법론을 따릅니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
TDD(테스트 주도 개발) 방법론을 따르는 신뢰할 수 있고 유지보수 가능한 테스트 작성을 위한 포괄적인 Go 테스팅 패턴입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
TDD(테스트 주도 개발) 방법론을 따르는 신뢰할 수 있고 유지보수 가능한 테스트 작성을 위한 포괄적인 Go 테스팅 패턴입니다.
RED → 먼저 실패하는 테스트를 작성합니다.
GREEN → 테스트를 통과하기 위한 최소한의 코드를 작성합니다.
REFACTOR → 테스트를 통과 상태로 유지하면서 코드를 개선합니다.
REPEAT → 다음 요구사항으로 넘어갑니다.
// 1단계: 인터페이스/시그니처 정의
// calculator.go
package calculator
func Add(a, b int) int {
panic("not implemented") // 플레이스홀더
}
// 2단계: 실패하는 테스트 작성 (RED)
// calculator_test.go
package calculator
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)
}
}
// 3단계: 테스트 실행 - 실패 확인
// $ go test
// --- FAIL: TestAdd (0.00s)
// panic: not implemented
// 4단계: 최소한의 코드 구현 (GREEN)
func Add(a, b int) int {
return a + b
}
// 5단계: 테스트 실행 - 통과 확인
// $ go test
// PASS
// 6단계: 필요한 경우 리팩터링하고 테스트가 여전히 통과하는지 확인
Go 테스트의 표준 패턴입니다. 최소한의 코드로 포괄적인 커버리지를 가능하게 합니다.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"양수", 2, 3, 5},
{"음수", -1, -2, -3},
{"0 값", 0, 0, 0},
{"부호 혼합", -1, 1, 0},
{"큰 수", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "유효한 설정",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "유효하지 않은 JSON",
input: `{invalid}`,
wantErr: true,
},
{
name: "빈 입력",
input: "",
wantErr: true,
},
{
name: "최소 설정",
input: `{}`,
want: &Config{}, // 기본값 설정
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig(tt.input)
if tt.wantErr {
if err == nil {
t.Error("에러를 예상했으나 nil이 반환됨")
}
return
}
if err != nil {
t.Fatalf("예상치 못한 에러: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v; want %+v", got, tt.want)
}
})
}
}
func TestUser(t *testing.T) {
// 모든 서브테스트에서 공유하는 설정
db := setupTestDB(t)
t.Run("생성", func(t *testing.T) {
user := &User{Name: "Alice"}
err := db.CreateUser(user)
if err != nil {
t.Fatalf("CreateUser 실패: %v", err)
}
if user.ID == "" {
t.Error("사용자 ID가 설정될 것으로 예상됨")
}
})
t.Run("조회", func(t *testing.T) {
user, err := db.GetUser("alice-id")
if err != nil {
t.Fatalf("GetUser 실패: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
})
t.Run("수정", func(t *testing.T) {
// ...
})
t.Run("삭제", func(t *testing.T) {
// ...
})
}
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"케이스1", "input1"},
{"케이스2", "input2"},
{"케이스3", "input3"},
}
for _, tt := range tests {
tt := tt // 범위 변수 캡처
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 서브테스트를 병렬로 실행
result := Process(tt.input)
// 검증...
_ = result
})
}
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // 이 함수를 헬퍼 함수로 표시합니다.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("데이터베이스 열기 실패: %v", err)
}
// 테스트가 종료될 때 정리 작업을 수행합니다.
t.Cleanup(func() {
db.Close()
})
// 마이그레이션 실행
if _, err := db.Exec(schema); err != nil {
t.Fatalf("스키마 생성 실패: %v", err)
}
return db
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("예상치 못한 에러: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func TestFileProcessing(t *testing.T) {
// 임시 디렉터리 생성 - 자동으로 정리됨
tmpDir := t.TempDir()
// 테스트 파일 생성
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
if err != nil {
t.Fatalf("테스트 파일 생성 실패: %v", err)
}
// 테스트 실행
result, err := ProcessFile(testFile)
if err != nil {
t.Fatalf("ProcessFile 실패: %v", err)
}
// 검증...
_ = result
}
testdata/에 저장된 예상 출력 파일에 대한 테스트입니다.
var update = flag.Bool("update", false, "골든 파일 업데이트")
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
}{
{"단순", Template{Name: "test"}},
{"복잡", Template{Name: "test", Items: []string{"a", "b"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
golden := filepath.Join("testdata", tt.name+".golden")
if *update {
// 골든 파일 업데이트: go test -update
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatalf("골든 파일 업데이트 실패: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("골든 파일 읽기 실패: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("출력 불일치:\ngot:\n%s\nwant:\n%s", got, want)
}
})
}
}
// 의존성을 위한 인터페이스 정의
type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
// 프로덕션 구현체
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
// 실제 데이터베이스 쿼리
}
// 테스트를 위한 모킹 구현체
type MockUserRepository struct {
GetUserFunc func(id string) (*User, error)
SaveUserFunc func(user *User) error
}
func (m *MockUserRepository) GetUser(id string) (*User, error) {
return m.GetUserFunc(id)
}
func (m *MockUserRepository) SaveUser(user *User) error {
return m.SaveUserFunc(user)
}
// 모킹을 사용한 테스트
func TestUserService(t *testing.T) {
mock := &MockUserRepository{
GetUserFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
service := NewUserService(mock)
user, err := service.GetUserProfile("123")
if err != nil {
t.Fatalf("예상치 못한 에러: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
}
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer() // 설정 시간은 포함하지 않습니다.
for i := 0; i < b.N; i++ {
Process(data)
}
}
// 실행: go test -bench=BenchmarkProcess -benchmem
// 출력: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 이미 정렬된 데이터를 다시 정렬하지 않도록 복사본을 만듭니다.
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"hello", "world", "foo", "bar", "baz"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
})
}
func FuzzParseJSON(f *testing.F) {
// 시드 코퍼스(seed corpus) 추가
f.Add(`{"name": "test"}`)
f.Add(`{"count": 123}`)
f.Add(`[]`)
f.Add(`""`)
f.Fuzz(func(t *testing.T, input string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(input), &result)
if err != nil {
// 무작위 입력에 대해 유효하지 않은 JSON이 발생하는 것은 정상입니다.
return
}
// 파싱에 성공했다면, 다시 인코딩하는 것도 성공해야 합니다.
_, err = json.Marshal(result)
if err != nil {
t.Errorf("성공적인 Unmarshal 후 Marshal 실패: %v", err)
}
})
}
// 실행: go test -fuzz=FuzzParseJSON -fuzztime=30s
func FuzzCompare(f *testing.F) {
f.Add("hello", "world")
f.Add("", "")
f.Add("abc", "abc")
f.Fuzz(func(t *testing.T, a, b string) {
result := Compare(a, b)
// 속성: Compare(a, a)는 항상 0이어야 함
if a == b && result != 0 {
t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result)
}
// 속성: Compare(a, b)와 Compare(b, a)는 서로 반대 부호여야 함
reverse := Compare(b, a)
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
if result != 0 || reverse != 0 {
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; 일관성 없음",
a, b, result, b, a, reverse)
}
}
})
}
# 기본 커버리지 확인
go test -cover ./...
# 커버리지 프로필 생성
go test -coverprofile=coverage.out ./...
# 브라우저에서 커버리지 확인
go tool cover -html=coverage.out
# 함수별 커버리지 확인
go tool cover -func=coverage.out
# 레이스 디텍터와 함께 커버리지 실행
go test -race -coverprofile=coverage.out ./...
| 코드 유형 | 목표 |
|---|---|
| 핵심 비즈니스 로직 | 100% |
| 공개 API | 90%+ |
| 일반 코드 | 80%+ |
| 생성된 코드 | 제외 |
//go:generate mockgen -source=interface.go -destination=mock_interface.go
// 커버리지 프로필에서 빌드 태그를 사용하여 제외합니다:
// go test -cover -tags=!generate ./...
func TestHealthHandler(t *testing.T) {
// 요청 생성
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// 핸들러 호출
HealthHandler(w, req)
// 응답 확인
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "OK" {
t.Errorf("got body %q; want %q", body, "OK")
}
}
func TestAPIHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "사용자 조회",
method: http.MethodGet,
path: "/users/123",
wantStatus: http.StatusOK,
wantBody: `{"id":"123","name":"Alice"}`,
},
{
name: "찾을 수 없음",
method: http.MethodGet,
path: "/users/999",
wantStatus: http.StatusNotFound,
},
{
name: "사용자 생성",
method: http.MethodPost,
path: "/users",
body: `{"name":"Bob"}`,
wantStatus: http.StatusCreated,
},
}
handler := NewAPIHandler()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, tt.path, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("got status %d; want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && w.Body.String() != tt.wantBody {
t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody)
}
})
}
}
# 모든 테스트 실행
go test ./...
# 상세 출력과 함께 테스트 실행
go test -v ./...
# 특정 테스트 실행
go test -run TestAdd ./...
# 패턴에 맞는 테스트 실행
go test -run "TestUser/Create" ./...
# 레이스 디텍터와 함께 테스트 실행
go test -race ./...
# 커버리지와 함께 테스트 실행
go test -cover -coverprofile=coverage.out ./...
# 짧은 테스트만 실행
go test -short ./...
# 타임아웃과 함께 테스트 실행
go test -timeout 30s ./...
# 벤치마크 실행
go test -bench=. -benchmem ./...
# 퍼징 실행
go test -fuzz=FuzzParse -fuzztime=30s ./...
# 테스트 실행 횟수 설정 (불안정한 테스트 감지용)
go test -count=10 ./...
권장 사항:
t.Helper() 사용t.Parallel() 사용t.Cleanup()으로 리소스 정리피해야 할 사항:
time.Sleep() 사용 (채널이나 조건 사용)# GitHub Actions 예시
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run tests
run: go test -race -coverprofile=coverage.out ./...
- name: Check coverage
run: |
go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
awk -F'%' '{if ($1 < 80) exit 1}'
기억하세요: 테스트는 문서입니다. 코드가 어떻게 사용되어야 하는지를 보여줍니다. 명확하게 작성하고 최신 상태로 유지하세요.