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

【Go言語】TDDで大規模リファクタリング!散らばった環境変数を一箇所にまとめる技術

😱「この設定、どこで使ってるんだっけ…?」

プロジェクトが大きくなるにつれて、こんな悪夢のような状況に陥っていませんか?

// ファイルA: ユーザーサービス
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")

// ファイルB: 認証サービス
poolId := os.Getenv("COGNITO_USER_POOL_ID") // あれ、変数名が違う…

// ファイルC: 共通ライブラリ
cognitoPool := os.Getenv("COGNITO_USER_POOL_ID")
if cognitoPool == "" {
    // デフォルト値を設定…でも、他の場所と違う値だったら?
}

設定の呼び出しがコードのあちこちに散らばると、

  • 一貫性がない: 同じ設定なのに、違う名前で呼ばれている。
  • バグの温床: ある場所では必須チェックをしているのに、別の場所ではしていない。
  • メンテナンス地獄: 設定名を一つ変更するのに、何十ファイルも修正する必要がある。
  • テストが困難: テストごとに環境変数を設定するのが面倒くさい。

私たちは、自分たちのコードベースを調査し、なんと18箇所にos.Getenvが散らばっていることを発見しました。これは、もはや悪夢です。

このカオスな状況を、今回はTDD(テスト駆動開発)を使って、安全かつ劇的に改善します!

今回のゴール

散らばったos.Getenv呼び出しを、中央集約化された、型安全で、テストしやすいconfigパッケージに生まれ変わらせる!

🚀 TDDは、大規模リファクタリングの羅針盤

今回のような「コードベース全体に影響する大きな変更」こそ、TDDの真価が発揮されます。

  • 安全な航海: 既存のテストが、変更によって機能が壊れていないかを常に監視してくれます。
  • 明確な目的地: 「テストを先に書く」ことで、どんなconfigパッケージを作りたいのか、設計図が明確になります。
  • 座礁を防ぐ: リファクタリング中にバグが生まれても、テストが即座に検知してくれます。

さあ、TDDという羅針盤を手に、リファクタリングの大海原へ出発しましょう!

サイクル1:🔴 Red - 「理想の設定管理」をテストで描く

まず、私たちが夢見る「理想のconfigパッケージ」が、どんな風に動いてほしいかをテストコードで表現します。

テスト1: 環境変数を読み込んで、型安全な設定オブジェクトを作ってほしい!

// pkg/config/config_test.go
func TestLoadConfig_Success(t *testing.T) {
    // 準備: テスト用の環境変数を設定
    os.Setenv("COGNITO_USER_POOL_ID", "ap-northeast-1_TestPool123")
    os.Setenv("USERS_TABLE_NAME", "test-users-table")
    os.Setenv("DEBUG_LOGGING", "true")
    // テストが終わったら環境変数を元に戻す
    defer os.Clearenv()

    // 実行: 理想の関数 `LoadConfig` を呼び出す(まだ存在しない!)
    config, err := LoadConfig()

    // 検証:
    assert.NoError(t, err) // エラーなく読み込めるはず
    assert.NotNil(t, config) // configオブジェクトが作られるはず

    // 型安全なゲッターメソッドで値を取得できるはず
    assert.Equal(t, "ap-northeast-1_TestPool123", config.GetCognitoUserPoolID())
    assert.Equal(t, "test-users-table", config.GetUsersTableName())
    assert.True(t, config.IsDebugEnabled()) // bool型として扱える!
}

テスト2: 必須の環境変数がなかったら、ちゃんとエラーを返してほしい!

// pkg/config/config_test.go
func TestLoadConfig_MissingRequiredVariable(t *testing.T) {
    // USERS_TABLE_NAME を設定しないでおく
    os.Setenv("COGNITO_USER_POOL_ID", "ap-northeast-1_TestPool123")
    defer os.Clearenv()

    // 実行
    _, err := LoadConfig()

    // 検証:
    assert.Error(t, err) // 必ずエラーになるはず
    assert.Contains(t, err.Error(), "USERS_TABLE_NAME") // エラーメッセージに変数名が含まれるはず
}

実行結果:期待通りの「失敗」!

当然、LoadConfigなんて関数はまだないので、コンパイルエラーで失敗します。これでOK!私たちの「設計図」が完成しました。

サイクル2:🟢 Green - テストを通すためだけの最小限のコードを書く

次に、この「設計図(失敗するテスト)」を満たすためだけの、最小限のコードを実装します。

Config構造体を定義する

// pkg/config/config.go
package config

