Zenn
💯

Go 1.24の新機能testing.T.Context() がやってきたから徹底解説する!

2025/03/11に公開
10

前置き

どうもtakemaruです!
最近やっと暖かくなってきましたね!2月まではジムまでのチャリンコの時間が寒すぎて、ちょこちょこサボってしまいました🥲

...余談はさておき

先月Go1.24がリリースされました🥳🥳

私、少しでもキャッチアップしたいとの思いで,
先日行われたGo 1.24 リリースパーティに参加してきました!
オンライン参加ではありましたが、Xのポストの様子からとても盛り上がっていることが見受けられました!どのサッションも内容がとても良かった!

リリースパーティの最中、CIARANAさんのセッション中、Xで下記のポストをしました

そしたらありがたいことに、Go界隈で有名なtenntennさんから

とコメントをいただきました!感謝です🙏🙏(このような細かいポストを拾ってくれるの本当にありがたいです☺️)

.
.
.

キャンセル処理がデフォルトでされるから…
いつ…
どこで…
どう使えば良いのかにゃん?😺

私、恥ずかしながら、コンテキストのキャンセルをテスト中に気にしたことがなかったのです😱😱

ということで、本日はGo 1.24で新たに追加されたtesting.T.Context()を徹底解説していきます😤

testing.T.Context()とは?

導入背景についてはtesting.T.Context()のproposal
testing: reconsider adding Context method to testing.T
のディスカッションを読んで解釈した内容となっています。(英語には自信がないので誤った表現などがあれば優しく教えてください)

要約

  • Go 1.24でtesting.Context()(以下、単に「t.Context」)を追加する提案が行われ、最終的に受け入れられた。
  • これはテスト内で使用できるcontext.Contextを提供し、テストが終わる直前にそのContextをキャンセルすることで、テストで起動したゴルーチンなどをクリーンに終了させる仕組みを提供するAPIである。
  • 同様にベンチマーク(testing.B)やファズ(testing.F)向けにもContext()メソッドが追加された。
    • testing.TBインタフェースに導入されている

導入の目的

  • テストが終了するタイミングでContextをキャンセルしたい。
    • テストの終了時にContextがキャンセルされることで、バックグラウンドで動作しているゴルーチンが早期にリソースを解放できる。
  • Go 1.14で導入されたT.Cleanupにより、テスト終了時にクリーンアップ関数を登録して呼び出す仕組みができた。これを組み合わせれば「Contextのキャンセル → Cleanupで同期してゴルーチン終了を待つ」というフローが実現。

