C# テスト駆動開発の実践方法:サービスクラスの実装をしよう-3- #17

当連載ではテスト駆動開発を実践するために必要な基礎知識や実践方法を解説しています。現在は実践編として簡単なアプリケーションを作成しながらテスト駆動開発のやり方を解説しています。

過去の記事については「C# テスト駆動開発の実践方法」から確認できますので、テスト駆動開発について詳しく知りたい人は参考にしてみてください。前回は入金機能の実装を行いましたので、今回はBankServiceとして最低限必要な引出処理を実装していきます。

出金機能には何が必要か

残高から出金するには何が必要かを考えます。基本的は引き出したい金額を残高から差し引いて最新の残高を更新するだけで十分に思いますね。しかしながらそれだけでは足りなさそうです。

例えば100円しかない口座に200円の引き出し指示が来たとき、これを了承して良いのでしょうか。自動的な貸し出し処理等を発生させるような特別仕様な口座ならよいかもしれませんが、ここではそんな複雑なことは考えないようにします。

残高と引き出し指示を比べて残高が0円以上になる場合に引き出しを許可するようにする必要がありそうです。例外処理の扱い方をどうするか考える必要がありますが、いったんは例外として処理しておきます。という訳でテストコードから考えましょう。

正常パターンのテストコード

まずは簡単に正常パターンのテストコードを記載していきましょう。今回は新規メソッドをWithdrawメソッドとしていきます。BankServiceTest.csに以下のテストメソッドを追加してみましょう。

[TestMethod]
public void Withdraw_OK()
{
    //初期金額
    string writePath = @"C:\temp\balance.txt";
    string text = ((int)100).ToString();
    File.WriteAllText(writePath, text);

    string path = @"C:\temp\balance.txt";
    var repository = new BankRepository(path);

    //引出指示
    var service = new BankService(repository);
    service.Withdraw(new Money(40));

    //残高確認
    var balance = service.GetBalance();
    Assert.IsTrue(balance.Equals(new Money(60)));
}

今回も例のごとく初期金額を100円としていますが、任意の金額に変更してください。さて現在の状態ではコンパイルエラーとなりますので、まずはコンパイルエラーを取り除いてレッドになる状態を目指します。

コンパイルエラーをレッドにする

現在のコンパイルエラーの直接的な原因はWithdrawメソッドがBankServiceクラスに存在しないことによるものです。この原因を取り除く必要があります。まずは暫定的に以下のメソッドをBankServiceクラスに追加します。

public Money Withdraw(Money withdraw)
{
    return withdraw;
}

これでコンパイルエラーを取り除くことができると思います。テスト駆動開発では、いきなり実装をスタートさせずに、まずは「レッド」の状態を目指してから正しい実装に着手していくのがセオリーです。

上記の実装が完了したらテストを実施して、無事にテストエクスプローラーがレッドになるかどうかを確認しましょう。レッドの状態になったら先に進むことになります。まずはこのサイクルを体になじませましょう。

レッドからグリーンにする

さて無事にレッドになることが確認できたら先に進みます。次はレッドの状態をグリーンにすることです。引出機能としては以下のような段階を踏む必要があります。

  • 残高を確認する
  • 出金指示が残高を超えないことを確認する
  • 問題なければ残高から出金指示分の金額を引く
  • 最新の残高として更新する

上記の手順になるのが一般的になると思いますので、これをシンプルに実装していきましょう。BankSerivceテストの中身は以下のようになるかと思います。

public Money Withdraw(Money withdraw)
{
    var balance = _repos.Read();

    var newBalance = balance.Subtract(withdraw);
    if (newBalance.Value < 0)
    {
        throw new Exception("残高以上の引き出しはできません。");
    }

    _repos.Write(newBalance);
    return newBalance;
}

出金指示が残高を上回っている場合には例外として処理しておきます。ここでの処理は例外でもエラーメッセージとして別プロパティに格納するでもよいかと思います。とはいえ極力シンプルに行きたいので例外としてハンドリングすることにします。

上記の実装まで済んだらいったんテストを実施してグリーンになるかを確認します。グリーンになればいったんはOKとします。もう少しリファクタリングをしたいと思いますが、まずはテストが通るかを確認しましょう。

リファクタリングで洗練させる

さてリファクタリングをして行きたいとおもいますが、そこまで複雑なことをするつもりはありません。先ほどのWithdrawメソッドで一つ気になる実装があります。それは出金指示と残高を比べる箇所です。

var newBalance = balance.Subtract(withdraw);
if (newBalance.Value < 0)
{
    throw new Exception("残高以上の引き出しはできません。");
}

ここの処理ですが残高に対して直接的に0より少ないかを確認しています。この状態ですと、将来的にほかの箇所に同様の実装をする場面では処理が分散します。それに現在の仕様では「出金指示は残高を上回らなくてはならない」という強いルールになりますよね。

