【Go言語】TDD実践入門!「止められないgoroutine」を安全に改善する方法
😱「コードを直したら、別の場所が壊れた…」そんな経験ありませんか?
プログラミングを学んでいると、誰もが一度は経験するこの問題。特に、バックグラウンドで何かを処理する「goroutine」のような機能は、一歩間違えると「止められないプログラム」を生み出し、メモリリークなどの深刻なバグの原因になります。
「止められないgoroutine」問題を、TDD(テスト駆動開発)を使って、安全に、そして楽しく解決していく過程を説明します。
🚨 問題発見!このコード、何が危険なの?
私たちのパフォーマンス監視システムに、こんなコードがありました。
// ❌ 問題のあるコード
func NewEnhancedPerformanceMonitor(...) *EnhancedPerformanceMonitor {
// 内部で勝手に「終わらない」Contextを作っている
ctx, cancel := context.WithCancel(context.Background())
monitor := &EnhancedPerformanceMonitor{
ctx: ctx,
cancel: cancel,
// ...
}
// バックグラウンドで、ずっと動き続けるgoroutineを開始
go monitor.processAlerts()
return monitor
}
一体何が問題なの?
問題1:プログラムが止められない! (メモリリーク)
このNewEnhancedPerformanceMonitorを、AWS Lambdaのような「実行時間が決まっている」環境で使うと、大変なことが起きます。
// Lambda関数で使った場合
func lambdaHandler(ctx context.Context, event Event) error {
// モニターを開始!
monitor := NewEnhancedPerformanceMonitor(...)
// 何かの処理をする
return processEvent(event)
} // ⬅️ Lambda関数はここで終了する。でも…
// monitor.processAlerts() のgoroutineは、誰にも止められず、
// サーバーの裏側で、永遠に動き続けてしまうのです!
// これが積み重なると、メモリを食いつぶす「メモリリーク」になります。
問題2:テストがちゃんと書けない!
テストコードでも同じ問題が起きます。
func TestSomething(t *testing.T) {
monitor := NewEnhancedPerformanceMonitor(...)
// テストが終わっても、monitorは裏で動き続ける…
// これが、他のテストに悪影響を与える可能性があります。
}
なぜこの問題が起きるの? Contextの誤解
この問題の根本原因は、Go言語のContext(コンテキスト)という仕組みの誤用です。
Contextとは?(超ざっくり解説)
Contextは、処理から処理へ「伝言ゲーム」のように情報(命令)を伝えるための道具です。一番大事な伝言が「もう終わりだよ!処理をやめて!」というキャンセル信号です。
正しい使い方 (親から子へ伝言)
// 親の処理
func ParentProcess(ctx context.Context) {
// 子の処理に「この伝言ゲームに参加してね」とContextを渡す
ChildProcess(ctx)
}
// 子の処理
func ChildProcess(ctx context.Context) {
for {
select {
case <-ctx.Done(): // 親から「やめて!」という伝言が来たら
fmt.Println("親が終了したので、私も終了します。")
return // 子も素直に処理をやめる
default:
// 通常の処理を続ける
}
}
}
間違った使い方 (勝手に伝言ゲームを始める)
// 問題のコードはコレ
func ChildProcess() {
// 親を無視して、自分で新しい伝言ゲームを始めてしまう
ctx := context.Background()
for {
select {
case <-ctx.Done(): // このキャンセル信号は、永遠に来ない!
return
default:
// 止まらない無限ループの完成…
}
}
}
問題のコードは、親(lambdaHandlerなど)からの「もう終わりだよ!」という伝言を無視して、自分だけの伝言ゲームを始めてしまったため、止められなくなっていたのです。
🎯 TDD(テスト駆動開発)で、安全に修理しよう!
TDDって何だっけ?
TDDは「テストを先に書いてから、コードを直す」開発スタイルです。
| 普通の開発(危険がいっぱい) | TDD(安全第一) |
|---|---|
| 1. とりあえずコードを変更 | 1. 「こう動いてほしい」をテストで書く |
| 2. 動かしてみる | 2. テストを実行(当然、失敗する 🔴) |
| 3. 案の定、壊れてる 😱 | 3. テストを通すためだけのコードを書く |
| 4. 慌てて直す | 4. テストが通る! ✅ (🟢) |
| 5. また別の場所が壊れる 😭 | 5. 安心してコードを綺麗にする (🔵) |
TDDを使えば、テストが「ガードマン」の役割をしてくれるので、私たちは安心してコードの改善に集中できます。
🔄 実践!Red-Green-Refactorサイクルを体験しよう
🔴 Step 1: Red Phase「まず、失敗するテストを書く」
目的: これから作る「理想の機能」が、今はまだ存在しないことをテストで証明します。
私たちの理想:
- 外部から
Contextを受け取れる新しい関数がほしい! - その
Contextがキャンセルされたら、goroutineもちゃんと止まってほしい!
書いてみよう!
// TDD Red Phase: TestContextControl
func TestContextControl(t *testing.T) {
// テスト1: 「外部からContextを受け取れる新しい関数がほしい!」
t.Run("外部Contextを受け取れるべき", func(t *testing.T) {
ctx := context.Background()
// ⚠️ まだ存在しない、理想の関数を呼んでみる
monitor := NewEnhancedPerformanceMonitorWithContext(ctx, ...)
assert.NotNil(t, monitor)
})
// テスト2: 「Contextがキャンセルされたら、ちゃんと停止してほしい!」
t.Run("Contextのキャンセルでgoroutineが停止するべき", func(t *testing.T) {
// キャンセル可能なContextを用意
ctx, cancel := context.WithCancel(context.Background())
monitor := NewEnhancedPerformanceMonitorWithContext(ctx, ...)
// 「やめて!」というキャンセル信号を送る
cancel()
// goroutineが止まるのを少し待つ
time.Sleep(100 * time.Millisecond)
// 💡 本当はここで「goroutineが本当に止まったか」を
// チェックする仕組みが必要ですが、今回はログ出力で確認します。
})
}
実行結果:期待通りの「コンパイルエラー」!
$ go test -v ./pkg/monitoring
# ...
undefined: NewEnhancedPerformanceMonitorWithContext
FAIL build failed
「そんな関数ないよ!」と怒られました。大成功です! これで、私たちが作るべきものがハッキリしました。
🟢 Step 2: Green Phase「テストを通すためだけの最小限のコードを書く」
目的: 先ほどのテストをパスさせるためだけの、一番シンプルなコードを書きます。綺麗さや効率は、まだ考えなくてOK!
書いてみよう!
1. 理想の新しい関数を作る
// ✅ 新しい関数:外部からContextを受け取れる!
func NewEnhancedPerformanceMonitorWithContext(
parentCtx context.Context, // 👈 親からContextを受け取る!
...
) *EnhancedPerformanceMonitor {
// 🔑 ポイント:受け取ったContextを親として、子Contextを作る
ctx, cancel := context.WithCancel(parentCtx)
monitor := &EnhancedPerformanceMonitor{
// ... 他の設定は同じ ...
// 新しいContextを設定
ctx: ctx, // 👈 親と連動するContext
cancel: cancel,
}
// このgoroutineは、親がキャンセルされると一緒に止まる!
go monitor.processAlerts()
return monitor
}
2. 古い関数も、新しい関数を呼び出すように修正(互換性のため)
// ✅ 古い関数:既存のコードを壊さないための配慮
func NewEnhancedPerformanceMonitor(...) *EnhancedPerformanceMonitor {
// 内部で、新しい関数をデフォルトのContextで呼び出すだけ
return NewEnhancedPerformanceMonitorWithContext(context.Background(), ...)
}
これで、古い関数を使っているコードも、今まで通り動き続けます。
実行結果:テストが「成功」!
$ go test -v ./pkg/monitoring
=== RUN TestContextControl
=== RUN TestContextControl/外部Contextを受け取れるべき
=== RUN TestContextControl/Contextのキャンセルでgoroutineが停止するべき
{"level":"INFO","message":"Shutting down alert processing"} // ⬅️ 注目!
--- PASS: TestContextControl (0.15s)
PASS ✅
やりました!テストが通りました!
ログに"Shutting down alert processing"と表示されています。これは、cancel()が呼ばれたことで、goroutineがちゃんと「終了します」というメッセージを残して停止した証拠です。問題は解決しました!
念のため、すべてのテストを実行
$ go test ./...
PASS ✅
既存のテストもすべてパスしました。他の機能を壊していないことが証明され、一安心です。
🔵 Step 3: Refactor Phase「安心して、コードを綺麗にする」
目的: テストというガードマンがいるので、安心してコードを読みやすく、保守しやすくします。
動くコードができたので、他の人(未来の自分も含む)が迷わないように、詳しいコメントやドキュメントを追加します。
// NewEnhancedPerformanceMonitorWithContext は、外部からのContext制御に対応した
// パフォーマンスモニターを作成します。
// AWS Lambdaのようなライフサイクルを持つ環境では、必ずこの関数を使用してください。
//
// 💡 使い方 (Lambdaでの例):
// func lambdaHandler(ctx context.Context, ...) (..., error) {
// // LambdaのContextを渡すことで、Lambda終了時にモニターも自動で停止します
// monitor := NewEnhancedPerformanceMonitorWithContext(ctx, ...)
// defer monitor.Shutdown() // 念のためクリーンアップ
// ...
// }
func NewEnhancedPerformanceMonitorWithContext(
parentCtx context.Context,
...
) *EnhancedPerformanceMonitor {
// ... 実装 ...
}
良いドキュメントは、将来のバグを防ぐ最高の投資です。
🎉 完成!どう変わった?改善結果まとめ
✅ 解決された問題が一目瞭然!
| 問題点 | Before (改善前) | After (改善後) |
|---|---|---|
| プログラムの停止 | ❌ 止められない | ✅ 親と一緒に止まる |
| テストのしやすさ | ❌ 困難 | ✅ 簡単で安全 |
| リソースリーク | 🔥 発生する危険大 | ✨ クリーンに終了 |
💡 実際のLambda関数での使い方
改善後は、こんなにシンプルで安全に使えます。
func lambdaHandler(ctx context.Context, request ...) (..., error) {
// 🔑 ポイント:Lambdaから渡される`ctx`をそのまま渡すだけ!
monitor := monitoring.NewEnhancedPerformanceMonitorWithContext(
ctx, // 👈 これでLambdaとモニターの運命は一心同体!
...
)
// ... 普通に処理 ...
return successResponse(...), nil
} // ⬅️ Lambdaが終了すると、monitorも自動的にクリーンアップされる!
🎓 TDDから学んだ、大切なこと
今回の経験を通して、TDDの本当の価値を実感できました。
- 🔴 Red Phaseの価値: 「何を作りたいか」が、コードを書く前に明確になる。
- 🟢 Green Phaseの価値: 「動く」という自信を最速で手に入れられる。
- 🔵 Refactor Phaseの価値: 「テストがある」という安心感の中で、心置きなくコードを変更できる。
今回の成功の秘訣
- 小さな一歩を刻む: 一度に一つずつ、焦らずに進めたこと。
- 互換性を守る: 古い関数を残し、既存のコードを壊さなかったこと。
- 具体的なテスト: 「キャンセルしたら止まる」という具体的な動作をテストしたこと。
🚀 次のステップへ
「コードを変更するのが怖い」という気持ちは、プログラマーなら誰でも持っています。TDDは、その恐怖を「自信」と「楽しさ」に変えてくれる、強力なパートナーです。
📚 関連記事・学習リソース
- Goの
Contextをもっと深く知りたい → Go公式ブログ: Context