// Config は、アプリケーションの全設定を保持する構造体
type Config struct {
    // 必須の設定
    CognitoUserPoolID string
    CognitoClientID   string
    UsersTableName    string

    // オプション(デフォルト値あり)
    AWSRegion      string
    AllowedOrigins string
    DebugLogging   bool
}

LoadConfig関数を実装する

// pkg/config/config.go
import (
    "fmt"
    "os"
    "strings"
)

// LoadConfig は環境変数から設定を読み込み、検証する
func LoadConfig() (*Config, error) {
    config := &Config{}

    // 必須の変数を読み込む
    config.CognitoUserPoolID = os.Getenv("COGNITO_USER_POOL_ID")
    if config.CognitoUserPoolID == "" {
        return nil, fmt.Errorf("必須の環境変数が見つかりません: COGNITO_USER_POOL_ID")
    }
    config.UsersTableName = os.Getenv("USERS_TABLE_NAME")
    if config.UsersTableName == "" {
        return nil, fmt.Errorf("必須の環境変数が見つかりません: USERS_TABLE_NAME")
    }
    // ...他の必須変数も同様に...

    // オプションの変数を読み込む(なければデフォルト値)
    config.AWSRegion = os.Getenv("AWS_REGION")
    if config.AWSRegion == "" {
        config.AWSRegion = "ap-northeast-1"
    }
    debugStr := strings.ToLower(os.Getenv("DEBUG_LOGGING"))
    config.DebugLogging = (debugStr == "true" || debugStr == "1")

    return config, nil
}

型安全なゲッターメソッドを実装する

// pkg/config/config.go

// GetCognitoUserPoolID は、CognitoのユーザープールIDを返す
func (c *Config) GetCognitoUserPoolID() string {
    return c.CognitoUserPoolID
}

// IsDebugEnabled は、デバッグログが有効かどうかを返す
func (c *Config) IsDebugEnabled() bool {
    return c.DebugLogging
}
// ...他のゲッターも同様に...

実行結果:テストが「成功」!

$ go test -v ./pkg/config
PASS
ok      poc-cognite/pkg/config  0.003s

やりました!私たちの「理想のconfigパッケージ」のプロトタイプが完成しました。

サイクル3:🔵 Refactor - 18箇所のos.Getenvを置き換えていく

いよいよ、リファクタリングの本番です。テストという強力な味方がいるので、自信を持って進められます。

コードベースに散らばるos.Getenvを、新しく作ったconfig.LoadConfig()に一つずつ置き換えていきます。

Before: カオスな直接呼び出し

// cmd/update-user/main.go (修正前)
func businessHandler(...) (..., error) {
    // ...
    tableName := os.Getenv("USERS_TABLE_NAME") // 😭 文字列、検証なし
    if tableName == "" {
        // ...
    }
    // ...
}

After: スッキリ中央集約!

// cmd/update-user/main.go (修正後)
import "poc-cognite/pkg/config" // 新しいパッケージをインポート

func businessHandler(...) (..., error) {
    // ...
    cfg, err := config.LoadConfig() // ✨ 型安全な設定読み込み
    if err != nil {
        // 起動時に設定ミスがわかる!
        return ..., fmt.Errorf("設定の読み込みに失敗: %w", err)
    }
    tableName := cfg.GetUsersTableName() // ✨ 型安全なゲッター
    // ...
}

この地道な作業を、18箇所すべてで繰り返します。変更を加えるたびに、プロジェクト全体のテストを実行して、何も壊していないことを確認します。

$ go test ./...
PASS ✅

この緑のPASSが、私たちの心の平穏を保ってくれます。

🎉 改善の効果は絶大!どう変わった?

📊 定量的な改善

項目Before (改善前)After (改善後)
os.Getenv呼び出し数18箇所✨ 1箇所 (configパッケージ内のみ)
設定の検証バラバラ、または無し✨ 全設定で統一
デフォルト値の管理7箇所に散在✨ 1箇所に集約
テスト容易性低い✨ 非常に高い

🚀 質的な改善

  1. 型安全で、もう間違えない! cfg.IsDebugEnabled()は必ずbool型を返します。もう"true"という文字列と比較する必要はありません。

  2. テストが、驚くほど書きやすい! テスト用の設定オブジェクトを作るだけで、どんな状況でも簡単に再現できるようになりました。

  3. 設定ミスは、実行時ではなく「起動時」にわかる! 必須の環境変数がなければ、アプリケーションは起動しません。これにより、実行中の予期せぬエラーを未然に防げます。

  4. 保守性が爆発的に向上! 新しい設定を追加するのも、configパッケージを修正するだけ。もうコードベース全体を探し回る必要はありません。