Devin.KR

테스트 심화 - 표 주도·서브테스트·퍼징

개발자KR 조회 0

이 장에서 배우는 것

기본서에서 go test 로 함수 하나를 검증하는 법을 익혔다. 서비스가 커지면 검증할 입력 조합이 늘고, HTTP 핸들러와 파일을 쓰는 코드도 테스트해야 한다. 이 장에서는 같은 테스트 코드를 반복하지 않고 케이스를 늘리는 방법, 서버를 띄우지 않고 핸들러를 검증하는 방법, 사람이 떠올리지 못한 입력을 기계가 찾게 하는 방법을 배달 주문 중계 서비스의 배달비 계산, 전화번호 정규화, 주문 접수 핸들러, 주문 저널에 적용해 본다.

  • 표 주도 테스트(table-driven test)로 케이스를 데이터로 나열한다.
  • t.Run 서브테스트(subtest)와 t.Parallel 로 케이스를 이름 붙여 나누고 병렬로 돌린다.
  • net/http/httptest 로 포트를 열지 않고 핸들러를 검증한다.
  • t.Cleanup 과 t.TempDir 로 테스트가 만든 자원을 스스로 치운다.
  • go test -fuzz 로 퍼징(fuzzing)을 시작하고, 입력값 대신 성질을 검사하는 방법을 익힌다.

문제 상황

중계 서비스는 가게 사장님이 올린 주문을 받아 배달비를 계산하고, 주문자 전화번호를 정리해 저장한다. 배달비 규칙은 거리 구간과 주문 금액 할인이 얽혀 있어 경계값마다 결과가 달라진다. 처음에는 TestFeeNear, TestFeeFar 처럼 케이스마다 함수를 복사해 만들었다. 규칙이 일곱 개가 되자 테스트 파일이 길어졌고, 하나가 실패했을 때 어떤 입력이었는지 메시지만으로는 알 수 없었다.

핸들러 테스트는 더 번거로웠다. 테스트마다 실제 포트를 열었다가 포트 충돌로 간헐적으로 실패했다. 주문 저널을 파일에 남기는 코드를 검증하려고 /tmp 에 파일을 만들었지만 정리 코드를 빠뜨려 CI 머신에 찌꺼기가 쌓였다. 전화번호 입력은 "010-1234-5678", "010 1234 5678", 공백만 있는 문자열 등 사용자가 어떻게 넣을지 예측하기 어렵다. 예시 몇 개를 직접 고르는 것만으로는 빈틈이 남는다. 이 세 가지 문제를 표준 라이브러리의 testing 과 httptest 만으로 풀어 본다.

표 주도 테스트와 서브테스트

케이스를 데이터로 쓴다

표 주도 테스트는 입력과 기대값을 구조체 슬라이스에 나열하고, 하나의 반복문이 각 행을 검증하는 방식이다. 검증 로직은 한 곳에만 있고 케이스를 늘리는 일은 표에 한 줄을 추가하는 일이 된다. 경계값(1000 과 1001, 5000 과 5001)을 나란히 적으면 규칙이 코드 없이도 읽힌다.

반복문 안에서 t.Run(이름, 함수)을 호출하면 각 행이 서브테스트가 된다. 서브테스트는 독립된 *testing.T 를 받으므로 한 행이 실패해도 나머지 행이 계속 실행된다. 실패 로그에는 TestDeliveryFee/5km_초과 처럼 부모와 자식 이름이 붙는다. 이름의 공백은 밑줄로 바뀐다. -run 옵션에 슬래시로 구분한 패턴을 주면 한 행만 골라 돌릴 수 있다.

케이스 표의 각 행이 t.Run 으로 이름 붙은 서브테스트가 되어 개별 실행된다.

t.Parallel 로 병렬 실행하기

서브테스트 안에서 t.Parallel() 을 호출하면 그 서브테스트는 부모 함수의 순차 구간이 끝난 뒤 다른 병렬 서브테스트와 함께 실행된다. 느린 케이스가 많을 때 전체 시간을 줄여 준다. 병렬로 돌리려면 케이스끼리 상태를 공유하지 않아야 한다. 반복 변수 tt 는 Go 1.22 부터 반복마다 새로 만들어지므로 클로저에서 그대로 써도 안전하다. 그러나 반복문 바깥에서 만든 가변 객체를 여러 서브테스트가 함께 바꾸면 여전히 경쟁 상태가 된다. 앞 장에서 다룬 race 검출기를 켠 go test -race 를 병렬 테스트와 함께 쓰는 것이 좋다.

