
ユニットテストができないコードから脱却しよう①
こんにちは、エンジニアの山内です。
今回はユニットテストができるコードを書くために意識していることをまとめてみました。
n番煎じな内容ですが、社内エンジニア向けということも兼ねてサンプルコードとともに見ていきたいと思います。
①ではかなり初歩的な内容になります。
1. なぜユニットテストができるコードを目指すのか
端的に言えば保守性の高いコードになるからです。
ユニットテスト自体はすぐに実装しなくてもよく、ユニットテストができるコードを意識してプロダクションコードを実装するだけでよいのです。
2. 環境
- C#
- .NET Framework 4.8
- MSTest
- FluentAssertions
3. サンプルクラス
弊社サービスであるECカートシステムに倣い、クーポンクラスを用意しました。
クーポンクラスには有効期限と最低購入金額を保持しています。
最低購入金額は注文金額が何円以上であれば使えるというよく見る仕様ですね。
public class Coupon
{
private readonly DateTime _expiredIn;
private readonly decimal _minimumPurchaseAmount;
public Coupon(DateTime expiredIn, decimal minimumPurchaseAmount)
{
_expiredIn = expiredIn;
_minimumPurchaseAmount = minimumPurchaseAmount;
}
}
こちらのクラスに以下条件でtrueとするIsValidメソッドを用意してみましょう。
- 現在日時が有効期限を超えていないこと
- 注文金額が最低購入金額を超えていること
このように複合的な条件が絡むとテストパターンの網羅が大変なのでユニットテストはやっておきたいところです。
4. よくないパターン
ひとまずIsValidメソッドを追加してみました。
しかしこれはユニットテストができない、よくないパターンです。
テストクラスとともにどこがダメなのかを見ていきましょう。
public class Coupon
{
private readonly DateTime _expiredIn;
private readonly decimal _minimumPurchaseAmount;
public Coupon(DateTime expiredIn, decimal minimumPurchaseAmount)
{
_expiredIn = expiredIn;
_minimumPurchaseAmount = minimumPurchaseAmount;
}
public bool IsValid(decimal purchaseAmount) =>
(_expiredIn >= DateTime.Now) && (_mininumPurchaseAmount <= purchaseAmount);
}
次に示すのはCouponクラスのテストクラスです。
IsValidメソッドが有効であるパターンをテストするIsValid_Validメソッドを用意しています。
有効期限は “2025年3月1日” 、最低購入金額は “2,000円” としています。
この記事を書いている日は2025年2月26日、注文金額には10,000円をセットしているので期待結果通り、trueが返ってくることになります。
ここまでは特に問題ありませんね。
ではコードを全く変えず、2025年3月2日にこのテストメソッドは実行してみたらどうなるでしょうか?
おそらくIsValidメソッドの中に定義している DateTime.Now は “2025年3月2日” という日時を返し、有効期限を超えてしまうことになるため、結果はfalseが返ってくることでしょう。
trueであればテストOKという形でテストメソッドを作っているわけですから、もちろんテストも失敗となります。
[TestClass]
public class CouponTest
{
[TestMethod]
public void IsValidTest_Valid()
{
var coupon = new Coupon(DateTime.Parse(“2025/3/1 00:00:00”), 2000);
var actual = coupon.IsValid(10000);
actual.Should().BeTrue();
}
}
テストが失敗する原因を探ってみましょう。
もうお気づきかと思いますが、IsValidメソッドは DateTime.Now という常に一定の値を返さない要素を定義してしまっていることが原因です。
DateTime.Now に依存したメソッドということです。
では、これをユニットテストするのはどうコーディングすればよいでしょうか?
ここからが本題です。
5. 解決法①
単純な解決法です。
現在日時はIsValidメソッドの引数として外からセットしてあげましょう。
IsValidメソッドに now という引数を用意しました。
public class Coupon
{
private readonly DateTime _expiredIn;
private readonly decimal _minimumPurchaseAmount;
public Coupon(DateTime expiredIn, decimal minimumPurchaseAmount)
{
_expiredIn = expiredIn;
_minimumPurchaseAmount = minimumPurchaseAmount;
}
public bool IsValid(DateTime now, decimal purchaseAmount) =>
(_expiredIn >= now) && (_mininumPurchaseAmount <= purchaseAmount);
}
テストクラスは以下のようになります。
IsValidメソッドに新たに増やした第一引数に “2025年2月26日” をセットしてあげます。
現在日時は外から “2025年2月26日” をセットしているので、これで2025年3月2日に実行してもメソッドは常にtrueを返すようになるはずです。
[TestClass]
public class CouponTest
{
[TestMethod]
public void IsValidTest_Valid()
{
var coupon = new Coupon(DateTime.Parse(“2025/3/1 00:00:00”), 2000);
var actual = coupon.IsValid(DateTime.Parse(“2025/2/26 00:00:00”), 10000);
actual.Should().BeTrue();
}
}
6. 解決法②
なるべく外から入れるのが一般的ですが、レガシーコードをリファクタリングする際などに引数を増やしたりすると困る場合は、内部で定義されているDateTimeクラスをラッピングしてユニットテスト向けに固定値をセットできるようにする解決法もあります。
そちらを見ていきましょう。
まず、DateTimeのラッパークラスを作成します。
様々なやり方があると思いますが、今回は分かりやすいように単純な実装にしておきました。
並列実行を考慮してスレッドセーフな実装にしています。
public static class DateTimeProvider
{
private static ThreadLocal<DateTime?> s_fixedDateTime = new();
public static void SetForUnitTest(DateTime dateTime)
{
s_fixedDateTime.Value = dateTime;
}
public static DateTime Now => s_fixedDateTime.Value ?? DateTime.Now;
}
Coupon#IsValidメソッドの引数を増やさず、 “DateTime.Now” -> “DateTimeProvider.Now” に置き換えてみました。
public class Coupon
{
private readonly DateTime _expiredIn;
private readonly decimal _minimumPurchaseAmount;
public Coupon(DateTime expiredIn, decimal minimumPurchaseAmount)
{
_expiredIn = expiredIn;
_minimumPurchaseAmount = minimumPurchaseAmount;
}
public bool IsValid(decimal purchaseAmount) =>
(_expiredIn >= DateTimeProvider.Now) && (_mininumPurchaseAmount <= purchaseAmount);
}
テストコードの実装は以下のようになります。
最初にDateTimeProvider.SetForUnitTestメソッドから固定値をセットしておくことで、引数からセットせず、DateTimeProvider.Nowプロパティから固定値を返すように実装できました。
[TestClass]
public class CouponTest
{
[TestMethod]
public void IsValidTest_Valid()
{
DateTimeProvider.SetForUnitTest(DateTime.Parse(“2025/2/26 00:00:00”));
var coupon = new Coupon(DateTime.Parse(“2025/3/1 00:00:00”), 2000);
var actual = coupon.IsValid(10000);
actual.Should().BeTrue();
}
}
7. まとめ
常に一定の値を返さない要素は外からセットすることで、依存を回避してユニットテストができるようになりましたね。
皆さんもご存じの通り、この考え方は一般的に「依存性の注入(Dependency Injection)」と呼ばれています。
弊社エンジニアも「依存性の注入」について別記事で紹介していますので、ぜひご一読ください。