こういう「ルール」があちこちに散らばってしまうとバグの温床になってしまいます。こういう処理は一つの「ルール」としてメソッドにまとめておく方が拡張性がありますね。という訳でこの部分を切り離します。このルールは「口座」のルールですのでBankService内で問題ないでしょう。まずはそのためのテストメソッド作りから始めます。

ルールのテストメソッドを実装する

BankServiceTest.csに移動してメソッドを3つほど追加します。OKパターンとNGパターンになります。コーディング内容は「出金指示は残高を超えてはならない」というものでした。

[TestMethod]
public void CanWithdraw_OK1()
{
    string path = @"C:\temp\balance.txt";
    var repository = new BankRepository(path);
    var service = new BankService(repository);

    var balance = new Money(100);
    var ins = new Money(100);

    var canWithdraw = service.CanWithdraw(balance, ins);
    Assert.IsTrue(canWithdraw);
}

[TestMethod]
public void CanWithdraw_OK2()
{
    string path = @"C:\temp\balance.txt";
    var repository = new BankRepository(path);
    var service = new BankService(repository);

    var balance = new Money(100);
    var ins = new Money(99);

    var canWithdraw = service.CanWithdraw(balance, ins);
    Assert.IsTrue(canWithdraw);
}

[TestMethod]
public void CanWithdraw_NG()
{
    string path = @"C:\temp\balance.txt";
    var repository = new BankRepository(path);
    var service = new BankService(repository);

    var balance = new Money(100);
    var ins = new Money(101);

    var canWithdraw = service.CanWithdraw(balance, ins);
    Assert.IsFalse(canWithdraw);
}

今回のテストでは境界チェックを実施したかったので以下の3パターンを用意しています。

  • 残高が100円で出金指示が99円の場合
  • 残高が100円で出金指示も100円の場合
  • 残高が100円で出金指示が101円の場合

上記の場合、先の2パターンが正常で最後のパターンが異常になります。「出金指示が残高を超えてはならない」という場合、残高と出金指示が同値であるという場合は引き出せなくてはなりません。それをチェックするためのテストになります。

コンパイルエラーからグリーンにする

という訳で早速、コンパイルエラーからレッドの状態を目指します。BankServiceクラスにCanWithdrawメソッドを作成してコンパイルエラーを解消します。

public bool CanWithdraw(Money balance, Money ins)
{
    throw new NotImplementedException();
}

いったんは上記の状態でテスト実行ができるような状態とします。テストを実行してレッドの状態になることを確認して下さい。レッドであればさらに続けて修正を施していきます。CanWithdrawメソッドは以下のように修正できると思います。

public bool CanWithdraw(Money balance, Money ins)
{
    return balance.Subtract(ins).Value >= 0;
}

残高から出金指示を引いて0円以上でれば引き出しが可能となります。上記の処理で十分に判定できるようになるはずです。修正できたらもう一度テストを実施してみましょう。さてグリーンになったでしょうか。それでは最後に元のテストを変更します。

public Money Withdraw(Money withdraw)
{
    var balance = _repos.Read();       
    if (!(CanWithdraw(balance, withdraw)))
    {
        throw new Exception("残高以上の引き出しはできません。");
    }

    var newBalance = balance.Subtract(withdraw);
    _repos.Write(newBalance);
    return newBalance;
}

テストを変更したら再度テストを実行して、すべてがグリーンのままであることを確認します。オールグリーンの状態であればリファクタリングは完了です。テスト駆動開発はリファクタリングをするときにも非常に便利ですね。ケースを網羅したテストをあらかじめ作っておけば、ちょっとしたコードの修正にも柔軟に対応できる実装手法です。

異常パターンのテストコード

さて、最後においておいたWithdrawメソッドの異常パターンのテストコードを実装していきます。まずはテストコードの実装から始めていきましょう。

[TestMethod]
public void Withdraw_NG()
{
    //初期金額
    string writePath = @"C:\temp\balance.txt";
    string text = ((int)100).ToString();
    File.WriteAllText(writePath, text);

    string path = @"C:\temp\balance.txt";
    var repository = new BankRepository(path);

    //引出指示
    try
    {
        var service = new BankService(repository);
        service.Withdraw(new Money(101));
    }
    catch (Exception ex)
    {
        Assert.AreEqual(ex.Message, "残高以上の引き出しはできません。");
    }
}

上記のような実装になればOKかなと思います。現在ではWithdrawメソッドの中で、残高がマイナスになる場合はエラーとしているので、いったんはtry ~ catch ~ を使ってエラーを拾うようにしておきましょう。より良い実装が見つかった時に変更するかもしれませんが、今はこのままとしておきます。

さて、ここまででBankServiceに必要な参照・引出・入金の3つの重要な核となる機能の実装が完了しました。これでもともと想定していた機能を実装する準備が整ったと言えそうですね。次回から最終局面である、アプリケーション全体の処理を司るアプリケーションサービスクラスの実装を行います。