Zenn
🥁

【Go】非同期処理のテストコードの書き方とsynctest入門

2025/04/02に公開

はじめに

こんにちは!e-dashのプロダクト開発部所属の高尾です。

Goの大きな特徴の1つとして、ゴルーチンを使った並行処理・非同期処理の実装の容易さが挙げられますよね。
go キーワードを使うだけで簡単に非同期処理を実装できる一方で、その非同期処理を含むコードのテストには独特の難しさがあります。

先日、非同期処理を含むコードのテストを書いていた際に、非同期処理が正常に完了しているかを検証したいケースがありました。
調べてみると意外にも情報が少なかったため、本記事では私なりの「非同期処理の完了検証方法」をご紹介します。

また、Go1.24において、非同期・並行テストをより簡単に実現することができるsynctestパッケージが試験的な機能として追加されました。今回はこのsynctestについても追加で紹介します。

実装されて時間が浅い機能になりますので、仕様を十分に理解できていない可能性があります。もし誤りがありましたらコメントなどでご指摘いただけますと幸いです。

本記事で扱う内容

  • 非同期処理を含むコードのテストにおける課題
  • 非同期処理の完了を待つ3つのテストパターン
  • synctestを使ったパターン

想定する読者

  • 非同期処理を含むコードのテストに課題を感じている方
  • より良いテストの書き方を模索している方

本記事で説明するサンプルコードは以下のリポジトリで公開しています。

なお私が所属するチームでは、Golangで書いたアプリケーションの単体テストのため、gomockを利用してモックを生成しています。
今回紹介するサンプルコードにおいてもgomockを利用しておりますので、ご承知おきください。

非同期処理を含むテスト対象のコード

Golangでは、ゴルーチンにより簡単に非同期処理を実現することができます。
例として、リクエスト受けて非同期でCSVファイルを生成する処理を今回は用意してみました。

func (i *CSVInteractorImpl) RequestCsvGenerate(ctx *gin.Context) error {
    copyCtx := ctx.Copy()
    // 非同期でCSV生成処理を行う
    go func() {
        heavyComputation()
        products := i.repo.List(copyCtx)
        if err := i.presenter.OutputCSV(products); err != nil {
            log.Printf("Error outputting CSV: %v", err)
            return
        }
        log.Printf("CSV generation completed successfully")
    }()

    return nil
}

heavyComputationはなにか重い処理をシミュレートした関数と思ってください。

この非同期処理では、重い計算処理を実行した後、リポジトリからデータを取得し、CSVファイルとして出力します。
ゴルーチンを使用することで、メインの処理をブロックすることなく早期にリターンされ、バックグラウンドでCSV生成を行うことができます。

今回はこの処理に対して単体テストを書いていきたいと思います。

非同期処理のテストにおける課題

ベースとなるテストを書いてみましょう。

func TestRequestCsvGenerate(t *testing.T) {
    // Arrange
    // モックコントローラーの作成
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // モックの作成
    mockProductRepo := mockRepository.NewMockProductRepository(ctrl)
    mockCsvPresenter := mockPresenter.NewMockCSVPresenter(ctrl)

    // モックの設定
    products := []model.Product{
        {Name: "商品A", Price: "1000", Stock: "50"},
    }
    mockProductRepo.EXPECT().List(gomock.Any()).Return(products)
    mockCsvPresenter.EXPECT().OutputCSV(products).Return(nil)

    // テスト対象のインスタンス作成
    interactor := NewCSVInteractor(mockProductRepo, mockCsvPresenter)

    // テスト用のContext作成
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)

    // Act
    err := interactor.RequestCsvGenerate(c)

    // Assert
    if err != nil {
        t.Errorf("期待値: nil, 実際の値: %v", err)
    }
}

このテストは、失敗します。

=== RUN   TestRequestCsvGenerate
    controller.go:251: missing call(s) to *mock_repository.MockProductRepository.List(is anything) /go-async-sample/internal/usecase/interactor/csv_interactor_test.go:35
    controller.go:251: missing call(s) to *mock_presenter.MockCSVPresenter.OutputCSV(is equal to [{商品A 1000 50}] ([]model.Product)) /go-async-sample/internal/usecase/interactor/csv_interactor_test.go:36
    controller.go:251: aborting test due to missing call(s)
