テストが書けないコードへの処方箋

プログラミング用の複数モニターにコードが表示され、キーボードで作業するエンジニアの手元を写した写真。

みなさんこんにちは。おさかなです。

UIのレイアウトを少し変えただけなのに、なぜかデバイスの機能が動かなくなった——このような経験をされたことがある方もいらっしゃるのではないでしょうか。私も以前、実際にデグレ(退行バグ)として体験したことがあります。その際の根本的な課題は、テストを書けないほどコードが複雑化していたことにありました。 この記事では、テストが書けないコードをいかにして改善するかということについて、実体験を交えてお話したいと思います。

プロジェクトの背景とテスト導入への課題

これは私がとあるプロジェクトでカメラ制御を行うUIを開発していたときの話です。そのUIは私が参加する前から長年にわたってさまざまな人が入れ替わりで開発に携わってきたもので、コードの中に不要な記述と重要な制御処理が入り交じっている状態になっており、テストもないまま複雑化していました。誰も悪気はなかったものの、リファクタリングはほどほどにして開発スピードを優先していた結果、いつの間にかテストコードを書く隙間のない「触るのが怖いコード」になってしまっていたのです。

修正のたびに実機を接続し、手作業で動作確認をする日々に限界を感じていました。限られた人数で開発を回しているため、万が一デグレが起きると、1つのバグを再現して特定するだけでも膨大な時間を奪われてしまいます。さらに、研究開発のフェーズから製品化のフェーズに移行することになり、しっかりとした品質保証もしていかなければならない状態になりました。もはや手動テストでの確認には限界があり、テストの自動化が必須要件となった私は、ついにこのレガシーコードへのテスト導入に挑むことになりました。

とはいえ、最初からすべてを理想的な設計に書き換えるのは影響範囲が大きく、スケジュール遅延やバグ混入のリスクが高すぎます。そのため、この記事で紹介するのは、いきなり綺麗な設計を追い求めることではありません。複雑なコードの隙間を縫って泥臭くリファクタリングを行い、まずは「(単体)テストができる状態」へと持ち込むために、私が実際に取り組んだプロセスです。

単体テストについて

私が実際に行ったプロセスを紹介する前に、単体テストについて簡単に紹介します。 単体テストとはざっくり言うと「少量のコード」を検証して誤りが無いか確認することです。特徴としては以下が挙げられます。

  • 実行時間が短い
  • 他のテストから隔離して実行される

この「隔離」という言葉の考え方によって単体テストには2種類の派閥「ロンドン学派」と「古典学派」が存在します。

ロンドン学派

  • 「テスト対象となるオブジェクトを他のオブジェクトから隔離すべき」という考え方
  • 単体テストの「単体」はコードにおけるクラスを意味する
  • テスト対象以外はすべてテスト・ダブル(偽物のオブジェクト)に置き換える

ロンドン学派
ロンドン学派

古典学派

  • 「テスト対象とは振る舞いのことであり、その振る舞いを他の振る舞いから隔離するべき」という考え方
  • 単体テストの「単体」は状態や振る舞いを意味する
  • テスト以外のオブジェクトと依存関係がある場合、基本的には本物のオブジェクトを使用してテストする
  • データベースやファイルシステムなど共有されるもののみテスト・ダブル(偽物のオブジェクト)に置き換える

古典学派
古典学派

ロンドン学派と古典学派のメリット・デメリットなどの特徴を以下の表にまとめます。

ロンドン学派 vs 古典学派
ロンドン学派 vs 古典学派

これら2つの学派に絶対的な優劣はなく、実際の現場では開発体制やプロジェクトの状況に応じて、臨機応変に使い分けるのが現実的だと考えています。どちらか一方に100%振り切るのではなく、それぞれの「いいとこ取り」をするアプローチです。

具体的には、計算処理や状態の振る舞いといったシステムの根幹をなす部分は「古典学派」でしっかりと検証し、外部APIとの通信やハードウェア制御など、テスト環境から直接コントロールすることが難しい部分は「ロンドン学派」のアプローチ(モック)を用いる、といった住み分けが良いと感じています。

