Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

32장. 테스트 작성하기

코드를 짜고 나면 “잘 돌아갈까?” 라는 질문이 따라온다. 한두 번은 손으로 돌려 봐도 되지만, 규모가 커지면 그 방법으로는 감당이 안 된다.

이때 등장하는 것이 자동화된 테스트다. Go 는 외부 도구 없이도 표준 testing 패키지만으로 충분히 테스트할 수 있다.

목표:

  • 왜 테스트를 쓰는지 납득하기
  • testing 패키지의 기본 사용법 익히기
  • 테이블 기반 테스트와 서브테스트 다루기
  • 벤치마크, 예제, 커버리지까지 한 바퀴 돌아 보기

32.1 왜 테스트를 작성하는가

테스트는 단순한 “검증 도구” 이상의 가치를 가진다.

회귀 방지

새 기능을 넣다가 기존 기능을 망가뜨리는 사고를 흔히 본다. 테스트가 있다면 망가지는 순간 빨간불이 켜진다.

리팩터링 안전망

코드를 깔끔히 정리하고 싶을 때 “건드리면 뭐가 깨질지 모른다” 는 두려움이 가장 큰 장애물이다.

테스트가 든든히 받쳐 주면 구조를 바꿔도 의도된 결과만 같으면 안심할 수 있다.

사양의 역할

테스트 코드는 곧 “이 함수가 어떻게 동작해야 하는가” 의 명세다. 새로 합류한 동료가 읽기에도, 미래의 내가 다시 보기에도 좋다.

잘 짜인 테스트는 주석보다 정확하다. 주석은 코드와 함께 변하지 않을 수 있지만, 테스트는 깨지는 순간 알려 준다.


32.2 testing 패키지 한눈에 보기

Go 의 테스트는 표준 라이브러리 testing 으로 한다. 별도 프레임워크를 깔지 않는다.

파일 이름 관례

테스트 코드는 _test.go 로 끝나는 파일에 둔다.

파일역할
math.go실제 코드
math_test.go테스트 코드

빌드할 때는 _test.go 파일이 자동으로 제외된다. 배포 바이너리에는 테스트 코드가 섞이지 않는다.

함수 시그니처

func TestXxx(t *testing.T) { ... }
  • 이름은 반드시 Test 로 시작
  • 그 뒤 첫 글자는 대문자
  • 매개변수는 *testing.T 하나

같은 패키지냐, 분리 패키지냐

테스트 파일은 보통 두 가지 방식으로 둘 수 있다.

  1. 같은 패키지package math 그대로
    • 패키지 내부 식별자(소문자 함수 등)에 접근 가능
  2. _test 분리 패키지package math_test
    • 외부 사용자 입장에서 쓰는 것처럼 테스트
    • 공개 API 만 보임

대부분의 경우는 같은 패키지 방식으로 충분하다.


32.3 첫 테스트 작성

간단한 함수를 하나 만들고 테스트해 본다.

대상 함수

mathx/mathx.go:

package mathx

// Add 는 두 정수의 합을 반환한다.
func Add(a, b int) int {
	return a + b
}

테스트 코드

mathx/mathx_test.go:

package mathx

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)
	}
}

실행

같은 디렉터리에서 다음을 실행한다.

go test

통과하면 이렇게 나온다.

ok      example.com/mathx       0.123s

자세히 보고 싶다면 -v 옵션을 붙인다.

go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      example.com/mathx       0.123s

실패 시 모습

일부러 want := 6 으로 바꿔서 실행해 보자.

=== RUN   TestAdd
    mathx_test.go:9: Add(2, 3) = 5, want 6
--- FAIL: TestAdd (0.00s)
FAIL

어느 줄에서 어떤 차이가 났는지 깔끔하게 보여 준다.


32.4 t.Error, t.Errorf, t.Fatal

테스트 실패를 보고하는 메서드는 여러 가지다. 가장 자주 쓰는 두 그룹을 비교한다.

Error 계열 (실패 후 계속 진행)

메서드동작
t.Error(args...)실패 표시, 메시지 출력, 이후 코드 계속 실행
t.Errorf(format, ...)위와 같되 포맷 문자열 사용

Fatal 계열 (실패 후 즉시 중단)

메서드동작
t.Fatal(args...)실패 표시 후 해당 테스트 즉시 종료
t.Fatalf(format, ...)위와 같되 포맷 문자열 사용