실패 보고에는 두 함수가 있다. t.Errorf 는 실패를 기록하고 계속 진행하고, t.Fatalf 는 기록한 뒤 해당 (서브)테스트를 바로 끝낸다. 다음 검사가 앞 검사의 결과를 전제한다면 Fatalf 를, 독립된 값 여러 개를 비교한다면 Errorf 를 쓴다. 메시지에는 입력, 실제 값, 기대 값을 모두 담아 로그만 보고도 원인을 짐작하게 한다.

httptest, t.Cleanup, t.TempDir

서버를 띄우지 않고 핸들러 검증하기

http.Handler 는 ServeHTTP(w, r) 하나로 이루어진 인터페이스다. httptest.NewRequest 가 만든 요청과 httptest.NewRecorder 가 만든 응답 기록기를 넘기면 네트워크 없이 핸들러를 호출할 수 있다. 기록기에서 Code 와 Body 를 읽어 검증한다. 실제 TCP 연결이 필요한 경우(클라이언트 코드 검증 등)에는 httptest.NewServer 로 임의 포트의 서버를 띄운다. 핸들러 자체의 검증에는 기록기만으로 충분하다. 이 장의 핸들러는 Go 1.22 이후의 라우팅 패턴("POST /orders", "GET /orders/{id}")을 쓰는데, 이 부분은 net/http 서버를 다루는 장에서 자세히 본다.

자원을 테스트에 묶어 치우기

t.Cleanup(f) 는 테스트(또는 서브테스트)가 끝날 때 f 를 실행하도록 등록한다. 등록한 역순으로 실행되므로 defer 와 비슷하게 쓸 수 있고, defer 와 달리 도우미 함수 안에서 등록해도 호출한 테스트가 끝날 때까지 살아 있다. t.TempDir() 은 테스트마다 고유한 임시 디렉터리를 만들고 종료 시 지운다. 이름 충돌이 없어 병렬 테스트에서도 안전하다. 파일을 열어 둔 채 디렉터리를 지우면 일부 운영체제에서 실패하므로, 파일을 닫는 Cleanup 을 TempDir 호출 뒤에 등록한다. 역순 실행 덕분에 파일이 먼저 닫히고 디렉터리가 나중에 지워진다.

자원 정리 도구별 실행 시점과 용도
도구실행 시점도우미 함수에서쓰는 곳
defer그 함수가 반환될 때도우미가 끝나면 바로 실행된다함수 안 짧은 정리
t.Cleanup테스트가 끝날 때, 등록 역순호출한 테스트 끝까지 유지된다파일 닫기, 서버 종료
t.TempDir테스트가 끝날 때 자동 삭제호출한 테스트에 묶인다임시 파일과 디렉터리

퍼징 입문

표 주도 테스트는 사람이 떠올린 입력만 검증한다. 퍼징은 시드(seed) 입력을 바탕으로 도구가 입력을 변형해 가며 함수를 수없이 호출하고, 실패하는 입력을 찾는다. 퍼즈 테스트는 FuzzXxx(f *testing.F) 형태이고, f.Add 로 시드를 넣고 f.Fuzz 에 인자를 받는 함수를 등록한다. 인자 타입은 string, []byte, 정수형 같은 기본 타입으로 제한된다.

입력이 무작위로 바뀌므로 기대 값을 미리 적을 수 없다. 대신 어떤 입력에서도 성립해야 하는 성질(property)을 검사한다. 전화번호 정규화라면 "성공했다면 결과는 0 으로 시작하는 10~11자리 숫자이고, 결과를 다시 정규화해도 같다" 같은 성질이다.

퍼징은 시드를 변형해 대상 함수를 반복 호출하고, 성질이 깨지면 그 입력을 testdata 에 저장한다.

일반 go test 는 퍼즈 테스트의 시드와 testdata/fuzz 에 저장된 실패 입력만 한 번씩 실행하므로 회귀 테스트로 쓰인다. 입력 생성은 go test -fuzz=FuzzNormalizePhone -fuzztime=10s 처럼 -fuzz 를 주었을 때만 시작된다. 이 옵션은 한 번에 퍼즈 테스트 하나만 대상으로 한다. 실패하면 입력이 testdata/fuzz/FuzzNormalizePhone/ 아래에 파일로 저장되고, 그 파일은 버전 관리에 포함해 두면 같은 버그가 되살아나는 것을 막아 준다. 자세한 옵션은 Go 공식 퍼징 문서에서 확인할 수 있다.