ただし、今回直面したような「すでに複雑化してしまったシステム」に対して、いきなり古典学派のテストを適用しようとすると、絡み合った依存関係をすべて解きほぐす必要があり、至難の業です。そのため本記事では、まずロンドン学派的な手法でオブジェクトの接合部を作り、依存を断ち切っていくプロセスをメインのアプローチとして解説していきます。

テスト作成へのアプローチ

それではこの章から複雑に絡み合ったコードから依存を断ち切ってテストを実装するまでの私のアプローチについてコードをベースに紹介します。

基本的にレガシーコードのテスト作成の流れとしては以下になります。

  1. テストしたい対象を隔離する: メソッド化やオブジェクト化して切り離す
  2. テストで固める: 切り離したものに対してテストコードを書く
  3. 安全にリファクタリング: 他のコードに影響を与えない箱の内部で綺麗に書き直していく

レガシーコードに手を入れるとき、いきなり「美しい設計」を目指すとなかなか進みません。まずは泥臭くてもテストできる状態にすることが重要です。細かいものまで含めるとアプローチは非常にたくさんありますが、ここでは実践しやすく個人的にも役に立ったと感じた手法を中心に紹介します。なお、サンプルコードはすべてC#で記載しています。

アプローチ1:隠れた依存関係を紐解く

テストを書こうとして最初につまずいたのが、コンストラクタです。ロジックをクラスに切り出したはずなのにテストが書けないことがあります。クラスの内部で「別のクラス」を直接生み出していることが原因です。

密結合なコンストラクタの例(アンチパターン)

public class CameraZoomService
{
    private PtzDeviceSdk _cameraSdk;
    public int CurrentZoom { get; private set; } = 1;

    public CameraZoomService()
    {
        // アンチパターン:コンストラクタ内での直接インスタンス化
        _cameraSdk = new PtzDeviceSdk();
    }

    public void ExecuteZoomIn()
    {
        CurrentZoom += 10;
        if (CurrentZoom > 100) CurrentZoom = 100;

        // SDKを介して実機に通信
        _cameraSdk.SendZoomCommand(CurrentZoom);
    }
}

一見問題ないように思えるこのコードの最大の問題は、CameraZoomService を使う側が PtzDeviceSdk の存在をコントロールできない点にあります。テストコードで new CameraZoomService() と書いた瞬間、背後で勝手に「実機のカメラ用のSDK」が起動してしまいます。これは「隠れた依存関係」と呼ばれ、外からは見えないところで、テストを失敗させてしまう可能性があります。


テストを実装するステップ

1. オブジェクトは引数で渡す

まず、コンストラクタの中で new するのをやめて、外からパラメータとして受け取るように変更します。これを「依存性の注入(DI:Dependency Injection)」と呼びます。

public class CameraZoomService
{
    private readonly PtzDeviceSdk _cameraSdk;

    // 具象クラスを引数で受け取るように変更
    public CameraZoomService(PtzDeviceSdk cameraSdk)
    {
        _cameraSdk = cameraSdk;
    }
}

これで「隠れた依存」は表に出すことができました。しかし、引数の型が PtzDeviceSdk という「具象クラス(本物の実装)」のままになっています。この状態では実際のカメラを接続しなければテストすることができません。テストコードで「偽物のカメラ」を渡すための接合部を作る必要があります。

2. インターフェースで接合部を作成する

ここで「インターフェース」の出番です。カメラの振る舞いをインターフェースとして切り出し、それをコンストラクタの引数として渡せるようにします。この変更により、実際のカメラを制御するクラスがなくても動作が可能になります。

// カメラの振る舞いだけを定義する(インターフェース)
public interface IPtzCamera
{
    void SetZoom(int zoomLevel);
}

public class CameraZoomService
{
    private readonly IPtzCamera _camera;
    public int CurrentZoom { get; private set; } = 1;

