본문으로 건너뛰기

커버리지와 골든 파일

이 챕터에서 다루는 것

두 가지를 다룬다. 커버리지는 "테스트가 어느 코드를 실행했는가"를 재는 도구이고, 골든 파일은 "출력이 통째로 이만큼"을 파일에 고정해 두는 기법이다.

둘을 한 챕터에 둔 이유는 서로 맞물리기 때문이다. 커버리지가 낮은 자리를 찾았을 때, 그 출력이 크고 구조적이면 골든 파일이 가장 싼 해법인 경우가 많다.

커버리지 재기

플래그 하나면 된다.

cd examples/08-testing
go test -cover ./04-coverage-golden/report
ok example.com/testing-and-quality/04-coverage-golden/report 0.355s coverage: 89.3% of statements

단위는 "문장(statement)"이다. 줄도 아니고 분기도 아니다. go test가 컴파일 전에 소스에 카운터를 심어 실행 여부를 기록한다.

모듈 전체를 한 번에 볼 수도 있다.

go test -cover ./...
ok example.com/testing-and-quality/01-basics/leaky 0.339s coverage: 92.9% of statements
ok example.com/testing-and-quality/01-basics/strutil 0.661s coverage: 100.0% of statements
ok example.com/testing-and-quality/02-table-driven/slug 0.973s coverage: 100.0% of statements
ok example.com/testing-and-quality/03-doubles/backoff 1.292s coverage: 75.0% of statements
ok example.com/testing-and-quality/03-doubles/ingest 1.609s coverage: 100.0% of statements
ok example.com/testing-and-quality/03-doubles/probe 1.966s coverage: 83.3% of statements
ok example.com/testing-and-quality/04-coverage-golden/report 3.408s coverage: 89.3% of statements
ok example.com/testing-and-quality/05-benchmarks/bufpool 2.279s coverage: 100.0% of statements
ok example.com/testing-and-quality/05-benchmarks/cache 1.616s coverage: 95.8% of statements
ok example.com/testing-and-quality/05-benchmarks/vanish 3.732s coverage: 0.0% of statements [no tests to run]
ok example.com/testing-and-quality/05-benchmarks/workers 4.042s coverage: 88.1% of statements
ok example.com/testing-and-quality/06-fuzzing/fieldline 4.060s coverage: 100.0% of statements
ok example.com/testing-and-quality/07-concurrency/collect 4.066s coverage: 95.6% of statements

vanish0.0%는 그 패키지에 벤치마크만 있고 테스트가 없기 때문이다 (8-5).

어느 줄이 안 돌았는지 보기

퍼센트만으로는 아무것도 못 고친다. 프로파일 파일을 뽑아야 한다.

go test -coverprofile=cover.out ./04-coverage-golden/report
go tool cover -func=cover.out
example.com/testing-and-quality/04-coverage-golden/report/report.go:25: Render 90.0%
example.com/testing-and-quality/04-coverage-golden/report/report.go:58: share 100.0%
example.com/testing-and-quality/04-coverage-golden/report/report.go:66: HumanBytes 80.0%
total: (statements) 89.3%

함수 단위로 보인다. HumanBytes가 80%다. 코드를 보면 이유가 분명하다.

examples/08-testing/04-coverage-golden/report/report.go
// HumanBytes는 바이트 수를 읽기 쉬운 단위로 바꾼다.
func HumanBytes(n int64) string {
switch {
case n < 1<<10:
return fmt.Sprintf("%dB", n)
case n < 1<<20:
return fmt.Sprintf("%.1fKiB", float64(n)/(1<<10))
case n < 1<<30:
return fmt.Sprintf("%.1fMiB", float64(n)/(1<<20))
default:
return fmt.Sprintf("%.1fGiB", float64(n)/(1<<30))
}
}

default 가지(GiB)를 밟는 테스트가 없다. 5개 문장 중 4개가 실행됐으니 80%다.

HTML로 보면 안 돌아간 줄이 빨갛게 칠해진다.

go tool cover -html=cover.out

브라우저가 열린다. 파일로 저장하려면 -o를 준다.

go tool cover -html=cover.out -o coverage.html

:::tip 커버리지 프로파일은 .gitignore에 넣는다 cover.outcoverage.html은 산출물이다. 커밋하지 않는다. :::

-covermode

모드기록하는 것기본값
set실행됐는가 (bool)-race 없을 때
count몇 번 실행됐는가
atomic몇 번 (원자적 카운터)-race 켰을 때