導入への課題(導入への鍵はT.Cleanup)

  • 過去に同種の提案(#16221など)があったが、**「Contextだけあっても、その終了を待ち合わせる仕組みが標準テストパッケージ内にない」**という問題から一度はリバート(取り消し)された歴史がある。
    • Contextをキャンセルしてもゴルーチンが本当に終了したかどうかを標準テスト側で待てないと意味がない、という意見があった。
    • すでにT.Cleanupが存在するので「Contextによる終了シグナル」と「終了待ち(Wait)」の役割分担が可能である。
      • テスト終了時にはContextをキャンセルし、その後T.Cleanup登録された処理の中でWaitGroup等を使って確実にゴルーチンが終わるまで待てる
  • 「テスト失敗で即座にキャンセル」か「テスト完了時にまとめてキャンセル」か?
    • 一部の意見として「テストに失敗したタイミング(FailNowなど)でContextをキャンセルすべきではないか」という議論もあった。
    • もし「失敗したらすぐキャンセルする」テストを書きたければ、ユーザが独自にWithCancel(t.Context())を包んでFail時に独自キャンセルを呼ぶ方法が用意できるため、標準のT.Contextはあくまで「テスト完了時キャンセル」で十分だという結論が得られている。
    • 「失敗が起きてもテスト内の残タスクや後片付けをするケースがある」「あるいは失敗時にすべてのゴルーチンを即時終了させると混乱が生じる」といった事情から、最終的には「テスト完了の直前にキャンセルする」という方針に落ち着いた。
  • APIが本当に必要か?
    • 単に以下のようなヘルパー関数を自分で定義するだけでも同等のことは実装可能(参照
      func testContext(t *testing.T) context.Context {
          ctx, cancel := context.WithCancel(context.Background())
          defer cancel()
          return ctx
      }
      
    • テストでContextを使うケースが増え続けている現状、これを標準のテストパッケージでサポートする意義は大きいと判断(参照
  • テスト中に途中でキャンセルしたい場合はどうするのか?
    • 必要なら各テストでWithCancel(T.Context())をネストするという使い方を案内すればよい、という方針。

実際のコードを読んで理解する

ここからは、実際にテストが実行されている際の挙動をtestingパッケージのコードを読みながら理解していきましょう!(冗長な箇所は省略しております。)

  1. T.Run 内で context.WithCancel(context.Background()) を作成
func (t *T) Run(name string, f func(t *T)) bool {
    // ...
    ctx, cancelCtx := context.WithCancel(context.Background())
    t = &T{
        // ...
        ctx:       ctx,
        cancelCtx: cancelCtx,
    }
    // ...
    go tRunner(t, f)
    return !t.failed
}
  • サブテスト用に新しい T が作られ、そこに ctx & cancelCtx がセットされます。
  • 親テストの Context を引き継ぐのではなく、毎回 context.Background() から始めています。
  1. tRunner でテスト本体を走らせる
func tRunner(t *T, fn func(t *T)) {
    defer func() {
        // テスト終了時にいろいろ後処理
        // ここでは直接 runCleanup は呼ばず、さらに defer が重なる
    }()
    defer func() {
        if len(t.sub) == 0 {
            t.runCleanup(normalPanic)
        }
    }()
    fn(t) // ユーザーのテスト関数
}
  • ここでユーザが定義した fn func(t *T) で t.Context() を呼び出すと、その ctx が取得できる
  • テストが終わると最終的に runCleanup へ。
  1. runCleanup で c.cancelCtx() → Cleanup 実行
func (c *common) runCleanup(ph panicHandling) (panicVal any) {
    c.cleanupStarted.Store(true)
    defer c.cleanupStarted.Store(false)

    if c.cancelCtx != nil {
        c.cancelCtx() // ここでキャンセル!
    }
    // Cleanup 登録された関数を逆順で呼ぶ
    for {
        // ...
        cleanup()
    }
}

  • テスト終了直前に cancelCtx() を呼び出す
  • 続いて、ユーザが t.Cleanup(...) で登録した処理を逆順(LIFO)に実行する。
  • つまり、キャンセル → Cleanup 関数で wg.Wait() のような終了待ちができる

これで、「テスト完了時に Context をキャンセルし、Cleanup によって安全にゴルーチンを止める」 流れが標準でサポートされる、というわけです✨

実際に使ってみる

では実際に、ゴルーチンを起動する「擬似的な業務用ワーカー」を定義し、そのテストを3種類用意してみましょう!

以下のファイルを worker_test.go などとして保存して、go test -v で動かせます。(Go 1.24 でコンパイルできる環境を想定しています)

package worker_test

import (
	"context"
	"fmt"
	"runtime"
	"sync"
	"testing"
	"time"
)

// Worker は業務で使われるような「バックグラウンドで何か処理をする」ゴルーチンを持つ例。
type Worker struct {
	id    int
	tasks chan string
	wg    sync.WaitGroup
}

func NewWorker(id int) *Worker {
	return &Worker{
		id:    id,
		tasks: make(chan string),
	}
}

// Start はtasksチャネルからタスクを取り出して処理するゴルーチンを立ち上げるワーカーを起動する
func (w *Worker) Start(ctx context.Context) {
	w.wg.Add(1)
	go func() {
		defer w.wg.Done()
		for {
			select {
			case <-ctx.Done():
				fmt.Println("Worker: context canceled, shutting down")
				return
			case task := <-w.tasks:
				// 実際にはチャネルがクローズされたかどうかの判定が必要
				fmt.Println("Worker: processing task:", task)
			default:
				time.Sleep(100 * time.Millisecond)
				fmt.Printf("Worker%d: waiting for tasks\n", w.id)
			}
		}
	}()
}

// Submit はタスクをワーカーに送る
func (w *Worker) Submit(task string) {
	w.tasks <- task
}

// ---- ここから下はテスト ---- //

// 1. context.Background() を使ってゴルーチンがリークする例
//    テストが終了してもワーカーのゴルーチンにはキャンセルが届かず、動き続けてしまう。
func TestWorkerBackground(t *testing.T) {
	before := runtime.NumGoroutine()
	w := NewWorker(1)
	// ここではBackground()をそのまま渡す
	ctx := context.Background()
	w.Start(ctx)

	// タスクを一つ投げる
	w.Submit("Task1")
	time.Sleep(200 * time.Millisecond) // ちょっと待つ

	// Testはこれで終了するが、wのゴルーチンはまだキャンセルされず待機している(closeもキャンセルもしていないため)
	// => 結果的にリーク(テスト完了後も goroutine が残る)
	t.Cleanup(func() {
		after := runtime.NumGoroutine()
		// このテストが終わった後もゴルーチンが残っているか確認
		t.Logf("NumGoroutine before=%d, after=%d", before, after)
	})
}

// 2. context.WithCancel(context.Background())で明示的に cancel() & Wait する例
func TestWorkerWithManualCancel(t *testing.T) {
	before := runtime.NumGoroutine()
	w := NewWorker(2)

	// 手動でキャンセル可能コンテキストを作り、defer で確実にキャンセル
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	w.Start(ctx)
	w.Submit("Task2")
	time.Sleep(500 * time.Millisecond) // Task1のゴルーチンがリークしていないか確認

	// テスト終了時にdefer cancel() が呼ばれてコンテキストがキャンセルされる
	// t.Cleanupに指定したコールバック関数は、テストが終了するときに呼び出される(defer cancel()のほうが先)
	t.Cleanup(func() {
		// wg.Wait()をここで実行すると、tasksチャネルが閉じられる前に<-tasksでブロックされパニックする
		// そのため、time.Sleep()で待つか、tasksチャネルを閉じる必要がある
		//time.Sleep(200 * time.Millisecond)
		after := runtime.NumGoroutine()
		// このテストが終わった後もゴルーチンが残っているか確認(time.Sleep()で待たないとゴルーチンがテスト後も残ってしまうが時間差ですぐに閉じる?)
		t.Logf("NumGoroutine before=%d, after=%d", before, after)
	})
}

// 3. testing.T.Context() を使用する例
func TestWorkerWithTContext(t *testing.T) {
	before := runtime.NumGoroutine()
	w := NewWorker(3)

	// t.Context() は、テストが終わるタイミングで自動的にキャンセルされる(t.Cleanup内の処理が実行される直前にキャンセルされる)
	ctx := t.Context()
	w.Start(ctx)

	w.Submit("Task3")
	time.Sleep(500 * time.Millisecond) // Task2のゴルーチンがリークしていないか確認

	t.Cleanup(func() {
		// t.Context() がキャンセルされると Start()内のselectが抜けてwg.Done()される
		w.wg.Wait()
		after := runtime.NumGoroutine()
		t.Logf("NumGoroutine before=%d, after=%d", before, after)
	})
}

func TestWaiting(t *testing.T) {
	time.Sleep(500 * time.Millisecond) // Test3のゴルーチンがリークしていないか確認
}

ポイント解説

各テストについてゴルーチンリークがわかりやすいようにテストの最初と最後にruntime.NumGoroutine()の数値を保持して比較します。

before := runtime.NumGoroutine()
// テスト処理
after := runtime.NumGoroutine()
t.Logf("NumGoroutine before=%d, after=%d", before, after)
  1. TestWorkerLeakBackground

    • context.Background() をそのまま渡しているので、テスト終了時にキャンセルが走りません。
    • Worker はずっと ctx.Done() を待つため、テスト関数が終わってもゴルーチンが残る。
      • Workerのログが後続のテスト時にも出力されてしまう…
    • ログでは “NumGoroutine before=○, after=○+α” となる可能性が高く、リークしていることが分かります。
  2. TestWorkerNoLeakManualCancel

    • context.WithCancel(...) を使い、テスト終了時 (t.Cleanup) で手動で cancel() を呼んでいます。
    • これによりゴルーチンはキャンセルされます。ただ、この状態でwg.Wait()を実行すると、tasksチャネルが閉じられる前に<-tasksがブロックされパニックが発生するためここでは呼んでいません。そのため、ゴルーチンの停止を待たずに後続のテストに進んでしまいます。
      • cancel()はされますが後続でログが出力されてしまうかも…
      • チャネルをcloseする仕組みやtime.Sleep()で待てばゴルーチンの停止を待つことも可能。
  3. TestWorkerNoLeakWithTContext

    • ctx := t.Context() を呼ぶだけで、テスト完了時にキャンセルが自動で行われます。
    • t.Cleanup では wg.Wait() を呼ぶだけでOK(チャネルをcloseして、wg.Wait())。
    • コード量も削減され、漏れにくい設計に。

実行してみよう!
端末で go test -v ./worker_test.go(あるいは適当なパッケージ名)を実行すると、各テストの挙動が分かります。

  • TestWorkerBackground

    === RUN   TestWorkerBackground
    Worker: processing task: Task1
    Worker1: waiting for tasks
    Worker1: waiting for tasks
        worker_test.go:76: NumGoroutine before=2, after=3
    --- PASS: TestWorkerBackground (0.20s)
    

    表示は「PASS」になっていても、実はゴルーチンがひとつ増えて残っています。すぐには大きな問題にはならないかもしれませんが、テストを大量に走らせるとゴルーチンが積もり積もって不具合を起こす恐れが…!

  • TestWorkerWithManualCancel

    === RUN   TestWorkerWithManualCancel
    Worker: processing task: Task2
    Worker1: waiting for tasks
    Worker2: waiting for tasks
    Worker2: waiting for tasks
    Worker1: waiting for tasks
    Worker2: waiting for tasks
    Worker1: waiting for tasks
    Worker1: waiting for tasks
    Worker2: waiting for tasks
        worker_test.go:101: NumGoroutine before=3, after=4
    --- PASS: TestWorkerWithManualCancel (0.50s)
    

    先ほどのテストでリークしたゴルーチンのWorker1: waiting for tasksという出力が残っていることがわかります。
     また、NumGoroutineを確認すると先ほど同様こちらもテスト終了時ゴルーチンがひとつ増えて残っています。(もしやリークしている?)

  • TestWorkerWithTContext

    === RUN   TestWorkerWithTContext
    Worker: processing task: Task3
    Worker2: waiting for tasks
    Worker: context canceled, shutting down
    Worker1: waiting for tasks
    Worker3: waiting for tasks
    Worker1: waiting for tasks
    Worker3: waiting for tasks
    Worker1: waiting for tasks
    Worker3: waiting for tasks
    Worker1: waiting for tasks
    Worker3: waiting for tasks
    Worker1: waiting for tasks
    Worker1: waiting for tasks
    Worker3: waiting for tasks
    Worker: context canceled, shutting down
        worker_test.go:121: NumGoroutine before=4, after=3
    --- PASS: TestWorkerWithTContext (0.50s)
    

    Worker2: waiting for tasksが表示されましたがその後は出力されないよう(キャンセルはされていたが)
    NumGoroutineは減っているように見えますが、これはスタートしていた時にまだ存在していたtest2のゴルーチンが途中終了したからですね!ということは成功?!

  • TestWaiting

      === RUN   TestWaiting
      Worker1: waiting for tasks
      Worker1: waiting for tasks
      Worker1: waiting for tasks
      Worker1: waiting for tasks
      --- PASS: TestWaiting (0.50s)
      Worker1: waiting for tasks
      PASS
    

    ログの出力はリークしていたtest1のみでtest3はリークしていないことが確認できました!

まとめ

  • t.Context の目的

    • テスト終了直前で Context をキャンセルし、ゴルーチンやリソースのリークを防ぐための仕組み。
    • T.Cleanup と組み合わせることでキャンセル後の終了待ちを一括管理できるように。
  • 過去の課題と採用の経緯

    • 以前は「ゴルーチンの終了待ちができない!」と却下されていたが、T.Cleanup で待ちやすくなり再提案 → Go 1.24 で晴れて採用。
  • 失敗時の即キャンセル vs. テスト完了時

    • 標準では「テスト完了時キャンセル」になった。
    • 「失敗した瞬間に止める」必要があれば、ユーザが WithCancel(t.Context()) でネストする方法が用意されている。
  • 実際に使うメリット

    • 手動で cancel() を書き忘れたり、リークを出したりするリスクが大幅低減。
    • コード量もスッキリ。

もし今テストで「context.Background() 使いまくってるんだけど…」という方は、ぜひ Go 1.24 にアップグレードしたうえで t.Context() を活用してみてください。
そして、さようならゴルーチンリーク。こんにちは安全安心のテストライフ!!

10

Discussion

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