숏컷 코드
func FuzzParseID(f *testing.F) {
f.Add("user-123")
f.Add("")
f.Fuzz(func(t *testing.T, raw string) {
id, err := ParseID(raw)
if err == nil && id.String() == "" {
t.Fatalf("empty id from %q", raw)
}
})
}go test ./...
go test -fuzz=FuzzParseID -fuzztime=30sfuzz 흐름
fuzz test는 seed corpus와 fuzz target으로 구성된다
Go fuzz test는 func FuzzXxx(f *testing.F) 형태로 작성합니다. f.Add(...)는 기본으로 실행할 seed corpus를 등록하고, f.Fuzz(...) 안의 함수가 실제로 무작위 입력을 받는 fuzz target입니다. 일반 go test에서는 seed corpus가 회귀 테스트처럼 실행되고, -fuzz flag를 주면 fuzzing engine이 입력을 변형하면서 새 실패를 찾습니다.
fuzz target은 빠르고 결정적이어야 한다
fuzzing은 같은 target을 매우 많이 반복 실행합니다. target이 느리거나 전역 상태, 현재 시간, 네트워크, 파일 시스템 상태에 의존하면 재현성이 떨어지고 실행 비용이 커집니다. parser, encoder/decoder, validator, 문자열 처리, binary format 처리처럼 순수 함수에 가까운 경계가 fuzzing에 잘 맞습니다.
f.Fuzz(func(t *testing.T, input []byte) {
out, err := Decode(input)
if err != nil {
t.Skip()
}
if !Valid(out) {
t.Fatalf("invalid decode: %#v", out)
}
})입력이 "관심 없는 invalid input"이면 실패가 아니라 t.Skip()으로 제외할 수 있습니다. panic, t.Fatal, t.Error는 실패 입력으로 기록됩니다.
실패 입력은 회귀 테스트 자산이 된다
fuzzing 중 실패가 나오면 Go는 실패 입력을 testdata/fuzz/<FuzzName>/... 아래에 저장할 수 있습니다. 버그를 고친 뒤 일반 go test만 실행해도 seed corpus가 다시 실행되므로, 같은 입력이 회귀 테스트가 됩니다. 이 파일은 우연한 부산물이 아니라 재현 가능한 실패 사례로 관리해야 합니다.
어디에 쓸까
| 상황 | 적합한 선택 |
|---|---|
| parser나 decoder가 예외 입력에 약할 때 | fuzz test |
| table test로 케이스를 다 쓰기 어려울 때 | seed + fuzz target |
| 실패 입력을 회귀 테스트로 남길 때 | testdata/fuzz 관리 |
| target이 느리거나 외부 I/O가 필요할 때 | fuzzing보다 unit/integration test |
| CI에서 짧게 돌릴 때 | -fuzztime 지정 |
주의할 점
fuzzing은 기본적으로 오래 실행될 수 있습니다. 로컬 탐색, 짧은 CI 실행, 장시간 보안 탐색을 같은 명령으로
운영하지 말고 -fuzztime과 대상 package를 명확히 나누는 편이 안전합니다.
go test -fuzz=FuzzParseID -fuzztime=30s장시간 실행할수록 더 많은 입력을 탐색할 수 있지만, 빠른 검증 루프에서는 시간 상한을 두는 편이 좋습니다.
참고 링크
2 sources