    // IPtzCameraを渡せば動作するようにする
    public CameraZoomService(IPtzCamera camera)
    {
        _camera = camera;
    }

    public void ExecuteZoomIn()
    {
        CurrentZoom += 10;
        if (CurrentZoom > 100) CurrentZoom = 100;
        _camera.SetZoom(CurrentZoom);
    }
}

3. テストコードを完成させる

接合部さえできれば、テストコードを書くのは非常に簡単です。Mockライブラリを使って偽物のカメラを作成することで、実機に一切触れずにズームロジックの正しさをテストできます。

[TestClass]
public void ExecuteZoomIn_Called10Times_IsCappedAt100()
{
    // Arrange: 接合部に流し込む「偽物のカメラ」を作成
    var mockCamera = new Mock<IPtzCamera>();
    
    // CameraZoomServiceは中身が偽物でも動作可能
    var service = new CameraZoomService(mockCamera.Object);

    // Act: 限界を超えてズームイン
    for (int i = 0; i < 15; i++)
    {
        service.ExecuteZoomIn();
    }

    // Assert: 計算ロジック(ドメイン)の結果を検証
    Assert.Equal(100, service.CurrentZoom);
    
    // Verify: 外部への通信が正しく行われたかも「振る舞い」として検証
    mockCamera.Verify(c => c.SetZoom(100), Times.AtLeastOnce());
}

アプローチ2:影響範囲が広すぎる場合は「抽出してオーバーライド」

テスト作成のためにアプローチ1で紹介したDIを導入して依存関係を紐解く手法は非常に強力です。しかし、コンストラクタの引数を変えると、当然ですがそのクラスの参照箇所をすべて修正しなければなりません。参照箇所が膨大な数になる場合、コードの修正はバグの原因にもなりやすく非常に手間がかかります。

そんな状況で役立つのが、「抽出してオーバーライド」というテクニックです。インターフェースすら作らず、継承を使って強引に「接合部」を作ります。

依存がありテストできないコードの例(アンチパターン)

public class CameraController
{
    public void StartRecording()
    {
        // ... 複雑な前処理 ...

        // スタティックな外部依存
        if (NativeCameraSdk.IsDeviceReady()) 
        {
            NativeCameraSdk.Start();
        }

        // ... 後処理 ...
    }
}

上記はカメラの録画開始ボタンの処理です。ロジックの途中で、スタティックなSDKを直接叩いています。これをそのままテスト実行しようとすると実機がないとエラーが発生してしまいます。


テストを実装するステップ

1. 接合部の切り出し

まず、外部依存している部分だけを、protected virtual なメソッドにそのまま「抽出」して接合部を作成します。

public class CameraController
{
    public void StartRecording()
    {
        // ...
        if (CheckDeviceReady()) // 直接叩かず、メソッド経由にする
        {
            StartDevice();
        }
        // ...
    }

    // 外部依存を仮想メソッドに隔離する
    protected virtual bool CheckDeviceReady() => NativeCameraSdk.IsDeviceReady();
    protected virtual void StartDevice() => NativeCameraSdk.Start();
}

2. テストコードでオーバーライド

テストプロジェクト内で、このクラスを継承した「テスト専用のサブクラス」を作ります。そして virtual 宣言したメソッドをオーバーライドすると、本来は実機から取得する必要のあった処理に対してテスト専用の処理に置き換えることが可能になります。

// テストのためだけに、メソッドを上書き(オーバーライド)する
public class FakeCameraController : CameraController
{
    public bool IsStartCalled { get; private set; }

    protected override bool CheckDeviceReady() => true; // 常に準備完了と見せかける
    protected override void StartDevice() => IsStartCalled = true; // 実機は動かさず、フラグだけ立てる
}

[TestMethod]
public void StartRecording_DeviceIsReady_StartsRecording()
{
    // Arrange: テスト対象のインスタンス(テスト用の接合部を持ったもの)を準備
    var fake = new FakeCameraController();

    // Act: テスト対象のメソッドを実行
    fake.StartRecording();
    
    // Assert: 実機通信の代わりに立てたフラグを検証(安全にロジックを検証可能)
    Assert.True(fake.IsStartCalled); // 安全にロジックを検証可能
}