--- FAIL: TestRequestCsvGenerate (0.00s)
FAIL

gomockの仕様により、「呼ばれるべき(EXPECT)」と定義したモックが呼び出されなかったためです。
非同期処理としてheavyComputationで重い処理をシミュレートしているため、モック呼び出し処理が実行される前にテストがFinish()してしまいます。

このように、非同期処理を含むコードのテストを作成する際、以下のような課題があります。

  • 親関数のRequestCsvGenerateはゴルーチンを起動した後すぐにreturnするため、非同期処理の完了を待つことができない
  • そのため、非同期処理内で呼び出されるrepo.Listpresenter.OutputCSVが実行されたことを単純に検証できない
  • テストコードの実行が非同期処理の完了を待たずに終了してしまう可能性がある

※本来であれば、テスト容易性を考慮して非同期処理の完了を通知する仕組みや、処理のステータスを管理する仕組みを実装すべきかもしれませんが、今回は既存コードのテスト方法に焦点を当てます。

このような課題を解決するためのテストコードとして、今回はいくつかのパターンを考えて見ました。

非同期処理の完了を待つテストのパターン

非同期処理のテストにおいて、処理の完了を待つ方法はいくつか考えられます。
ここでは3つのパターンを紹介し、それぞれのメリット・デメリットを確認していきます。

1.一定時間Sleepして完了を待機する

func TestRequestCsvGenerate(t *testing.T) {
    // 〜 略 〜

    // Act
    err := interactor.RequestCsvGenerate(c)

    // 非同期処理の完了を待つ 指定時間以内に終わる担保はどこにもなくFlakyになる可能性がある。過剰待機になる可能性もありCI実行時間の無駄も発生。
>    time.Sleep(8 * time.Second)

    // Assert
    if err != nil {
        t.Errorf("期待値: nil, 実際の値: %v", err)
    }
}
実行結果
=== RUN   TestRequestCsvGenerate
2025/03/20 16:19:22 heavyComputationの処理時間: 3.216627959s
2025/03/20 16:19:22 CSV generation completed successfully
--- PASS: TestRequestCsvGenerate (8.00s)

最も簡単な検証方法かと思います。開発の余裕がないとき安易に手を出しがちですが、この方法には以下のような重大な問題があります。

  1. テストの信頼性が低い (Flaky Test)

    • 実行環境や負荷によって処理速度が変わるため、同じテストでも時と場合によって結果が変わってしまう
    • CI/CDパイプラインで突然テストが失敗する原因となりやすい
    • 特にクラウド環境での実行時は、インスタンスのスペックによって大きく挙動が変わる可能性がある
  2. テスト実行時間の無駄

    • 必要以上に長い待機時間を設定してしまいがち
    • テストケースが増えるほど、無駄な待機時間も積み重なっていく
    • CI/CDのコスト増加にもつながる

このように、time.Sleep()を使用した待機は、一時的な対応としては簡単ですが、長期的な保守性や信頼性を考えると避けるべき方法といえます。

2.通知チャネルを使って完了を待機する

func TestRequestCsvGenerateWithChannel(t *testing.T) {
    // Arrange
>    done := make(chan struct{})

    // 〜 略 〜

    mockProductRepo.EXPECT().List(gomock.Any()).Return(products)
    mockCsvPresenter.EXPECT().OutputCSV(products).Return(nil).DoAndReturn(
        func(products []model.Product) error {
>           defer func() { done <- struct{}{} }() // モックが正しく呼び出されたらdoneチャネルに通知
            return nil
        })

    interactor := NewCSVInteractor(mockProductRepo, mockCsvPresenter)

    // 〜 略 〜

    // Assert
    if err != nil {
        t.Errorf("期待値: nil, 実際の値: %v", err)
    }

>    <-done // 非同期処理の完了を待つ
}
実行結果
=== RUN   TestRequestCsvGenerateWithChannel
2025/03/20 16:19:33 heavyComputationの処理時間: 3.084197625s
2025/03/20 16:19:33 CSV generation completed successfully
--- PASS: TestRequestCsvGenerateWithChannel (3.08s)