count로 뽑으면 프로파일의 마지막 열이 실행 횟수가 된다.

go test -covermode=count -coverprofile=c2.out ./04-coverage-golden/report
mode: count
example.com/testing-and-quality/04-coverage-golden/report/report.go:25.58,26.20 1 3
example.com/testing-and-quality/04-coverage-golden/report/report.go:26.20,28.3 1 1
example.com/testing-and-quality/04-coverage-golden/report/report.go:30.2,32.25 3 2
example.com/testing-and-quality/04-coverage-golden/report/report.go:32.25,35.3 2 4
example.com/testing-and-quality/04-coverage-golden/report/report.go:37.2,43.25 6 2

형식은 파일:시작줄.열,끝줄.열 문장수 실행횟수다. 두 번째 줄의 마지막 1ErrNoRows 반환 경로가 딱 한 번 밟혔다는 뜻이다.

atomic은 병렬 테스트에서 카운터 자체의 경합을 막는다. -race와 함께 쓸 때는 자동으로 atomic이 되므로 직접 지정할 일은 드물다.

-coverpkg — 다른 패키지의 커버리지

기본적으로 커버리지는 테스트 중인 패키지 자신만 잰다. A 패키지의 테스트가 B 패키지를 호출해도 B는 집계되지 않는다.

go test -coverpkg=./... -cover ./04-coverage-golden/report
ok example.com/testing-and-quality/04-coverage-golden/report 0.354s coverage: 89.3% of statements in ./...

in ./...가 붙은 것에 주목한다. 통합 테스트 하나로 여러 패키지의 커버리지를 합산할 때 쓴다. 다만 분모가 커지므로 숫자를 다른 실행과 비교할 때 주의한다.

커버리지 숫자의 한계

커버리지는 "실행됐다"를 재지 "검증됐다"를 재지 않는다. 이것이 전부다.

func TestUseless(t *testing.T) {
Render(io.Discard, "제목", []Row{{Name: "a"}}) // 아무것도 단언하지 않는다
}

이 테스트는 Render의 커버리지를 90%로 올린다. 그리고 아무것도 보장하지 않는다.

구체적으로 놓치는 것들이다.

  • 문장 커버리지는 분기 커버리지가 아니다. if a && b는 한 문장이다. a가 false여서 b를 평가조차 안 해도 100%가 나온다.
  • 경계값을 모른다. n < 1<<10을 1023으로만 테스트하고 1024를 빼먹어도 100%다.
  • 에러 경로를 모른다. if err != nil { return err }를 밟았다는 것과 그 에러가 올바르게 감싸졌다는 것은 다르다.
  • 동시성 버그를 모른다. 데이터 경합은 -race의 몫이다.

:::warning 커버리지 목표치를 CI 게이트로 거는 것 "80% 미만이면 빌드 실패"는 흔한 정책이지만 부작용이 크다. 개발자가 숫자를 채우려고 단언 없는 테스트에러 처리 코드 삭제로 대응하기 때문이다.

숫자를 쓰려면 절대값보다 변화를 본다. "이 PR이 커버리지를 3%p 떨어뜨렸다"는 쓸모 있는 신호이고, "전체가 79.4%다"는 대개 아니다.

커버리지의 진짜 용도는 -html로 빨간 줄을 눈으로 보는 것이다. 거기서 "아, 이 에러 경로를 한 번도 안 밟았네"를 발견하는 것이 퍼센트보다 값지다. :::

골든 파일 테스트

출력이 크고 구조적일 때 — 렌더링된 표, 생성된 코드, 포맷된 문서 — 기대값을 테스트 소스에 문자열로 박는 것은 고통스럽다. 기대 출력을 파일로 두고 통째로 비교하는 것이 골든 파일 테스트다.

대상은 표 렌더러다.

