카레제육 블로그
GopherCon Korea 2024 - 랜덤하게 실패하지 않는 테스트 방법, 정겨울 본문
당근에서 소프트웨어 엔지니어로 근무하는 정겨울님의 발표로, 어떻게하면 테스트에서 랜덤하게 실패하는것을 피할 수 있는가? 에 대한 내용이다. 아래 내용을 이야기한다. 먼저, 스포일러 하자면 변규현님 세션에서도 느꼈는데 당근은 의존성 주입을 정말 잘 활용하는 것 같다.
- deterministic testing이란?
- 비 결정적 요소를 더 잘 테스트 하는 법
- 시간을 더 잘 테스트 하는 법
- 고루틴을 더 잘 테스트 하는 법
- (유닛 테스트에서)
Deterministic testing in Go
의존성을 잘 주입해 쓰자-!
파이썬에서 몽키 패칭이 되었지만... Go에서 잘 안된다.
deterministic testing이란?
Non-deterministic testing
결과를 예측할 수 없는 테스트, 네트워크 호출에 의존하거나 (언제나 실패할 수 있는 경우), 파일을 읽고 쓰거나 (실패할 수 있음), 임의의 값을 사용하거나 (어떤 값이 생성될 지 -정의는 정해져 있지만- 예측 할 수 없는 경우), 동시에 실행하거나 (순서가 일관되지 않음)
Flaky testing - 왜 피해야 할까
Non-deterministic 요소 때문에 때론 성공하거나 실패하는 테스트가 된다. 깨진 유리창 이론과 같이 '원래 종종 실패하니까..'라는 안일함 발생, 불필요한 테스트 시간의 늘어뜨림, 프로덕션 배포의 잠재 위협 요소
비 결정적 요소를 고정하기
단순 샘플링 때문에 사용할 수도 있고 다양한 사례에서 랜덤을 사용한다. 이 경우, rand.Seed로 고정시켜볼 수 있었으나 1.20버전 부터는 rand.New(rand.NewSource(...))로 반환된 rander를 써야한다. 때문에 rander를 주입받아 사용하는게 속 편한 상황이 되었다.
rander를 그대로 쓰기보다는 id generator, logging decider처럼 그 역할을 인터페이스로 빼서 주입하길 권장하고 있다.
// X rand.Rand를 그냥 사용
func sampling(rate float64) bool {
return rand.Float64() < rate
}
// O 고정된 rand.Rand을 외부에서 주입
func sampling2(r rand.Rand, rate float64) bool {
return r.Float64() < rate
}
// O sampling3안에서 클로저를 반환함. 함수로 사용
func sampling3(r rand.Rand) func(float64) bool {
return func(rate float64) bool {
return r.Float64() < rate
}
}
// O rand 함수 자체를 주입
func sampling4(randFn func() float64, rate float64) bool {
return randFn() < rate
}
// O 인터페이스와 구조체를 만들어서 포인트 리시버를 사용하고 rand를 주입
type sampler interface {
Sample(float64) bool
}
type randSampler struct {
randFn func() float64
}
func (s *randSampler) Sample(rate float64) bool {
return s.randFn() < rate
}
포인트 리시버를 사용하는 구조를 사용하는 경우 역할에 맞는 함수를 재정의하여 사용 시나리오 맞게 만들 수 있다.
type naverSampler struct {}
func (s *neverSampler) Sample(float64) bool { return false }
type alwaysSampler struct {}
func (s *alwaysSampler) Sample(float64) bool { return true }
무언가를 생성할 때는..
해시처럼 input으로 넣었던 인자의 expected를 계산하기 쉬운 경우도 있다. 테스트를 두 번 돌려서 확인해본다.
문제는 uuid, nonce generator 류 함수, new func든 factory든 생성 함수를 인자로 받기, atomic을 사용하기.
*atomic 패키지로 하나씩 증가하는 방식을 구성일때 추천..
기존 코드 수정이 어렵다면 값 그 자체보다는 의도된 포맷인지 그 속성을 검사하는 방법도 존재한다. 예를들어 알파뱃 조합인지, 정해진 길이인지 등...
그 외 flaky test 피하기
*동일한 코드에 대해 여러 번 실행했을 때 일관되지 않은 결과를 보이는 테스트. 때로는 통과하고 때로는 실패하는 테스트를 의미
- map을 순회할 때 순서가 보장되지 않음
-> slice에 저장하고 sorting 하거나 아예 slice 자체를 돌며 map은 보조 룩업용으로만 사용
- 고루틴 사용 시 실행 순서가 보장되지 않음
- 값을 모두 수신할 수 있는 채널 여러개를 select 할 땐 랜덤. 예) quit 시그널 받았을 때 채널을 drain 하기
- protojson, prototext의 특이사항. marshal된 값은 그때그때 다르다. 예) `{"a": "b"}`일 수도 `{"a":"b"}`일 수도 있다.
*marshal: struct를 를 JSON, XML 등의 형식으로 직렬화할 때 사용. 그 후 string 화 가능.
시간에 구애받지 않는 테스트
시간을 테스트 할 땐
내부적으로 time.Noew()를 쓰는 time.Since(t), time.Until(t)은 피하고 time.Now 대신 time func, now func을 인자로 전달받아 쓰기
// X 단순히 시간을 하나 넘김
func isExpired(t time.Time) bool P
return t.Before(time.Now())
}
// O 시간을 두 개 넘김
func isExpired(t, now time.Time) bool {
return t.Before(now)
}
sleep, ticker, timer, after 등 좀 더 복잡해지면 jonboulle/clockwork 라이브러리처럼 clock 인터페이스를 정의해서 사용하길 권장
타임아웃 테스트
요청이 10초 이상 걸리면 취소하고 예전 stale 응답을 반환하는 예제로 테스트에서 10초를 기다릴 순 없다.
context는 항상 상위 scope에서 전달받아야하고, 테스트 코드에서는 context.WithTimeout(ctx, 0)으로 이미 타임아웃된 요청을 넘기는 방법
고루틴 잘 테스트하기
고루틴 타이밍
핸들러에서 처리를하고 환영 이메일을 보내줘! 하는 예제.
- fire and forgot보다 더 관리의 영역으로 두어야 한다.
- runtime.Gosched()로도 실행을 보장할 수 없다.
- testify의 assert.Eventually 함수를 사용하거나
- 전달한 의존성의 채널을 소비하거나 wait group을 만료시키는 방식 필요
- 고루틴 실행 순서에 민감하다면 go 키워드, sync/errgroup, sync.WaitGroup 사용 대신 Group 같은 인터페이스를 선언해 메커니즘 자체를 의존성으로 사용하길 권장
type Group interface {
Go(f func() error)
Wait() error
}
// using sync.WaitGroup, golang.org/x/sync/errgroup
type syncGroup struct {}
// for testing
type sequentialGroup struct {}
// handler use Group
func handler(g Group) {
g.Go(func() error {
return nil
})
if err := g.Wait(); err != nil {
// ...
}
}
fanout 결과를 담을 땐 mutex+append, 채널 보단 정해진 인덱스에 assign하는 방식으로 진행, 로직에 따라 zero value 필터링이 필요해질 수 있음
Flaky test 탐지하기
- 가끔 github actions가 실패했는데 re-run하니 성공하는 식.
- go test -count 10 혹은 100 하다보면 관측된다.
- 1.17부턴 go test -shuffle on 옵션으로 평소에 발견해볼 수 있다.
- 수정이 어렵다면, gotestyourself/gotestsum같은 도구로 테스트 retry를 자동으로 시도해볼 수 있다.
- 테스트에 불안정함을 표시할 수 있는 기능, 자동으로 재시도하는 기능관련 제안이 수락되어 미래 릴리즈 버전에 자체 구현될 전망이 있다.