アプローチ3:ローカル変数が多すぎるメソッドは「メソッドオブジェクトの取り出し」を行う

既存のコードをテスト可能にする基本は「ロジックだけを別のメソッドに抽出する」ことです。しかし、何百行もあるレガシーコードを前にしてそれを実行しようとすると、すぐに問題に直面します。それがローカル変数が多すぎて、引数だらけのメソッドになってしまう、という問題です。そんな時に使うテクニックが「メソッドオブジェクトの取り出し」です。

修正前のレガシーコード(アンチパターン)

例えば、カメラの「オートフォーカス」を計算する巨大なボタンクリックイベントがあるとします。

// UIコードビハインド
private void AutoFocusButton_Click(object sender, RoutedEventArgs e)
{
    // 大量の状態をローカル変数として取得
    int currentFocus = int.Parse(FocusLabel.Content.ToString());
    double contrast = GetImageContrast();
    bool isNightMode = NightModeCheckBox.IsChecked ?? false;
    double lux = Sensor.GetBrightness();

    // 数百行にわたる複雑な計算ロジック
    // (ローカル変数 currentFocus, contrast, isNightMode, lux を全て書き換えながら計算が進む)
    // ※計算部分のみをテストしたいが、変数が多すぎて「メソッドの抽出」ができない

    int targetFocus = currentFocus;
    if (isNightMode && lux < 10.0) 
    {
        targetFocus += (int)(contrast * 2.5);
    }
    // さらに処理が続く…

    // 副作用とUI更新
    PtzDeviceSdk.SetFocus(targetFocus);
    FocusLabel.Content = targetFocus.ToString();
}

このコードの「計算部分」だけを別のメソッドにしようとすると、Calculate(currentFocus, contrast, isNightMode, lux, ...) のように引数が爆発し、さらにC#の refout を多用しなければならず、かえってコードが破綻してしまいます。


テストを実装するステップ

1. 新しいクラス(メソッドオブジェクト)を作る

このような場合はメソッドをクラスに昇格させます。つまり、巨大なメソッドを細かく別のメソッドに分けるのではなく、「ひとつの新しいクラス(オブジェクト)」として独立させます。元のメソッドで使っていた大量のローカル変数を、新しいクラスのメンバ変数にします。

// メソッドのためだけのクラスを作る
public class AutoFocusCalculator
{
    // 元のローカル変数を、すべてクラスのフィールドにする
    private int _currentFocus;
    private double _contrast;
    private bool _isNightMode;
    private double _lux;
    public int TargetFocus { get; private set; } // 計算結果を保持するプロパティ

    // コンストラクタで初期状態を受け取る
    public AutoFocusCalculator(int focus, double contrast, bool nightMode, double lux)
    {
        _currentFocus = focus;
        _contrast = contrast;
        _isNightMode = nightMode;
        _lux = lux;
        TargetFocus = focus; // 初期値
    }

    // 引数なしで純粋な計算だけを行うメソッド
    public void Calculate()
    {
        if (_isNightMode && _lux < 10.0) 
        {
            TargetFocus += (int)(_contrast * 2.5);
        }
        // ...
    }
}

2. 元のUIコードから呼び出す

// UIコードビハインド
private void AutoFocusButton_Click(object sender, RoutedEventArgs e)
{
    // UIから状態を取得
    int currentFocus = int.Parse(FocusLabel.Content.ToString());
    double contrast = GetImageContrast();
    bool isNightMode = NightModeCheckBox.IsChecked ?? false;
    double lux = Sensor.GetBrightness();

    // メソッドオブジェクトを生成して計算を実行
    var calculator = new AutoFocusCalculator(currentFocus, contrast, isNightMode, lux);
    calculator.Calculate();

    // 計算結果を受け取って副作用・UI更新
    PtzDeviceSdk.SetFocus(calculator.TargetFocus);
    FocusLabel.Content = calculator.TargetFocus.ToString();
}