examples/08-testing/04-coverage-golden/report/report.go
// Render는 rows를 표로 만들어 w에 쓴다. 출력은 입력에만 의존한다 —
// 시간도, 맵 순회도, 난수도 끼어들지 않으므로 골든 파일로 고정할 수 있다.
func Render(w io.Writer, title string, rows []Row) error {
if len(rows) == 0 {
return ErrNoRows
}

var total int64
var totalCount int
for _, r := range rows {
total += r.Bytes
totalCount += r.Count
}

var b strings.Builder
fmt.Fprintln(&b, title)
fmt.Fprintln(&b, strings.Repeat("=", utf8.RuneCountInString(title)))

tw := tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tCOUNT\tBYTES\tSHARE")
for _, r := range rows {
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\n",
r.Name, r.Count, HumanBytes(r.Bytes), share(r.Bytes, total))
}
fmt.Fprintf(tw, "TOTAL\t%d\t%s\t%s\n", totalCount, HumanBytes(total), share(total, total))
if err := tw.Flush(); err != nil {
return fmt.Errorf("표 정렬: %w", err)
}

if _, err := io.WriteString(w, b.String()); err != nil {
return fmt.Errorf("보고서 쓰기: %w", err)
}
return nil
}

tabwriter가 열 너비를 계산하므로, 행 하나만 바뀌어도 표 전체의 정렬이 달라진다. 이런 출력을 문자열 리터럴로 테스트에 박으면 관리가 불가능하다.

testdata 디렉터리

testdata는 Go 툴체인이 특별 취급하는 이름이다. 이 디렉터리는 패키지로 간주되지 않으므로, 안에 무엇이 들어 있든 빌드에 영향을 주지 않는다. _.으로 시작하는 디렉터리와 같은 대우다.

테스트는 자기 패키지 디렉터리를 작업 디렉터리로 실행되므로, 상대 경로 testdata/basic.golden이 그대로 통한다.

-update 플래그

골든 파일 테스트의 관용구는 비교와 갱신을 같은 코드가 하는 것이다.

examples/08-testing/04-coverage-golden/report/golden_test.go
// -update를 주면 골든 파일을 현재 출력으로 덮어쓴다.
// 플래그 이름은 관례상 update다.
//
// go test ./04-coverage-golden/report -update
var update = flag.Bool("update", false, "골든 파일을 현재 출력으로 갱신한다")

// assertGolden은 got을 testdata/<name>과 비교한다.
// -update가 켜져 있으면 비교 대신 파일을 쓴다.
func assertGolden(t *testing.T, name string, got []byte) {
t.Helper()

path := filepath.Join("testdata", name)

if *update {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatalf("testdata 생성: %v", err)
}
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("골든 파일 쓰기: %v", err)
}
t.Logf("골든 파일 갱신: %s", path)
return
}

want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("골든 파일 읽기: %v (없으면 -update로 만든다)", err)
}
if bytes.Equal(got, want) {
return
}

// 실패했을 때만 실제 출력을 파일로 남긴다.
// ArtifactDir는 -artifacts 없이는 테스트가 끝나면 지워지는 임시 디렉터리다.
out := filepath.Join(t.ArtifactDir(), name)
if err := os.WriteFile(out, got, 0o644); err != nil {
t.Errorf("실제 출력 저장 실패: %v", err)
} else {
t.Logf("실제 출력을 %s에 저장했다", out)
}

t.Errorf("골든 파일과 다르다: %s\n--- want ---\n%s\n--- got ---\n%s", path, want, got)
}

flag.Bool을 패키지 수준에 선언해 두면 go test가 자기 플래그를 파싱할 때 같이 파싱해 준다. 별도 등록이 필요 없다.

:::note os와 파일 I/O는 파트 9에서 os.ReadFile/os.WriteFile은 여기서 골든 파일을 읽고 쓰는 데만 쓴다. 파일 권한, os.Root, 경로 처리 같은 것은 파트 9의 주제다. :::

호출부는 평범한 테이블 주도 테스트다.

examples/08-testing/04-coverage-golden/report/golden_test.go
func TestRenderGolden(t *testing.T) {
tests := []struct {
name string
title string
rows []report.Row
golden string
}{
{
name: "기본",
title: "수집 결과",
rows: []report.Row{
{Name: "docs", Count: 12, Bytes: 4096},
{Name: "images", Count: 3, Bytes: 2_500_000},
{Name: "index", Count: 1, Bytes: 512},
},
golden: "basic.golden",
},
{
name: "한 줄",
title: "단일",
rows: []report.Row{{Name: "only", Count: 1, Bytes: 0}},
golden: "single.golden",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
if err := report.Render(&buf, tt.title, tt.rows); err != nil {
t.Fatalf("Render: %v", err)
}
assertGolden(t, tt.golden, buf.Bytes())
})
}
}

처음 만들 때는 -update로 돌린다.