通知チャネルを使用する方法は、time.Sleep()と比べてより信頼性の高いテストを実現できます。

具体的な仕組みは以下の通りです:

  1. done := make(chan struct{})で空構造体を送受信するチャネルを作成
  2. モック内のDoAndReturnで、処理完了時にdoneチャネルへ送信
  3. テストコード側で<-doneを使って、チャネルからの受信を待機

この方法を採用することにより、Sleepと比較すると以下の問題を解決することができます。

  • 処理完了を正確に検知できるため、テストの信頼性が向上
  • 必要以上の待機時間が発生しないため、テスト実行が効率的
  • 環境に依存せず安定したテスト結果が得られる

一方、デメリット、とまでは言えませんが、テスト内で余計なチャンネル管理が増えてしまうため、多少テストの複雑性が増してはしまいます。

3.WaitGroupで完了を待機する

func TestRequestCsvGenerateWithWaitingGroup(t *testing.T) {
    // Arrange
>    var wg sync.WaitGroup
>    wg.Add(1)

    // 〜 略 〜

mockProductRepo.EXPECT().List(gomock.Any()).Return(products)
    mockCsvPresenter.EXPECT().OutputCSV(products).Return(nil).DoAndReturn(
        func(products []model.Product) error {
>            wg.Done() // モックが正しく呼び出されたらWaitGroupを減らす
            return nil
        })

    // 〜 略 〜

    // Assert
    if err != nil {
        t.Errorf("期待値: nil, 実際の値: %v", err)
    }

>    wg.Wait() // 非同期処理実行によりWaitGroupが0になるまで待機
}
実行結果
=== RUN   TestRequestCsvGenerateWithWaitingGroup
2025/03/20 16:19:36 heavyComputationの処理時間: 3.008524917s
2025/03/20 16:19:36 CSV generation completed successfully
--- PASS: TestRequestCsvGenerateWithWaitingGroup (3.01s)

大まか作りは通知チャネルを使ったやり方と同じです。
WaitGroupを作成し、モックが期待通りに呼ばれた段階でWaitGroupを0にすることで待機を解除します。
この方法のメリットは通知チャネルを使ったケースと同等と言えるでしょう。
追加のメリットとしては、WaitGroupであれば1つのグループで複数のゴルーチンの完了を待機できるため、より効率的にテストを書くことができる可能性があります。

デメリットに関しても通知チャネルのパターン同様、多少テストの複雑性が増してはしまいます。

synctestの紹介

Go 1.24から実験的な機能としてsynctestパッケージが導入されました。
このパッケージは、非同期処理のテストをより簡単に、より信頼性高く実装するためのツールを提供しています。
まだ実験的な機能であるものの、本実装されれば非同期/並行処理のテストコード作成が大いに楽になりそうだったため、調べてみました。

synctestの主な機能

https://github.com/golang/go/issues/67434

proposalにおいて、synctestパッケージには以下2つの主な機能があると記載されています。

It permits using a fake clock to test code which uses timers. The test can control the passage of time as observed by the code under test.
It permits a test to wait until an asynchronous operation has completed.

(翻訳)
タイマーを使用するコードをテストするために、fake clockを使用することができる。テストは、テスト対象のコード下で観測される時間の経過を制御できる。
非同期処理が完了するまでテストを待機させることができる。

2つ目の記載、ドンピシャですね。
godocも参照して更に見ていきましょう。

https://pkg.go.dev/testing/synctest@go1.24.1

synctestパッケージには、RunWaitの2つのAPIが用意されています。

Run関数

synctest.Run()は、以下の機能を提供します。

  • テストコードを「バブル」と呼ばれる分離された環境内で実行
  • バブル内ではfake clockが利用され、時間の進み方を制御可能
  • バブル内のすべてのゴルーチンがアイドル状態になる(ブロックされる)ことで時間が進む
  • バブル内で起動されたゴルーチンを追跡し、グループとして管理

イメージを掴むために簡単なテストを実行してみます。