완성 코드

작업 디렉터리에서 go mod init delivery 로 모듈을 만든 뒤 두 파일을 둔다. main.go 는 go run main.go 로 바로 실행되며 테스트 대상을 한 번씩 호출해 보인다.

main.go

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
)

const journalName = "orders.log"

var (
	ErrInvalid   = errors.New("거리와 금액은 0 이상이어야 한다")
	ErrOutOfArea = errors.New("배달 가능 거리를 넘었다")
)

type Order struct {
	ID        int    `json:"id"`
	Phone     string `json:"phone"`
	DistanceM int    `json:"distance_m"`
	Amount    int    `json:"amount"`
	Fee       int    `json:"fee"`
}

// DeliveryFee 는 거리(m)와 주문 금액(원)으로 배달비를 계산한다.
func DeliveryFee(distanceM, amount int) (int, error) {
	if distanceM < 0 || amount < 0 {
		return 0, ErrInvalid
	}
	var fee int
	switch {
	case distanceM <= 1000:
		fee = 2000
	case distanceM <= 3000:
		fee = 3000
	case distanceM <= 5000:
		fee = 4000
	default:
		return 0, ErrOutOfArea
	}
	if amount >= 30000 {
		fee -= 1000
	}
	return fee, nil
}

// NormalizePhone 은 공백과 하이픈을 지워 숫자만 남긴다.
func NormalizePhone(s string) (string, error) {
	var b strings.Builder
	for _, r := range s {
		switch {
		case r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '-' || r == ' ':
		default:
			return "", fmt.Errorf("전화번호에 쓸 수 없는 문자 %q", r)
		}
	}
	d := b.String()
	if len(d) < 10 || len(d) > 11 || d[0] != '0' {
		return "", errors.New("전화번호 형식이 아니다")
	}
	return d, nil
}

type Store struct {
	mu     sync.Mutex
	orders []Order
}

func (s *Store) Add(o Order) Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	o.ID = len(s.orders) + 1
	s.orders = append(s.orders, o)
	return o
}

func (s *Store) Get(id int) (Order, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if id < 1 || id > len(s.orders) {
		return Order{}, false
	}
	return s.orders[id-1], true
}