呼び出し元で作成した AutoFocusCalculator クラスをインスタンス化し、計算メソッドを呼び出します。一見すると「ただコードを別の場所に移動しただけ」に見えるかもしれませんが、クラス化して切り離したことで「AutoFocusCalculator クラスに対する単体テスト」が書けるようになります。

3. テストコードを実装する

[TestClass]
public class AutoFocusCalculatorTest
{
    [TestMethod]
    public void Calculate_NormalModeAndHighContrast_CalculatesCorrectFocus()
    {
        // Arrange: 状態(ローカル変数だったもの)をセットして計算クラスを生成
        int currentFocus = 50;
        double contrast = 0.8;
        bool isNightMode = false;
        double lux = 500.0;
        
        var calculator = new AutoFocusCalculator(currentFocus, contrast, isNightMode, lux);

        // Act: 計算を実行(引数なしで呼べる)
        calculator.Calculate();

        // Assert: 計算結果のプロパティを検証
        Assert.AreEqual(60, calculator.TargetFocus); 
    }

    [TestMethod]
    public void Calculate_NightModeAndLowLux_SetsFocusToInfinity()
    {
        // Arrange: ナイトモード等の条件をセットして計算クラスを生成
        int currentFocus = 50;
        double contrast = 0.2;
        bool isNightMode = true; 
        double lux = 10.0;
        
        var calculator = new AutoFocusCalculator(currentFocus, contrast, isNightMode, lux);

        // Act: 計算を実行
        calculator.Calculate();

        // Assert: 計算結果が仕様通りに更新されたかを検証
        // ※仮の仕様:ナイトモードで暗い場合は、強制的に無限遠(100)に設定される想定
        Assert.AreEqual(100, calculator.TargetFocus); 
    }
}

アプローチ4:仕様変更時は芽生えクラスを作成

数千行に及ぶ「神クラス」や、何重にもネストされた巨大なメソッドに新規ロジックを追加する、または仕様変更しなければならない時、既存のコードの中に if 文を書き足すのは、沼をさらに深くしてしまいます。そこで使うのが、「芽生えクラス」というテクニックです。

神クラスにそのまま新規ロジックを追加したコードの例(アンチパターン)

既存の巨大なズーム処理に、「特定のユーザーのみ最大倍率を制限する」という新機能を足すケースを考えます。

public void ZoomIn(User user)
{
    // ... 数百行の既存コード ...
    
    // ここに直接ロジックを書くと、この巨大なメソッド全体のテストが必要になる
    int limit = (user.Role == "Admin") ? 100 : 50;
    if (currentZoom > limit) return;

    // ... さらに続く ...
}

テストを実装するステップ

1. 新規ロジックだけを完全に独立した「テスト可能な新しいクラス」として作成する

// 芽生えクラス:既存のクラスから切り離したテスト可能なクラス
public class ZoomPolicy
{
    public int GetMaxLimit(User user)
    {
        return (user.Role == "Admin") ? 100 : 50;
    }
}

そして、既存のメソッドからはこの新しいクラスを呼び出すだけに留めます。

public void ZoomIn(User user)
{
    // ... 既存コード ...

    // 呼び出しだけを追加。既存コードへの影響を最小限にする
    var policy = new ZoomPolicy(); 
    if (currentZoom > policy.GetMaxLimit(user)) return;

    // ... 既存コード ...
}

2. テストコードを作成する

[TestClass]
public class ZoomPolicyTest
{
    [TestMethod]
    public void GetMaxLimit_AdminUser_Returns100()
    {
        // Arrange: 管理者権限を持つユーザーと、テスト対象の芽生えクラスを準備
        var adminUser = new User { Role = "Admin" };
        var policy = new ZoomPolicy();

        // Act: 制限値を取得して実行
        int actualLimit = policy.GetMaxLimit(adminUser);

        // Assert: 管理者の上限値(100)が返されることを検証
        Assert.AreEqual(100, actualLimit);
    }