func TestNow(t *testing.T) {
    synctest.Run(func() {
        // 初期時刻は2000-01-01 00:00:00 UTC
        t.Logf("初期時刻: %v", time.Now())

        // 100秒間スリープ
        time.Sleep(100 * time.Second)

        t.Logf("スリープ後の時間: %v", time.Now())
    })
}

もしsynctestを使っていなければ、100秒かかるテストになっています。
実行結果はどうでしょう。

実行結果
test $ go test -v
=== RUN   TestNow
    synctest_example_test.go:13: 初期時刻: 2000-01-01 09:00:00 +0900 JST m=+946108335.561623210
    synctest_example_test.go:18: スリープ後の時間: 2000-01-01 09:01:40 +0900 JST m=+946108435.561623210
--- PASS: TestNow (0.00s)
PASS
ok      github.com/htk-donuts/go-async-sample/test    0.186s

0.168sで終了しました。出力ログを見ると一目瞭然で、fake clockの時間が100秒進んでいることがわかります。
バブル内では、全てのゴルーチンがアイドル状態(ブロック状態)になると時間が進みます。
この例では、time.Sleepによってゴルーチンがアイドル状態になったため、fake clock上100秒を瞬時に経過させ、次の処理に進みます。
(「アイドル状態」については、後述のWaitの説明にて触れます。)

Wait関数

synctest.Wait()は、以下の機能を提供します。

  • バブル内の全てのゴルーチンがアイドル状態になるまで待機
    • アイドル状態とは以下を指す
      • チャネル操作でブロックされている
      • ミューテックス操作でブロックされている
      • time.Sleepで待機中
      • selectで待機中
    • I/O操作でブロックされているゴルーチンはアイドルとみなされない

こちらを書きながらもムノーな私にはまだピンとこないので具体的な使用例を見てみましょう。

func TestWait(t *testing.T) {
    synctest.Run(func() {
        done := false
        go func() {
            sum := 0
            for i := 0; i < 100000000; i++ {
                sum += i * i
            }
            done = true
        }()
        synctest.Wait()
        if !done {
            t.Fatalf("done = false, want true")
        }
    })
}

このテストは正常終了し、synctest.Wait()が非同期処理の完了を待つことが確認できました。
もしsynctestを導入していなければ、新たに作ったゴルーチン内の処理を待たずに進むため、エラーになるでしょう。

Wait関数の挙動を深掘る

前節のWaitの検証コードではゴルーチンによる非同期処理内で実行する多少重い処理をforループの加算にしました。よくやるtime.Sleepによるシミュレートではありません。
もしtime.Sleepによるシミュレートに変えたら、このテストは失敗します。

// 失敗するテスト
func TestWait(t *testing.T) {
    synctest.Run(func() {
        done := false
        go func() {
-            sum := 0
-            for i := 0; i < 100000000; i++ {
-                sum += i * i
-            }
+            time.Sleep(100 * time.Second)
            done = true
        }()
        synctest.Wait()
        if !done {
            t.Fatalf("done = false, want true")
        }
    })
}

なぜでしょう?

synctest.Wait()の重要な特徴を振り返ってみましょう。
この関数は「バブル内で実行中の全てのゴルーチンがブロックされる(アイドル状態になる)まで待機する」と定義されています。

ここでのポイントは「アイドル状態」の定義です。
アイドル状態とは、チャネル操作でのブロック、ミューテックス操作でのブロック 、time.Sleepなどのタイマー待ちなどの状態を指していました。

つまり、ゴルーチン内でtime.Sleepを使用すると、そのゴルーチンは即座に「アイドル状態」となります。
その結果

  1. synctest.Wait()は「全てのゴルーチンがアイドル状態」という条件を満たしたと判断
  2. 待機を解除して先に進んでしまう
  3. 非同期処理の完了を正しく待てない

という流れになるわけです。


一方、forループによる負荷シミュレートを行ったゴルーチンの場合はどうでしょうか?
こちらは単純なCPU処理であり、「アイドル状態」には該当しません。なのになぜsynctest.Wait()はゴルーチンの完了を待つのでしょうか。
これに関しては、おそらくgodoc等において記載が十分ではないと考えられます。
厳密には、synctest.Wait()は、以下のいずれかの条件を満たすまで待機するようです。

  1. バブル内の全てのゴルーチンがアイドル状態になる
  2. バブル内の全てのゴルーチンが完了する

