Go의 비밀스러운 삶: 테스트
I’m happy to translate the article for you, but I’ll need the full text of the post (or the portions you’d like translated). Could you please paste the content here? Once I have the text, I’ll provide a Korean translation while keeping the source link, formatting, markdown, and any code blocks exactly as they are.
The Table of Truth
수요일 비가 아카이브 창에 일정한 리듬으로 두드리며 맨해튼 스카이라인을 회색과 슬레이트 색의 흐림으로 만들었다. 안쪽에서는 오래된 종이 냄새와 신선하고 날카로운 레몬 향이 섞여 있었다.
이선은 긴 오크 테이블에 서서 종이 조각들에 둘러싸여 있었다. 그는 격렬하게 타이핑하고, 명령을 실행하고, 찡그리며, 한 줄을 삭제하고 다시 실행하고 있었다.
“레몬 양귀비씨 머핀,” 그가 고개를 들지 않은 채 흰 가방을 책상 위에 미끄러뜨리며 말했다. “그리고 런던 포그. 얼그레이, 바닐라 시럽, 스팀 밀크.”
엘레노어는 차를 받아 들었다.
“뭔가… 초조해 보이네, 이선.”
“사용자명 검증기에서 버그를 고치고 있어,” 이선이 중얼거렸다. “한 케이스를 고치면 다른 케이스가 깨지고, 또 그걸 고치면 처음 케이스가 깨져.
go run main.go를 한 시간째 돌리면서 입력 변수를 수동으로 바꾸며 무슨 일이 일어나는지 보고 있어.”
엘레노어는 차를 천천히 내려놓았다.
“수동으로 테스트하고 있는 거야?”
“다른 방법이 있겠어?”
“이선, 너는 인간이야. 창의적이고 직관적이며 지루함에 쉽게 빠져. 반복 작업은 정말 못해.” 그녀가 노트북을 열었다. “컴퓨터는 창의적이지도 않고 지루하기도 하지만 절대 피곤해하지 않아. 우리는 수동으로 테스트하지 않아. 코드를 써서 우리 코드를 테스트하지.”
“Go는 무거운 테스트 프레임워크를 설치하도록 요구하지 않아,” 엘레노어가 말을 이었다. “내장돼 있어. 코드 옆에
_test.go로 끝나는 파일을 만들면 돼.”
그녀는 validator_test.go라는 파일을 만들었다:
package main
import "testing"
func TestIsValidUsername(t *testing.T) {
result := IsValidUsername("admin")
expected := true
if result != expected {
t.Errorf("IsValidUsername('admin') = %v; want %v", result, expected)
}
}
“함수 이름은 Test로 시작하고
testing.T에 대한 포인터를 받아야 해. 이t가 바로 네 제어판이야. 실패를 보고할 때 이걸 사용하지.”
그녀는 터미널에서 go test를 실행했다.
PASS
“알겠어,” 이선이 말했다. “하지만 나는 스물 가지 다른 경우가 있어. 빈 문자열, 기호, 너무 길거나, 너무 짧은 경우…”
“그럼 스물 개의 어설션을 쓰겠다는 거야?” 엘레노어가 물었다. “같은
if블록을 스물 번 복사·붙여넣기?”
“그런가?”
“아니.” 엘레노어가 고개를 저었다. “그게 바로 보일러플레이트에 빠지는 방법이야. Go에서는 특정 관용구를 사용해. 테스트 케이스를 코드가 아니라 데이터로 다루지. 우리는 Table of Truth를 만든다.”
그녀는 화면을 닦고 스크립트라기보다 장부에 가깝게 보이는 구조를 타이핑하기 시작했다:
package main
import "testing"
func TestIsValidUsername(t *testing.T) {
// 1. Define the table
// An anonymous struct slice containing all inputs and expected outputs
tests := []struct {
name string // A description of the test case
input string // The input to the function
expected bool // What we expect to get back
}{
{"Valid user", "ethan_rose", true},
{"Too short", "ab", false},
{"Too long", "this_username_is_way_too_long_for_our_system", false},
{"Empty string", "", false},
{"Contains symbols", "ethan!rose", false},
{"Starts with number", "1player", false},
}
// 2. Loop over the table
for _, tt := range tests { // tt = "test table" entry
// 3. Run the subtest
t.Run(tt.name, func(t *testing.T) {
got := IsValidUsername(tt.input)
if got != tt.expected {
// We use Errorf, NOT Fatalf.
// Errorf marks failure but continues to the next case.
t.Errorf("IsValidUsername(%q) = %v; want %v", tt.input, got, tt.expected)
}
})
}
}
“이 구조를 봐,” 엘레노어가 손가락으로 슬라이스를 따라가며 말했다. “로직—
if검사와 실행—은 정확히 한 번만 작성돼. 테스트의 복잡성은 데이터의 복잡성과 분리돼 있지.”
이선은 바라보았다.
“스프레드시트 같아.”
“정확히. 테이블이야. 새로운 버그를 찾으면—예를 들어, 사용자명이 밑줄로 끝날 수 없다고 하면—새 함수를 만들 필요 없어. 그냥 한 줄을 추가하면 돼.”
“그리고 끝났어요. 테스트 러너가 나머지를 처리합니다. 여기서
t.Errorf를 사용했지t.Fatalf를 사용한 건 아니에요.Fatal을 쓰면 첫 번째 실패가 전체 테스트를 중단시키죠.Error를 쓰면 모든 실패를 한 번에 볼 수 있습니다.”
“
t.Run라인을 보세요,” 엘리노어가 지적했습니다. “이게 서브테스트를 만들어요. ‘Empty string’ 케이스가 실패하면, Go가 정확히 어떤 케이스가 이름으로 실패했는지 알려줍니다.”
그녀는 의도적으로 코드를 깨뜨려 보여주었습니다:
--- FAIL: TestIsValidUsername (0.00s)
--- FAIL: TestIsValidUsername/Empty_string (0.00s)
validator_test.go:26: IsValidUsername("") = true; want false
FAIL
“즉시 컨텍스트를 제공해 줍니다. 해당 케이스만 고치고 다시 테스트를 실행하면 초록색 PASS를 볼 수 있죠. 이것이 피드백 루프입니다. 테이블에 실패 케이스를 넣고, 코드를 고치고, 통과하는 것을 확인하고, 반복합니다.”
이선은 눈을 비볐습니다.
“아침에 이걸 알았다면 세 시간은 절약했을 텐데요.”
“당신의 커리어 전체에 걸쳐 세 해를 절약하게 될 겁니다,” 라고 엘리노어는 레몬 양귀비 씨 머핀을 한 입 물며 말했습니다. “테이블‑드리븐 패턴은 엣지 케이스를 생각하게 강제합니다. 테이블을 보면 뇌가 자연스럽게 ‘뭐가 빠졌지? 음수를 체크했나? nil을 체크했나?’ 라고 묻거든요.”
“오류에도 이렇게 쓸 수 있나요?” 이선이 물었습니다. “지난번에 얘기한 오류 처리 같은 경우도요?”
“오류 처리에 특히 빛을 발합니다,” 라고 엘리노어가 미소 지었습니다.
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
filename string
wantErr bool // Simple boolean check: did we get an error?
}{
{"Valid file", "config.json", false},
{"File not found", "missing.json", true},
{"Bad permissions", "root_only.json", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseConfig(tt.filename)
// If we expected an error (true) and got none (nil)… failure.
if (err != nil) != tt.wantErr {
t.Errorf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
“여기서
wantErr는 단순한 불리언입니다. 우리는 항상 …”
(대화는 계속됩니다.)
Conversation Excerpt
“정확한 오류 메시지 텍스트를 확인하세요—대부분 경우, 실패했다는 사실만 알아도 논리 검증에 충분합니다. 특정 오류 유형을 확인해야 한다면 루프 안에서
errors.Is를 사용하면 됩니다.”
Ethan은 눈을 감고 자신의 지저분한 main.go를 떠올렸다.
“그러니까 테스트 파일이 곧 프로그램의 사양이라는 거죠.”
“맞아요. 그것은 거짓말을 할 수 없는 문서입니다. 주석은 오래될 수 있고, 다이어그램은 틀릴 수 있습니다. 하지만 테스트가 통과하면 코드는 정상입니다.”
그는 차를 마시고 나서 말했다. “오래된 러시아 속담이 있죠: Doveryay, no proveryay.”
“믿되, 검증하라?”
“정확히 그렇습니다. 좋은 코드를 작성했다고 믿으세요. 하지만 표(table)로 검증하세요.”
Ethan은 **user_test.go**라는 새 파일을 열고 타이핑을 시작했다:
tests := []struct {
// fields for each test case
}{ ... }
“Eleanor?”
“네?”
“이 머핀 꽤 맛있네.”
“괜찮아요,” 그녀는 입가에 살짝 미소를 띠며 말했다. “이제 이모지가 포함된 사용자 이름에 대한 테스트 케이스를 추가해 보세요. 검증 로직이 실패할 것 같거든.”
Go 테스트 입문
테스트 패키지
- Go의 내장 테스트 프레임워크.
- 외부 라이브러리가 필요하지 않음.
파일 명명
- 테스트 파일은 반드시
_test.go로 끝나야 합니다 (예:user_test.go). - 일반 애플리케이션을 빌드할 때 컴파일러가 무시하지만
go test에서는 인식됩니다.
테스트 함수
- 이름이
Test로 시작해야 합니다. - 시그니처:
func TestXxx(t *testing.T)
테이블 기반 테스트 (관용적인 Go)
- 정의: 입력, 기대 출력, 이름을 포함하는 익명 구조체 슬라이스를 정의합니다.
- 반복:
range를 사용해 슬라이스를 순회합니다. - 실행: 루프 안에서 로직을 한 번 실행합니다.
func TestValidateUsername(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"valid‑ascii", "john_doe", false},
{"invalid‑space", "john doe", true},
{"emoji‑username", "😀user", true},
}
for _, tc := range cases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
err := ValidateUsername(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
})
}
}
Error vs. Fatal
| Function | Behaviour |
|---|---|
t.Errorf | 실패를 기록하지만 테스트 함수를 계속 실행합니다. 테이블 기반 테스트에서는 여러 실패를 확인할 수 있어 선호됩니다. |
t.Fatalf | 실패를 기록하고 테스트 함수를 즉시 중단합니다. 테스트를 진행할 수 없을 때만 사용합니다 (예: 설정 실패). |
서브테스트 (t.Run)
- 루프의 각 반복에 라벨을 붙일 수 있습니다.
- 하나의 케이스가 실패하면
go test가 해당 케이스의 구체적인 이름을 보고합니다.
테스트 실행
| Command | Description |
|---|---|
go test | 패키지 내 모든 테스트를 실행합니다. |
go test -v | 자세한 출력 – 모든 서브테스트를 표시합니다. |
go test -run TestName | 특정 테스트 함수만 실행합니다. |
사고 모델
테스트는 귀찮은 일이 아니라 진리의 표입니다.
테스트는 데이터(테스트 케이스)와 실행 로직(테스트 러너)를 분리합니다.
다음 장: 실전 인터페이스
관리자와 일반 사용자를 위한 검증 규칙이 다를 때는 어떻게 할까요?
Ethan은 “accept interfaces, return structs” 가 유연한 설계의 핵심이라는 것을 배웁니다.
저자 소개
Aaron Rose – tech‑reader.blog의 소프트웨어 엔지니어이자 기술 작가; Think Like a Genius의 저자.