メインコンテンツにスキップ

【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の価値: 「テストがある」という安心感の中で、心置きなくコードを変更できる。

今回の成功の秘訣

  1. 小さな一歩を刻む: 一度に一つずつ、焦らずに進めたこと。
  2. 互換性を守る: 古い関数を残し、既存のコードを壊さなかったこと。
  3. 具体的なテスト: 「キャンセルしたら止まる」という具体的な動作をテストしたこと。

🚀 次のステップへ

「コードを変更するのが怖い」という気持ちは、プログラマーなら誰でも持っています。TDDは、その恐怖を「自信」と「楽しさ」に変えてくれる、強力なパートナーです。


📚 関連記事・学習リソース