winterjung blog


시간에 구애받지 않고 랜덤하게 실패하지 않는 테스트 방법

지난 7월 Go To Jeju 2024 행사와 10월의 GopherCon Korea 2024 행사에서 《Deterministic testing in Go》 주제로 발표를 했다. 발표에서는 코드에서 time.Now()를 사용하더라도 어떻게 시간에 구애받지 않고, 랜덤값이 있거나 고루틴을 사용해도 결정론적으로 테스트할 수 있는지 다뤘는데 이를 글로 옮겼다. 만약 발표 영상을 보고 싶다면 DAY1 GopherCon Korea 2024 라이브 영상 혹은 아래 영상을 참고할 수 있다.

Deterministic testing#

Flaky testing#

몽키 패칭#

랜덤 값을 고정하기#

// ❌
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))
    }
}

go 언어 특성상 flaky test가 발생하기 쉬운 지점#

  1. map은 순회할 때 순서가 보장되지 않음
    • 때문에 map을 순회하며 slice에 저장하고 마지막에 정렬을 하거나
// ❌
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
}
  1. 고루틴을 쓸 때 실행 순서가 보장되지 않음
  2. 값을 모두 수신할 수 있는 채널이 여러개 있다면 랜덤하게 select 됨
    • e.g. quit 시그널 받았을 때 큐 용도로 쓰는 채널이 있다면 해당 채널을 drain 해줘야 함
  3. go 언어를 쓰다보면 자주 쓰게되는 protojson, prototext의 특이사항. marshal된 값이 그때그때 {“a”: “b”}일 수도 {“a”:“b”}일 수도 있다.
    • 프로세스 안에서는 고정적이기에 문자열을 expected로 쓰는 대신 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)
}

시간에 구애받지 않는 테스트#

// ❌
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)
}
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에서 해당 로직을 수행할 수도 있음
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]
}

Flaky test 탐지하기#

마치며#

앞서 말한 내용을 한 줄로 요약해보자면 결국은 의존성을 인자로 잘 넘겨 쓰자는 얘기다. 앞의 코드 예시와 상당 내용이 go 언어와 관련된 내용이긴 하지만 개발할 때 보편적으로 적용해볼 수 있으리라 기대한다.

추가 자료#