언제 어느 쪽?

  • 한 검사가 실패해도 다음 검사가 의미 있다면 → Error
  • 한 검사가 실패하면 이후 코드가 의미 없다면 → Fatal

예를 들어 파일을 열지 못했으면 이후 코드는 돌릴 가치가 없으니 Fatal 이 맞다.

f, err := os.Open("data.txt")
if err != nil {
	t.Fatalf("파일 열기 실패: %v", err)
}
defer f.Close()
// 이후 코드는 f 가 유효하다는 전제

메시지 포맷 팁

좋은 실패 메시지는 세 가지를 담는다.

  1. 어떤 함수를 호출했는가
  2. 무엇이 나왔는가 (got)
  3. 무엇을 기대했는가 (want)
t.Errorf("Add(%d, %d) = %d, want %d", a, b, got, want)

32.5 테이블 기반 테스트

비슷한 검사를 여러 입력에 대해 반복하고 싶을 때 Go 에서는 테이블 기반 테스트 가 관례다.

기본 구조

입력과 기대값 쌍을 슬라이스로 만들고 for range 로 돌린다.

func TestAdd(t *testing.T) {
	cases := []struct {
		name string
		a, b int
		want int
	}{
		{"양수+양수", 2, 3, 5},
		{"음수 포함", -1, 1, 0},
		{"0 포함", 0, 7, 7},
	}

	for _, c := range cases {
		got := Add(c.a, c.b)
		if got != c.want {
			t.Errorf("%s: Add(%d,%d)=%d, want %d",
				c.name, c.a, c.b, got, c.want)
		}
	}
}

서브테스트로 깔끔하게 — t.Run

각 케이스를 별도의 테스트처럼 다루고 싶다면 t.Run 으로 감싼다.

for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
		got := Add(c.a, c.b)
		if got != c.want {
			t.Errorf("Add(%d,%d)=%d, want %d",
				c.a, c.b, got, c.want)
		}
	})
}

이렇게 하면 출력이 다음처럼 분리된다.

=== RUN   TestAdd
=== RUN   TestAdd/양수+양수
=== RUN   TestAdd/음수_포함
=== RUN   TestAdd/0_포함
--- PASS: TestAdd
    --- PASS: TestAdd/양수+양수
    --- PASS: TestAdd/음수_포함
    --- PASS: TestAdd/0_포함

특정 서브테스트만 돌리고 싶다면 -run 으로 지정한다.

go test -run TestAdd/음수

테이블 기반의 장점

  • 새 케이스 추가가 한 줄이면 끝
  • 어떤 입력에서 깨졌는지 한눈에 보임
  • 코드 중복이 거의 없음

거의 모든 단위 테스트는 이 패턴으로 풀린다. Go 코드를 읽다 보면 이 형태가 사방에서 보일 것이다.


32.6 테스트 헬퍼와 t.Helper

테스트 코드가 길어지면 공통 검사 로직을 헬퍼 함수로 빼고 싶어진다.