    [TestMethod]
    public void GetMaxLimit_NormalUser_Returns50()
    {
        // Arrange: 一般権限のユーザーと、テスト対象の芽生えクラスを準備
        var normalUser = new User { Role = "Normal" };  // Admin以外の権限
        var policy = new ZoomPolicy();

        // Act: 制限値を取得して実行
        int actualLimit = policy.GetMaxLimit(normalUser);

        // Assert: 一般ユーザーの上限値(50)が返されることを検証
        Assert.AreEqual(50, actualLimit);
    }
}

当たり前かもしれませんが、既存のコードがどれほど汚くても、追加するコードだけは綺麗でテスト可能であるように心がけることが重要です。巨大なクラス全体のテストを書くのは骨が折れますが、新しく作った ZoomPolicy クラスなら、100%のテストカバレッジを簡単に達成できます。レガシーコードの肥大化を食い止め、新しい設計の種を蒔くことが「芽生えクラス」の本質です。

テストコードのリファクタリング

前章で複雑なレガシーコードを紐解いてテストコードを作成する方法について紹介してきました。しかし、テストコードはプロダクションコードと同じぐらい重要で、継続的なメンテナンスを怠るとテストコード自体もレガシーコードになってしまいます。 そこで、最後にテストコードのリファクタリングの実践的なテクニックを2つ紹介します。

テストコードの冗長性を排除して保守性を向上させる

前章のテストコード内にコメントで書いていましたが、テストコードは「Arrange(準備)」「Act(実行)」「Assert(検証)」のAAAパターンで書くのが基本です。

AAAパターン
AAAパターン

しかし、テストケースが増えてくると、テストを実行するための準備処理である Arrange の記述が肥大化し、テストの本来の意図が埋もれてしまい読みづらいコードになってしまいます。例えば、下記のテストの場合、重要なのはコントラストの値が 0.8 という条件だけです。

[TestMethod]
public void Calculate_NormalModeAndHighContrast_CalculatesCorrectFocus()
{
    // Arrange (準備)
    int currentFocus = 50;
    double contrast = 0.8;
    bool isNightMode = false;
    double lux = 500.0;
    var calculator = new AutoFocusCalculator(currentFocus, contrast, isNightMode, lux);

    // Act (実行)
    calculator.Calculate();

    // Assert (検証)
    Assert.AreEqual(60, calculator.TargetFocus); 
}

そこで、テストデータを生成するファクトリメソッド(またはBuilder)を導入し、テストコードからノイズを減らします。

// 共通のファクトリメソッド
private AutoFocusCalculator CreateCalculator(int focus = 50, double contrast = 0.5, bool isNightMode = false, double lux = 500.0)
{
    return new AutoFocusCalculator(focus, contrast, isNightMode, lux);
}

[TestMethod]
public void Calculate_HighContrast_IncreasesFocus()
{
    // Arrange: 検証したいパラメータ(コントラスト)だけを明示する
    var calculator = CreateCalculator(contrast: 0.8);

    // Act: 計算を実行
    calculator.Calculate();

    // Assert: 計算結果を検証
    Assert.AreEqual(60, calculator.TargetFocus); 
}

