지난 7월 Go To Jeju 2024 행사와 10월의 GopherCon Korea 2024 행사에서 《Deterministic testing in Go》 주제로 발표를 했다.
발표에서는 코드에서 time.Now()
를 사용하더라도 어떻게 시간에 구애받지 않고, 랜덤값이 있거나 고루틴을 사용해도 결정론적으로 테스트할 수 있는지 다뤘는데 이를 글로 옮겼다.
만약 발표 영상을 보고 싶다면 DAY1 GopherCon Korea 2024 라이브 영상 혹은 아래 영상을 참고할 수 있다.
rand.Seed
로 시드를 고정시켜볼 수 있었으나rand.New(rand.NewSource(...))
로 반환된 rander를 써야함// ❌
func sampling(rate float64) bool {
// non-deterministic
return rand.Float64() < rate
}
// ✅
func sampling2(r rand.Rand, rate float64) bool {
return r.Float64() < rate
}
// ✅
func sampling3(r rand.Rand) func(float64) bool {
return func(rate float64) bool {
return r.Float64() < rate
}
}
// ✅
func sampling4(randFn func() float64, rate float64) bool {
return randFn() < rate
}
// ✅
type sampler interface {
Sample(float64) bool
}
type randSampler struct {
randFn func() float64
}
func (s *randSampler) Sample(rate float64) bool {
return s.randFn() < rate
}
// 항상 샘플링 하지 않는다.
type neverSampler struct {}
func (s *neverSampler) Sample(float64) bool { return false }
// 항상 샘플링한다.
type alwaysSampler struct {}
func (s *alwaysSampler) Sample(float64) bool { return true }
func TestHash(t *testing.T) {
hashed := sha256.Sum256([]byte("hello"))
s := hex.EncodeToString(hashed[:])
assert.Equal(t, "...", s)
}
// Error:
// Not equal:
// expected: "..."
// actual : "2cf24dba5fb0a30e...425e73043362938b9824" // Copied!
// ❌
func TestUUIDEventLogger(t *testing.T) {
logger := NewEventLogger()
logger.Log()
// Output:
// 8a18ead2-c292-4998-be08-ce0f1b5936c5
// 2885f037-494e-4910-89fe-c7160ebf5e61
}
// ✅
func TestFixedEventLogger(t *testing.T) {
logger := NewEventLogger(func() string {
return "00000000-0000-0000-0000-123456789012"
})
logger.Log()
// Output:
// 00000000-0000-0000-0000-123456789012
}
// ✅
func TestAtomicEventLogger(t *testing.T) {
var cnt int32
mockUUIDFunc := func() string {
atomic.AddInt32(&cnt, 1)
return fmt.Sprintf("00000000-0000-0000-0000-%012d", cnt)
}
logger := NewEventLogger(mockUUIDFunc)
logger.Log()
// Output:
// 00000000-0000-0000-0000-000000000001
// 00000000-0000-0000-0000-000000000002
}
// other_pkg.go
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func NewNonce() string {
b := make([]byte, 16)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
// my_test.go
func TestNewNonce(t *testing.T) {
result := NewNonce() // Output: Imq61MBEGBVxXQ2l, eU1XBzYOqUFlTQeL
assert.Len(t, result, 16)
for _, r := range result {
assert.Contains(t, charset, string(r))
}
}
// ❌
func unstableUniq(candidates []string) []string {
uniq := make(map[string]bool)
for _, k := range candidates {
uniq[k] = true
}
keys := make([]string, 0)
for k := range uniq { // unstable
keys = append(keys, k)
}
return keys
}
// ✅
func stableSortUniq(candidates []string) []string {
uniq := make(map[string]bool)
for _, k := range candidates {
uniq[k] = true
}
keys := make([]string, 0)
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys) // stable
return keys
}
// ✅
func stableUniq(candidates []string) []string {
keys := make([]string, 0)
uniq := make(map[string]bool) // 룩업용 보조 map
for _, k := range candidates {
if uniq[k] {
continue
}
uniq[k] = true
keys = append(keys, k)
}
return keys
}
{“a”: “b”}
일 수도 {“a”:“b”}
일 수도 있다.
assert.JSONEq
같은 함수를 사용하거나 mustMarshalJSON
같은 헬퍼 함수를 만들어 사용해야함// marshal_test.go
func mustMarshalJSON(m proto.Message) []byte {
marshaler := protojson.MarshalOptions{}
b, err := marshaler.Marshal(m)
if err != nil {
panic(err)
}
return b
}
func TestPublishedProtoEvent(t *testing.T) {
event := &proto.Event{
Name: "hello",
}
publishedEvent := publish(event)
// ❌
assert.Equal(t, `{"source": {"name": "hello"}}`, publishedEvent)
// ✅
assert.JSONEq(t, `{"source": {"name": "hello"}}`, publishedEvent)
// ✅
assert.Equal(t, mustMarshalJSON(&proto.PublishedEvent{
Source: &proto.Event{
Name: "hello",
},
}), publishedEvent)
}
time.Now()
를 쓰는 time.Since(t)
, time.Until(t)
사용을 피해야 함time.Now()
대신 앞서 말한 내용과 마찬가지로 time func, now func 따위의 이름으로 현재 시각을 반환하는 함수를 인자로 전달받아 사용해야함// ❌
func isExpired(t time.Time) bool {
return t.Before(time.Now())
}
// ✅
func isExpired(t, now time.Time) bool {
return t.Before(now)
}
// 아래처럼 선언해두면 용도에 맞게 nowFunc를 주입할 수 있음
func handler(db *sql.DB, nowFunc func() time.Time) handlerFunc {
return func(ctx context.Context, r http.Request) (http.Response, error) {
token := getTokenFromDB(db)
if isExpired(token.Expiry, nowFunc()) {
// ...
}
}
}
// main.go
func main() {
// ...
handler(db, time.Now)
}
// handler_test.go
func TestHandler(t *testing.T) {
// ...
mockNow := func() time.Time {
return time.Date(2024, 7, 13, 0, 0, 0, 0, time.UTC)
}
resp, err := handler(mockDB, mockNow)(ctx, req)
}
func TestExponentialBackoff(t *testing.T) {
// Given
// 실행 횟수에 따라 의도된 backoff 시간이 나오는지 검증하기 위한 sleep 함수와 카운터
var count int32
sleepFunc := func() func(time.Duration) {
expectedIntervals := []time.Duration{
1 * time.Second, 2 * time.Second,
4 * time.Second, 8 * time.Second,
}
return func(d time.Duration) {
assert.Equal(t, expectedIntervals[count], d)
count++
}
}()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/users/some-user-id", r.URL.Path)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
client := NewUserServiceClient(
srv.URL,
sleepFunc,
)
// When
ctx := context.Background()
resp, err := client.GetUser(ctx, &GetUserRequest{
UserID: "some-user-id",
})
// Then
assert.EqualError(t, err, "503: Service Unavailable")
assert.Equal(t, 5, count)
}
type Clock interface {
After(d time.Duration) <-chan time.Time
Sleep(d time.Duration)
Now() time.Time
Since(t time.Time) time.Duration
NewTicker(d time.Duration) Ticker
NewTimer(d time.Duration) Timer
AfterFunc(d time.Duration, f func()) Timer
}
func userRegisterHandler(cli emailClient) {
// ...
go sendEmail(cli, newUser)
}
func TestIsEmailSent(t *testing.T) {
cli := &mockEmailClient{/* ... */}
err := handler(cli)
assert.NoError(t, err)
// 고루틴 기다리기
time.Sleep(100 * time.Millisecond)
assert.Len(t, cli.sentEmails, 1)
}
runtime.Gosched()
함수로도 해당 고루틴의 실행을 보장할 수 없음assert.Eventually
함수를 사용하거나func TestIsEmailSent(t *testing.T) {
cli := &mockEmailClient{}
handler(cli)
assert.Eventually(t, func() bool {
return cli.sentEmails > 0
}, time.Second, 100*time.Millisecond)
}
func (c *mockEmailClient) SendEmail(title, body string) {
c.sentEmails = append(c.sentEmails, title)
c.sent <- struct{}{}
}
func TestIsEmailSent(t *testing.T) {
cli := &mockEmailClient{sent: make(chan struct{})}
handler(cli) // go sendEmail(cli, newUser) 수행
<-cli.sent
assert.Len(t, cli.sentEmails, 1)
}
// 혹은 mock 객체를 만들어주는 도구에 따라 Do hook의 func에서 해당 로직을 수행할 수도 있음
sync/errgroup
, sync.WaitGroup
대신 Group같은 인터페이스를 선언해 메커니즘 자체를 의존성으로 사용해볼 수 있음type Group interface {
Go(f func() error)
Wait() error
}
// implemented using sync.WaitGroup, golang.org/x/sync/errgroup
type syncGroup struct {}
// for testing
type sequentialGroup struct {}
func handler(g Group) {
g.Go(func() error {
return nil
})
if err := g.Wait(); err != nil {
// ...
}
}
// ❌
func TestFanOutWrongWay(t *testing.T) {
var wg sync.WaitGroup
result := make([]int, 0)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
result = append(result, i) // 단순 append
}(i)
}
wg.Wait()
assert.Len(t, result, 10) // 10개가 아님
}
// ⚠️
func TestFanOutNonDeterministic(t *testing.T) {
var mu sync.Mutex
var wg sync.WaitGroup
result := make([]int, 0)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
mu.Lock() // lock 후 append
defer mu.Unlock()
result = append(result, i)
}(i)
}
wg.Wait()
// 10개지만 순서가 항상 다름
assert.Len(t, result, 10) // [1 9 5 6 7 8 0 2 3 4]
}
// ✅
func TestFanOut(t *testing.T) {
var wg sync.WaitGroup
result := make([]int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
result[i] = i // assign
}(i)
}
wg.Wait()
assert.Len(t, result, 10) // [0 1 2 3 4 5 6 7 8 9]
}
go test -count 10
혹은 100 하면 더 쉽게 관측됨go test -shuffle on
옵션으로 테스트 순서를 섞어 좀 더 색출해낼 수 있음앞서 말한 내용을 한 줄로 요약해보자면 결국은 의존성을 인자로 잘 넘겨 쓰자는 얘기다. 앞의 코드 예시와 상당 내용이 go 언어와 관련된 내용이긴 하지만 개발할 때 보편적으로 적용해볼 수 있으리라 기대한다.