func assertEqual(t *testing.T, got, want int) {
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

이대로 쓰면 실패 메시지가 헬퍼 함수의 줄 번호를 가리킨다.

helper_test.go:5: got 3, want 5

정작 잘못된 호출은 호출자 쪽인데 말이다.

t.Helper() 한 줄로 해결

헬퍼 함수 맨 위에 t.Helper() 를 적는다.

func assertEqual(t *testing.T, got, want int) {
	t.Helper()
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

이제 실패 줄 번호가 호출자 위치로 표시된다. 실패 추적이 훨씬 편해진다.


32.7 setUp / tearDown

테스트 전후에 공통 준비/정리가 필요하면 두 가지 방법이 있다.

TestMain 으로 전체 묶기

테스트 파일 안에 다음 시그니처를 두면, 같은 패키지의 모든 테스트가 시작되기 전후로 한 번씩 돈다.

func TestMain(m *testing.M) {
	// 전역 준비
	setupDB()

	code := m.Run()

	// 전역 정리
	teardownDB()

	os.Exit(code)
}
  • 한 패키지에 TestMain 은 하나만
  • 호출하지 않으면 m.Run() 이 자동으로 돌아간다

서브테스트 단위로 직접

각 테스트가 독립적인 자원을 쓴다면 서브테스트 안에서 직접 준비/정리하는 편이 안전하다.

func TestThing(t *testing.T) {
	tmpDir := t.TempDir()      // 자동 삭제
	// ... 테스트 본문 ...
}
  • t.TempDir() 가 만든 디렉터리는 테스트 종료 시 자동으로 지워진다
  • t.Cleanup(func() { ... }) 로 임의 정리 작업도 등록 가능
t.Cleanup(func() {
	conn.Close()
})

가능하면 TestMain 보다 t.Cleanup 을 권한다. 테스트가 자기 정리를 책임지면 다른 테스트와의 결합이 줄어든다.


32.8 벤치마크 (26장 복습)

성능을 재고 싶다면 벤치마크 함수를 쓴다. 26장에서 잠깐 봤지만 다시 정리한다.

시그니처

func BenchmarkXxx(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}
  • 이름은 Benchmark 로 시작
  • 매개변수는 *testing.B
  • b.N 만큼 반복 — 횟수는 Go 가 알아서 늘려 정확도 확보

실행

go test -bench=.
BenchmarkAdd-10    1000000000    0.25 ns/op
  • Add-10: 함수 이름과 GOMAXPROCS 값
  • 0.25 ns/op: 한 번 호출당 평균 시간

자주 같이 쓰는 옵션

옵션의미
-bench=.모든 벤치마크 실행
-benchmem할당 횟수와 메모리도 출력
-count=NN 회 반복해서 분산 확인
-benchtime=3s한 벤치를 3초 동안 측정
go test -bench=. -benchmem -count=3

32.9 예제(Example) 함수

Example 로 시작하는 함수는 두 역할을 한다.

  • 테스트 (출력이 일치해야 통과)
  • 문서 (godoc 페이지에 코드 예시로 노출)
func ExampleAdd() {
	fmt.Println(Add(2, 3))
	// Output: 5
}
  • 마지막의 // Output: 주석이 핵심
  • 실제 출력이 이 주석과 같지 않으면 테스트 실패

순서가 중요하지 않은 출력

맵처럼 순서가 보장되지 않는 경우엔 // Unordered output: 을 쓴다.

func ExamplePrintMap() {
	PrintMap(map[string]int{"a": 1, "b": 2})
	// Unordered output:
	// a=1
	// b=2
}

패키지 / 타입 단위 예제

이름노출 위치
Example()패키지 첫 페이지
ExampleAdd()Add 함수 문서
ExampleUser_Greet()User 타입의 Greet 메서드 문서

잘 짠 Example 한 개가 README 한 단락을 대신한다. 사용법을 코드로 보여 주는 것이 가장 강력하다.


32.10 커버리지

테스트가 코드의 몇 퍼센트를 거쳤는지 알고 싶다면 -cover 옵션을 쓴다.

기본 출력

go test -cover
ok      example.com/mathx       0.123s  coverage: 78.3% of statements

HTML 리포트

어느 줄이 안 거쳐졌는지 시각적으로 보고 싶다면 프로파일을 파일로 떨군 뒤 HTML 로 변환한다.

go test -coverprofile=c.out
go tool cover -html=c.out

브라우저가 열리며, 초록은 거친 줄, 빨강은 안 거친 줄이다.

커버리지에 대한 한 마디

100% 가 항상 좋은 건 아니다. “이 함수의 핵심 동작이 빠짐없이 테스트되는가” 가 본질이다.

  • 비즈니스 로직은 높게
  • 단순 게터, 단순 위임 함수는 굳이 무리하지 않기

70~80% 정도를 가이드 삼되, 숫자보다 “중요한 경로가 다 덮였나” 를 본다.


32.11 정리

이 장에서 본 것:

  • _test.go 파일과 TestXxx(t *testing.T) 가 기본
  • 실패 보고는 Error / Fatal 계열로
  • 테이블 기반 테스트 + t.Run 이 거의 표준
  • 헬퍼 함수에는 t.Helper() 한 줄
  • 벤치마크, Example, 커버리지까지 표준 도구로 다 됨

테스트는 한 번 들이는 습관이지만 시간이 갈수록 복리로 돌아온다. “테스트 없이는 손도 못 댄다” 는 동료들의 농담은 대개 진심이다.

다음 장에서는 지금까지 배운 모든 것을 모아 실전 미니 프로젝트 두 개를 만들어 본다.