「デフォルト値を持つ生成メソッド(C#のオプション引数を活用)」を作ることで、 Arrange フェーズが1行に圧縮され、「このテストは何を検証したいのか」が誰の目にも明らかになります。また、同じ Arrange フェーズが必要なテストでこのファクトリメソッドを流用することもできるようになります。

テスト内容が実装の詳細に依存しないようにしてリファクタリング耐性を高める

テストが「プロダクションコードの内部構造(どうやって計算しているか)」を知りすぎていると、コードを綺麗に直しただけでテストが失敗するようになり、テストを直すことに疲弊してしまいます。

特にロンドン学派にこだわり、無理やり接合部を作ったテストではリファクタリング耐性の低いテストになりがちです。例えば、ズームを滑らかに行うための SmoothZoomService というクラスを作ったとします。内部ではカメラの現在のズーム値を取得し、少しだけ数値を足してカメラに送信しています。

ズーム制御のプロダクションコード

public class SmoothZoomService
{
    private readonly IPtzCamera _camera;
    
    public SmoothZoomService(IPtzCamera camera) 
    { 
        _camera = camera; 
    }

    public void ZoomInSlightly()
    {
        // 現在のズーム値を取得
        int current = _camera.GetCurrentZoom(); 
        // 10だけ足して設定
        _camera.SetZoom(current + 10);          
    }
}

実装に依存してしまったテストコード(アンチパターン)

[TestMethod]
public void ZoomInSlightly_MockTest()
{
    // Arrange: モックの準備と戻り値の設定
    var mockCamera = new Mock<IPtzCamera>();
    // GetCurrentZoomが呼ばれたら50を返すように設定
    mockCamera.Setup(c => c.GetCurrentZoom()).Returns(50);
    
    var service = new SmoothZoomService(mockCamera.Object);
    
    // Act: 実行
    service.ZoomInSlightly();

    // Assert: ここが「実装の詳細」への依存
    mockCamera.Verify(c => c.GetCurrentZoom(), Times.Once); 
    // 「SetZoomに60が渡されて1回呼ばれたこと」を検証している
    mockCamera.Verify(c => c.SetZoom(60), Times.Once);      
}

上記のテストは、ZoomInSlightly を実行した際、内部で GetCurrentZoom が1回呼ばれたことを必ず検証する仕組みになっています。

現時点では無事に成功しますが、将来もし「通信回数を減らすために、ズーム値をキャッシュで保持する」といったリファクタリングを行った場合、最終的なズーム結果は正しいにもかかわらずこのテストはエラーを吐いて壊れてしまいます。コードを綺麗にするたびにテストコードまで修正が必要になる、いわゆる「脆いテスト」になってしまっています。

テストのリファクタリング耐性を高めるためには「実装の手順」ではなく「得られる結果」にフォーカスしてテストを作成することが重要です。

具体的な解決策として、ここでは計算処理だけを担当する純粋な ZoomCalculator クラスを切り出し、そこにテストを記述するアプローチへと改善することでリファクタリング耐性を上げることができます。

[TestMethod]
public void CalculateNextZoom_Adds10ToCurrentZoom()
{
    // Arrange: 入力値だけを用意する
    int currentZoom = 50;
    var calculator = new ZoomCalculator();

    // Act: 実行
    int nextZoom = calculator.CalculateNextZoom(currentZoom);

    // Assert: 最終的な「結果」だけを確認(内部でどう計算したかには依存しない)
    Assert.AreEqual(60, nextZoom); 
}

このように、実装の詳細に依存しない堅牢なテストに洗練することで、将来的なリファクタリングによるエラーの発生を未然に防ぐことができます。単体テスト自体がレガシーコードとなる事態を回避するため、プロダクションコードと並行してテストの設計も継続的に改善していくことが重要です。

さいごに

本記事では、密結合なシステムにテストを追加し、安全な状態へと改善していくプロセスを私の経験をもとに順を追って紹介しました。最初はロンドン学派的なアプローチで泥臭く接合部を作って依存を断ち切り、次に「芽生えクラス」を使って純粋なロジックを抽出しました。そして最後は、実装の詳細に依存しない古典学派のテストへとリファクタリングすることで、壊れにくい堅牢なテストの実現方法について説明しました。

レガシーコードの身動きが取れない状態から抜け出す第一歩は、泥臭くコードの隙間に接合部を作ることが大切です。そこからテスト可能な状態へ持ち込み、さらにテストコード自体も「振る舞いを検証する堅牢なもの」へと育てていく、この地道な改善の積み重ねが品質と開発スピードを両立させる確実な近道となります。

最初から完璧な設計を目指す必要はありません。本記事が、レガシーコードに立ち向かう皆様の背中を少しでも押すヒントになれば幸いです。 また、次の記事でお会いしましょう!