func NewHandler(s *Store) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Phone     string `json:"phone"`
			DistanceM int    `json:"distance_m"`
			Amount    int    `json:"amount"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "잘못된 JSON", http.StatusBadRequest)
			return
		}
		phone, err := NormalizePhone(req.Phone)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		fee, err := DeliveryFee(req.DistanceM, req.Amount)
		if err != nil {
			status := http.StatusBadRequest
			if errors.Is(err, ErrOutOfArea) {
				status = http.StatusUnprocessableEntity
			}
			http.Error(w, err.Error(), status)
			return
		}
		o := s.Add(Order{Phone: phone, DistanceM: req.DistanceM, Amount: req.Amount, Fee: fee})
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(o)
	})
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.Atoi(r.PathValue("id"))
		if err != nil {
			http.Error(w, "주문 번호가 숫자가 아니다", http.StatusBadRequest)
			return
		}
		o, ok := s.Get(id)
		if !ok {
			http.Error(w, "주문이 없다", http.StatusNotFound)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(o)
	})
	return mux
}

type Journal struct {
	f *os.File
}

func OpenJournal(dir string) (*Journal, error) {
	f, err := os.OpenFile(filepath.Join(dir, journalName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return nil, err
	}
	return &Journal{f: f}, nil
}

func (j *Journal) Append(o Order) error {
	_, err := fmt.Fprintf(j.f, "%d|%s|%d\n", o.ID, o.Phone, o.Fee)
	return err
}

func (j *Journal) Close() error {
	return j.f.Close()
}

func call(h http.Handler, method, path, body string) string {
	req := httptest.NewRequest(method, path, strings.NewReader(body))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return fmt.Sprintf("%d %s", rec.Code, strings.TrimSpace(rec.Body.String()))
}

func demoJournal() error {
	dir, err := os.MkdirTemp("", "journal")
	if err != nil {
		return err
	}
	defer os.RemoveAll(dir)

	j, err := OpenJournal(dir)
	if err != nil {
		return err
	}
	for _, o := range []Order{
		{ID: 1, Phone: "01012345678", Fee: 3000},
		{ID: 2, Phone: "01099990000", Fee: 2000},
	} {
		if err := j.Append(o); err != nil {
			j.Close()
			return err
		}
	}
	if err := j.Close(); err != nil {
		return err
	}
	data, err := os.ReadFile(filepath.Join(dir, journalName))
	if err != nil {
		return err
	}
	fmt.Print(string(data))
	return nil
}

func main() {
	for _, c := range [][2]int{{1500, 12000}, {1500, 35000}, {6000, 12000}} {
		fee, err := DeliveryFee(c[0], c[1])
		fmt.Println("배달비", c, fee, err)
	}

	phone, _ := NormalizePhone("010-1234 5678")
	fmt.Println("전화번호:", phone)
	_, err := NormalizePhone("02-abc")
	fmt.Println("전화번호 오류:", err)

	h := NewHandler(&Store{})
	fmt.Println(call(h, "POST", "/orders", `{"phone":"010-1234-5678","distance_m":1500,"amount":12000}`))
	fmt.Println(call(h, "GET", "/orders/1", ""))
	fmt.Println(call(h, "GET", "/orders/9", ""))
	fmt.Println(call(h, "POST", "/orders", `{"phone":"01012345678","distance_m":9000,"amount":5000}`))

	if err := demoJournal(); err != nil {
		fmt.Println("저널 오류:", err)
	}
}

main_test.go

package main

import (
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func serve(t *testing.T, h http.Handler, method, path, body string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(method, path, strings.NewReader(body))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestDeliveryFee(t *testing.T) {
	tests := []struct {
		name      string
		distanceM int
		amount    int
		want      int
		wantErr   error
	}{
		{"1km 경계", 1000, 10000, 2000, nil},
		{"1km 초과", 1001, 10000, 3000, nil},
		{"3km 경계", 3000, 10000, 3000, nil},
		{"5km 경계", 5000, 10000, 4000, nil},
		{"5km 초과", 5001, 10000, 0, ErrOutOfArea},
		{"할인 경계", 1000, 30000, 1000, nil},
		{"음수 거리", -1, 10000, 0, ErrInvalid},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := DeliveryFee(tt.distanceM, tt.amount)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("DeliveryFee(%d, %d) 오류 = %v, want %v", tt.distanceM, tt.amount, err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("DeliveryFee(%d, %d) = %d, want %d", tt.distanceM, tt.amount, got, tt.want)
			}
		})
	}
}

func TestCreateOrder(t *testing.T) {
	tests := []struct {
		name     string
		body     string
		wantCode int
		wantBody string
	}{
		{"정상", `{"phone":"010-1234-5678","distance_m":1500,"amount":12000}`, 201, `"fee":3000`},
		{"JSON 깨짐", `{`, 400, "잘못된 JSON"},
		{"전화번호 오류", `{"phone":"abc","distance_m":1,"amount":1}`, 400, "쓸 수 없는 문자"},
		{"범위 밖", `{"phone":"01012345678","distance_m":9000,"amount":1}`, 422, "배달 가능 거리"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			h := NewHandler(&Store{})
			rec := serve(t, h, http.MethodPost, "/orders", tt.body)
			if rec.Code != tt.wantCode {
				t.Errorf("상태 = %d, want %d", rec.Code, tt.wantCode)
			}
			if !strings.Contains(rec.Body.String(), tt.wantBody) {
				t.Errorf("본문 = %q, %q 를 포함해야 한다", rec.Body.String(), tt.wantBody)
			}
		})
	}
}

func TestGetOrder(t *testing.T) {
	store := &Store{}
	store.Add(Order{Phone: "01012345678", DistanceM: 500, Amount: 8000, Fee: 2000})
	h := NewHandler(store)

	tests := []struct {
		name     string
		path     string
		wantCode int
	}{
		{"있는 주문", "/orders/1", 200},
		{"없는 주문", "/orders/9", 404},
		{"숫자 아님", "/orders/x", 400},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			rec := serve(t, h, http.MethodGet, tt.path, "")
			if rec.Code != tt.wantCode {
				t.Errorf("GET %s 상태 = %d, want %d", tt.path, rec.Code, tt.wantCode)
			}
		})
	}
}

func TestJournal(t *testing.T) {
	dir := t.TempDir()
	j, err := OpenJournal(dir)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { j.Close() })

	if err := j.Append(Order{ID: 1, Phone: "01012345678", Fee: 2000}); err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(filepath.Join(dir, journalName))
	if err != nil {
		t.Fatal(err)
	}
	if got, want := string(data), "1|01012345678|2000\n"; got != want {
		t.Errorf("저널 내용 = %q, want %q", got, want)
	}
}

func FuzzNormalizePhone(f *testing.F) {
	f.Add("010-1234-5678")
	f.Add("02 555 1234")
	f.Add("")
	f.Fuzz(func(t *testing.T, s string) {
		got, err := NormalizePhone(s)
		if err != nil {
			return
		}
		if len(got) < 10 || len(got) > 11 || got[0] != '0' {
			t.Fatalf("NormalizePhone(%q) = %q, 길이나 첫 글자가 규칙에 맞지 않는다", s, got)
		}
		for _, r := range got {
			if r < '0' || r > '9' {
				t.Fatalf("NormalizePhone(%q) = %q, 숫자가 아닌 문자가 있다", s, got)
			}
		}
		again, err := NormalizePhone(got)
		if err != nil || again != got {
			t.Fatalf("NormalizePhone(%q) 결과 %q 를 다시 정규화하면 %q, %v", s, got, again, err)
		}
	})
}

줄별 해설

main.go

  • DeliveryFee 는 음수 입력을 ErrInvalid 로, 5000m 초과를 ErrOutOfArea 로 돌려준다. 두 오류는 패키지 변수로 두어 호출자와 테스트가 errors.Is 로 종류를 구분한다. switch 는 위에서부터 첫 일치 구간을 고르므로 경계값은 "이하" 쪽 구간에 속한다. 30000원 이상이면 구간별 요금에서 1000원을 뺀다.
  • NormalizePhone 은 문자열을 룬 단위로 훑는다. 숫자는 모으고 공백과 하이픈은 건너뛰며 그 밖의 문자는 오류로 처리한다. 빈 case 절은 아무것도 하지 않고 switch 를 빠져나가므로 해당 문자는 버려진다. 잘못된 UTF-8 바이트는 range 에서 U+FFFD 로 읽혀 default 로 가므로 퍼징 입력에도 패닉이 나지 않는다.
  • Store 는 뮤텍스로 슬라이스를 보호하고 주문 번호를 1부터 매긴다. 병렬 서브테스트가 하나의 Store 를 읽어도 안전하다.
  • NewHandler 는 POST /orders 에서 JSON 해석, 전화번호 정규화, 배달비 계산 순으로 검사하고 실패 종류에 따라 400 또는 422 를 돌려준다. GET /orders/{id} 는 r.PathValue 로 경로 값을 꺼낸다.
  • Journal 은 파일을 O_APPEND 로 열어 주문 한 건을 한 줄로 덧붙인다. 파일 핸들을 쥐고 있어 Close 가 필요하다는 점이 Cleanup 을 쓰는 이유가 된다.
  • call 은 httptest 의 요청과 기록기로 핸들러를 직접 호출하고 "상태 본문" 한 줄로 만든다. main 은 서버를 띄우지 않고 이 함수로 결과를 출력한다.
  • demoJournal 은 os.MkdirTemp 로 임시 디렉터리를 만들고 defer 로 지운다. 테스트 바깥의 일반 함수이므로 defer 가 맞는 도구다.

main_test.go

  • serve 는 t.Helper() 를 불러 실패 위치가 도우미가 아닌 호출한 테스트 줄로 표시되게 한다.
  • TestDeliveryFee 의 wantErr 는 error 타입이다. 오류가 없을 때는 nil 을 적고, errors.Is(nil, nil) 은 참이다. 서브테스트마다 t.Parallel() 을 호출해 일곱 행이 함께 실행된다.
  • TestCreateOrder 는 서브테스트마다 새 Store 를 만든다. 주문 번호 같은 상태가 행 사이에서 새지 않는다. 본문 전체 대신 strings.Contains 로 핵심 조각만 확인하므로 JSON 필드 순서가 바뀌는 사소한 변경에 덜 민감하다.
  • TestGetOrder 는 한 Store 를 공유하지만 서브테스트가 읽기만 하므로 병렬 실행해도 문제가 없다.
  • TestJournal 은 t.TempDir 로 디렉터리를 얻고 파일을 닫는 Cleanup 을 그 뒤에 등록한다. 종료 시 Cleanup 이 등록 역순으로 실행되어 파일 닫기, 디렉터리 삭제 순이 된다.
  • FuzzNormalizePhone 은 시드 세 개를 넣고, 성공한 결과에 대해서만 길이·문자·멱등성을 검사한다. 오류를 돌려준 입력은 규칙대로 거절된 것이므로 그냥 반환한다.

실행 결과

프로그램 실행은 다음과 같다. 임시 디렉터리 경로나 시간은 출력하지 않으므로 결과가 매번 같다.

$ go run main.go
배달비 [1500 12000] 3000 <nil>
배달비 [1500 35000] 2000 <nil>
배달비 [6000 12000] 0 배달 가능 거리를 넘었다
전화번호: 01012345678
전화번호 오류: 전화번호에 쓸 수 없는 문자 'a'
201 {"id":1,"phone":"01012345678","distance_m":1500,"amount":12000,"fee":3000}
200 {"id":1,"phone":"01012345678","distance_m":1500,"amount":12000,"fee":3000}
404 주문이 없다
422 배달 가능 거리를 넘었다
1|01012345678|3000
2|01099990000|2000

테스트는 다음 명령으로 실행한다. 통과하면 패키지 이름 뒤에 걸린 시간이 붙은 ok 줄이 나오는데, 시간 값은 실행마다 달라지므로 출력 예시는 적지 않았다.

$ go mod init delivery
$ go test -race ./...
$ go test -run 'TestDeliveryFee/5km_초과' -v
$ go test -fuzz=FuzzNormalizePhone -fuzztime=10s

두 번째 명령의 -run 패턴은 서브테스트 하나만 고른다. 마지막 명령은 10초 동안 입력을 만들어 보며, 성질이 깨지는 입력이 없으면 그대로 끝난다.

실무에서 자주 틀리는 것

병렬 서브테스트가 상태를 공유한다

케이스 사이에서 주문 번호가 이어지는 Store 를 하나만 만들고 병렬로 돌리면, 실행 순서에 따라 기대한 번호가 달라진다.

store := &Store{}
h := NewHandler(store)
for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		rec := serve(t, h, http.MethodPost, "/orders", tt.body)
		// 주문 번호가 1 이라고 가정한 검사 - 실행 순서에 따라 깨진다
		_ = rec
	})
}

서브테스트 안에서 자기 Store 를 만들면 서로 영향을 주지 않는다.

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		h := NewHandler(&Store{})
		rec := serve(t, h, http.MethodPost, "/orders", tt.body)
		_ = rec
	})
}

퍼즈 테스트가 정확한 값을 기대한다

퍼징 입력은 도구가 정하므로 기대 값을 미리 알 수 없다. 구현을 그대로 베낀 기준 함수와 비교하면 같은 버그를 두 번 쓰는 셈이다.

f.Fuzz(func(t *testing.T, s string) {
	got, _ := NormalizePhone(s)
	if got != strings.ReplaceAll(s, "-", "") {
		t.Fatal("불일치")
	}
})

어떤 입력에서도 성립해야 하는 성질을 검사한다.

f.Fuzz(func(t *testing.T, s string) {
	got, err := NormalizePhone(s)
	if err != nil {
		return
	}
	again, err := NormalizePhone(got)
	if err != nil || again != got {
		t.Fatalf("멱등성이 깨졌다: %q -> %q -> %q", s, got, again)
	}
})

도우미 함수의 defer 로 임시 자원을 지운다

도우미가 반환되는 순간 defer 가 실행되므로 호출한 테스트는 이미 지워진 디렉터리를 받는다.

func newDir(t *testing.T) string {
	dir, _ := os.MkdirTemp("", "journal")
	defer os.RemoveAll(dir) // 도우미가 끝나면 바로 지워진다
	return dir
}

t.TempDir 은 테스트가 끝날 때 지우고, 직접 만든 자원은 t.Cleanup 으로 묶는다.

func newDir(t *testing.T) string {
	return t.TempDir()
}

오류를 메시지 문자열로 비교한다

문구를 다듬는 순간 테스트가 깨지고, 감싼 오류에서는 일치하지 않는다.

if err == nil || err.Error() != "배달 가능 거리를 넘었다" {
	t.Errorf("오류 = %v", err)
}

표에 오류 값을 담고 errors.Is 로 종류를 비교한다.

if !errors.Is(err, tt.wantErr) {
	t.Errorf("오류 = %v, want %v", err, tt.wantErr)
}

한눈에 보기

이 장의 테스트 도구와 쓰는 때
도구핵심 사용법얻는 것주의
표 주도 테스트구조체 슬라이스와 for 루프케이스 추가가 한 줄메시지에 입력과 기대 값을 담는다
t.Runt.Run(이름, 함수)행별 이름과 개별 실행공백은 밑줄로 바뀐다
t.Parallel서브테스트 첫 줄에서 호출실행 시간 단축가변 상태를 공유하지 않는다
httptestNewRequest 와 NewRecorder포트 없이 핸들러 검증실제 연결이 필요하면 NewServer
t.Cleanup, t.TempDir테스트에 자원을 묶는다자동 정리, 역순 실행defer 와 시점이 다르다
퍼징f.Add 와 f.Fuzz, -fuzz 옵션예상 밖 입력 발견값이 아닌 성질을 검사한다
테스트 실행 명령과 효과
명령실행되는 것시드와 저장 입력용도
go test모든 테스트한 번씩 실행일상 검증과 CI
go test -race경쟁 검출을 켠 테스트한 번씩 실행병렬 테스트 점검
go test -run 패턴이름이 맞는 (서브)테스트패턴에 맞을 때만실패 행 재현
go test -fuzz=이름입력을 만들며 반복 호출출발점으로 사용새 입력 탐색

연습 문제

  1. DeliveryFee 에서 금액이 29999원과 30000원일 때 구간 요금이 같은 거리(2000m)에서 배달비 차이가 나는지 확인하는 행 두 개를 TestDeliveryFee 표에 추가하라. 이름과 기대 값을 정하라.
  2. TestCreateOrder 표에 "금액이 음수인 경우"를 추가하라. 상태 코드와 본문에서 확인할 문구는 무엇이어야 하는가.
  3. TestJournal 을 고쳐, 주문 두 건을 Append 한 뒤 파일이 정확히 두 줄인지 확인하라. 줄 수는 어떻게 세면 좋은가.
  4. FuzzNormalizePhone 에 "성공한 결과의 길이는 입력에서 숫자 문자만 센 개수와 같다"는 성질을 추가하려 한다. 성질 코드를 쓰고, 이 검사가 왜 구현 복사가 아닌지 설명하라.

정답과 해설

  1. 표에 {"할인 직전", 2000, 29999, 3000, nil} 과 {"할인 경계 2km", 2000, 30000, 2000, nil} 을 추가한다. 2000m 는 1001~3000m 구간이라 기본 3000원이고, 30000원 이상이면 1000원이 빠진다. 기존 "할인 경계" 행은 1000m 구간이어서 이 경계가 구간별로 같은 방식으로 적용되는지 한 번 더 확인하게 된다.
  2. {"음수 금액", `{"phone":"01012345678","distance_m":100,"amount":-1}`, 400, "0 이상"} 를 추가한다. DeliveryFee 가 ErrInvalid 를 돌려주고 핸들러는 ErrOutOfArea 가 아닌 오류를 400 으로 처리하며, 메시지 "거리와 금액은 0 이상이어야 한다"에 "0 이상"이 들어 있다. 이 행은 오류 종류별 상태 코드 매핑이 맞는지 보여 준다.
  3. 두 번째 Append 후 파일 내용을 읽고 strings.Count(string(data), "\n") 로 줄바꿈 수를 세어 2 와 비교한다. 줄마다 개행으로 끝나므로 줄 수와 개행 수가 같다. strings.Split(data, "\n") 은 마지막 빈 문자열이 하나 더 생기므로 길이가 3 이 되어 헷갈리기 쉽다.
  4. 성질 코드는 다음과 같다.
    digits := 0
    for _, r := range s {
    	if r >= '0' && r <= '9' {
    		digits++
    	}
    }
    if len(got) != digits {
    	t.Fatalf("NormalizePhone(%q) 길이 = %d, 숫자 수 = %d", s, len(got), digits)
    }
    
    성공한 경우(오류가 nil 인 경우)에만 이 검사를 한다. 이 검사는 구현이 하는 "숫자만 모으기"를 다른 방식(개수 세기)으로 확인한다. 결과 문자열 자체를 다시 만들어 비교하지 않으므로 구현의 오류를 그대로 따라 쓰지 않는다. 어떤 입력에서도 성립해야 하는 관계를 기술했다는 점이 핵심이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.