go test ./04-coverage-golden/report -update -v
=== RUN TestRenderGolden
=== RUN TestRenderGolden/기본
golden_test.go:89: 골든 파일 갱신: testdata/basic.golden
=== RUN TestRenderGolden/한_줄
golden_test.go:89: 골든 파일 갱신: testdata/single.golden
--- PASS: TestRenderGolden (0.00s)

만들어진 파일이다.

examples/08-testing/04-coverage-golden/report/testdata/basic.golden
수집 결과
=====
NAME COUNT BYTES SHARE
docs 12 4.0KiB 0.2%
images 3 2.4MiB 99.8%
index 1 512B 0.0%
TOTAL 16 2.4MiB 100.0%

:::danger -update의 결과는 반드시 눈으로 읽는다 -update테스트를 통과시키는 버튼이다. 코드에 버그를 넣은 채 -update를 돌리면 버그 있는 출력이 새 정답이 된다.

규칙은 하나다. -update 후에는 git diff를 읽는다. diff가 의도한 변경과 일치할 때만 커밋한다. 골든 파일을 커밋에 포함시키는 이유가 이것이다 — 리뷰어가 출력 변화를 diff로 볼 수 있다. :::

T.ArtifactDir

실패했을 때 실제 출력을 파일로 남기면 CI에서 받아 볼 수 있다. t.ArtifactDir()은 그 테스트 전용 디렉터리를 준다.

examples/08-testing/04-coverage-golden/artifacts/artifacts_test.go
func TestAttrAndArtifacts(t *testing.T) {
// CI가 읽을 구조화된 속성. -json으로 돌리면 별도 액션으로 나온다.
t.Attr("owner", "platform")
t.Attr("issue", "1234")

dir := t.ArtifactDir()
fmt.Println("artifactdir:", dir)

if err := os.WriteFile(filepath.Join(dir, "out.txt"), []byte("hi"), 0o644); err != nil {
t.Fatalf("산출물 쓰기: %v", err)
}

// t.Output()은 테스트 로그 스트림에 붙은 io.Writer다.
// t.Log와 달리 소스 위치와 개행을 붙이지 않는다.
fmt.Fprint(t.Output(), "T.Output으로 쓴 줄")
}
go test -v -count=1 ./04-coverage-golden/artifacts
=== RUN TestAttrAndArtifacts
=== ATTR TestAttrAndArtifacts owner platform
=== ATTR TestAttrAndArtifacts issue 1234
artifactdir: /var/folders/7g/55yy17tj7qqbshbzbcj48sm80000gn/T/TestAttrAndArtifacts2967813646/001
T.Output으로 쓴 줄
--- PASS: TestAttrAndArtifacts (0.00s)

기본값은 임시 디렉터리이고 테스트가 끝나면 사라진다. 남기려면 -artifacts를 준다.

go test -count=1 -artifacts ./04-coverage-golden/artifacts

그러면 -outputdir(기본값은 현재 디렉터리) 아래 _artifacts/에 쌓인다.

_artifacts
_artifacts/1181058996
_artifacts/1181058996/out.txt

테스트와 서브테스트마다 고유한 디렉터리를 받으므로 이름이 겹칠 걱정이 없다. _artifacts는 밑줄로 시작하니 Go 툴체인이 무시한다 — 그래도 .gitignore에 넣는다.

:::info T.AttrT.Output 같은 계열의 도구가 둘 더 있다.

**t.Attr(key, value)**가 붙인 속성은 -v 출력에 === ATTR 줄로 나오고, -json으로 돌리면 별도 액션이 된다.

{"Time":"2026-08-11T16:34:17.697127+09:00","Action":"attr","Package":"example.com/testing-and-quality/04-coverage-golden/artifacts","Test":"TestAttrAndArtifacts","Key":"owner","Value":"platform"}

**t.Output()**은 테스트 로그 스트림에 붙은 io.Writer다. t.Log와 달리 소스 위치와 개행을 붙이지 않으므로, io.Writer를 받는 함수의 출력을 테스트 로그로 흘려보낼 때 쓴다. :::

골든 파일을 쓸 자리와 쓰지 말 자리

쓸 자리

  • 렌더링된 표, 리포트, 템플릿 출력
  • 코드 생성기의 산출물
  • 직렬화 결과 (설정 파일, 포맷된 문서)
  • 파서의 AST 덤프