これはGoのproposalにもこっそり記載があります。

The synctest.Wait ensures that all background ゴルーチンs have idled or exited before the test proceeds.

また、Goのソースコードにおいても、以下の箇所がアクティブなゴルーチン有無をチェックしていることが確認できます。
https://github.com/golang/go/blob/57dac327d15b4debe33057b9ca785e303731e81c/src/runtime/synctest.go#L121-L125

このWait内の実装において、以下いずれかの状態のときにnilを返すことで待機を継続しています。

  • sg.running > 0 - 現在実行中(非アイドル状態)のゴルーチンが存在する
  • sg.active > 0 - チャネル操作、I/O操作、タイマー待ちなどのアクティビティが存在する

したがって、forループによる負荷をシミュレートしたケースにおいては、

  1. synctest.Wait()到達時点で、forループ処理をするゴルーチンは「実行中」であり、非アイドル状態
  2. そのため、synctest.Wait()において、監視している全てのゴルーチンがアイドル状態になるまで待機
  3. ゴルーチンが完了すると、バブル内では監視対象である実行中のゴルーチンがなくなるため、待機が解除される

という、非同期処理完了を待つ望ましい動作を実現することができるのだと思われます。

synctestを適用してみる

前述の非同期処理のテストパターンを、synctestを使って書いてみましょう。
前述のパターンでは、以下のような問題や気になりがありました。

  1. time.Sleepを使う方法
    • テストの信頼性が低い
    • テスト実行時間の無駄が発生
  2. 通知チャネルを使う方法
  3. WaitGroupを使う方法
    • 実装が若干複雑
    • テストのために余計なコードが必要

synctestを使えば、これらを全て解決できます。

func TestRequestCsvGenerateWithSyncTest(t *testing.T) {
>    synctest.Run(func() {
        // Arrange
        ctrl := gomock.NewController(t)
        defer ctrl.Finish()

        mockProductRepo := mockRepository.NewMockProductRepository(ctrl)
        mockCsvPresenter := mockPresenter.NewMockCSVPresenter(ctrl)

        products := []model.Product{
            {Name: "商品A", Price: "1000", Stock: "50"},
        }
        mockProductRepo.EXPECT().List(gomock.Any()).Return(products)
        mockCsvPresenter.EXPECT().OutputCSV(products).Return(nil)

        interactor := NewCSVInteractor(mockProductRepo, mockCsvPresenter)

        gin.SetMode(gin.TestMode)
        w := httptest.NewRecorder()
        c, _ := gin.CreateTestContext(w)

        // Act
        err := interactor.RequestCsvGenerate(c)

        // Assert
>        // `RequestCsvGenerate()`内の非同期処理がアイドル状態ではない(実行中)のため待機
>        // -> 非同期処理が完了すると、監視対象のゴルーチンがなくなるため待機解除
>        synctest.Wait()

        if err != nil {
            t.Errorf("期待値: nil, 実際の値: %v", err)
        }
    })
}

元のテストに対してRun()を適用し、アサートの前にWait()を追加するだけです。

実行結果
=== RUN   TestRequestCsvGenerateWithSyncTest
2000/01/01 09:00:00 heavyComputationの処理時間: 0s
2000/01/01 09:00:00 CSV generation completed successfully
--- PASS: TestRequestCsvGenerateWithSyncTest (3.01s)

テストが無事パスすることが確認できました。
ログから時間はfake cloak のデフォルト値が使われていることがわかりますね。注意しましょう。

まとめ

本記事では、非同期処理を含むコードのテストにおける課題と、その解決方法について紹介しました。
非同期処理のテストは難しいものですが、適切なツールと方法を選択することで、より確実で効率的なテストを実現できます。
Go1.24のsynctestは、その選択肢の一つとして期待が高まります。
正式な採用を願い、今後も非同期処理のテストについて知見を深めていきたいと思います!

Discussion

ログインするとコメントできます