쓰지 말 자리

  • 출력이 결정적이지 않을 때. 시각, 난수, 맵 순회 순서, 포인터 주소, 절대 경로가 섞이면 골든 파일은 매번 깨진다. 먼저 결정적으로 만들고(8-3의 주입) 골든 파일을 쓴다.
  • 출력이 작을 때. 세 줄짜리 결과라면 테스트 소스에 리터럴로 적는 편이 읽기 좋다. 골든 파일은 검증을 다른 파일로 옮기는 것이라, 테스트만 읽어서는 무엇을 기대하는지 알 수 없어진다.
  • 한 값만 검증하면 될 때. "전체가 이만큼"은 무엇이 왜 바뀌었는지 말해 주지 않는다.

흔히 하는 실수

1. 결정적이지 않은 출력을 고정하려 한다

fmt.Fprintf(w, "생성 시각: %s\n", time.Now()) // 골든 파일이 매번 깨진다

시각을 주입하거나, 비교 전에 정규화한다.

2. 골든 파일을 .gitignore에 넣는다

커밋해야 한다. 골든 파일은 산출물이 아니라 기대값이다. 커밋되어 있어야 리뷰에서 출력 변화가 보인다.

3. 실패했을 때 diff가 안 보인다

t.Errorf("불일치")만 쓰면 무엇이 다른지 알 수 없다. 위 예제처럼 want와 got을 전부 찍거나, 큰 출력이면 cmp.Diff로 줄 단위 diff를 낸다.

4. -update를 CI에서 돌린다

테스트가 절대 실패하지 않는다. -update는 사람이 로컬에서 손으로 돌리는 플래그다.

5. 커버리지 100%를 목표로 삼는다

default: 가지, panic("unreachable"), 생성된 코드까지 채우려면 의미 없는 테스트를 써야 한다. 테스트하기 어려워서 못 밟는 코드가 있다면, 그건 설계 신호일 수 있다 — 그쪽을 먼저 본다.

6. testdata를 다른 이름으로 만든다

fixtures/test_data/로 만들면 그 안의 .go 파일이 빌드 대상이 되고, go vet이 검사하려 든다. testdata라는 이름 자체가 기능이다.

정리

  • -cover문장 커버리지를 잰다. 퍼센트만 보지 말고 -coverprofile + go tool cover -func/-html안 돌아간 줄을 본다.
  • -covermodeset/count/atomic. -race와 함께면 자동으로 atomic이다.
  • -coverpkg=./...로 다른 패키지까지 집계할 수 있다. 분모가 커진다는 것을 기억한다.
  • 커버리지는 "실행됐다"이지 "검증됐다"가 아니다. 목표치를 게이트로 걸면 단언 없는 테스트가 늘어난다. 절대값보다 변화를 본다.
  • testdata는 툴체인이 무시하는 예약된 이름이다. 테스트의 작업 디렉터리는 자기 패키지 디렉터리다.
  • 골든 파일 테스트는 flag.Bool("update", ...) + assertGolden 도우미가 관용구다.
  • -update 후에는 반드시 git diff를 읽는다. 그게 유일한 안전장치다.
  • t.ArtifactDir()은 실패 산출물을 남길 자리다. -artifacts 없이는 임시 디렉터리이고, 주면 _artifacts/에 남는다.
  • 골든 파일은 크고 결정적인 출력에만 쓴다. 결정적으로 만드는 것이 먼저다.

연습문제

  1. HumanBytes의 GiB 가지를 밟는 테스트를 추가하고 커버리지가 어떻게 변하는지 확인해 보자. 그다음 1<<10 - 1, 1<<10, 1<<20 - 1, 1<<20 네 경계값을 전부 넣어 보자. 커버리지 퍼센트는 그대로인데 테스트의 가치는 달라졌다 — 이것이 커버리지 숫자의 한계를 어떻게 보여 주는가?

  2. Render에 "SHARE 열은 내림차순으로 정렬한다"는 요구를 추가하고, -update 없이 먼저 돌려 보자. 실패 메시지에서 무엇이 바뀌었는지 읽을 수 있는가? 그다음 -update를 돌리고 git diff로 골든 파일의 변화를 확인해 보자.

  3. assertGolden의 실패 경로를 cmp.Diff(google/go-cmp)로 바꿔 보자. 출력이 여러 줄일 때 어느 쪽 실패 메시지가 더 쓸모 있는가? 의존성 하나를 추가할 만한 가치가 있다고 보는가?