メインコンテンツまでスキップ

.NET単体テスト (xUnit/Shouldly/NSubstitute)

.NETエコシステムには強力なテストツールが多数存在します。本ドキュメントでは、モダンな.NET開発で広く採用されている以下の3つのライブラリを組み合わせた単体テストの実装方法を解説します。

  • xUnit: テストフレームワーク
  • Shouldly: 流暢なアサーションライブラリ
  • NSubstitute: モックライブラリ

単体テストの目的と重要性

単体テスト(Unit Test)は、ソフトウェア開発において個々の機能(関数、メソッド、クラスなど)が意図した通りに動作することを検証するプロセスです。

主な目的

  1. 早期のバグ発見: 開発サイクルの早い段階で不具合を見つけることで、修正コストを大幅に削減します。
  2. リファクタリングの安全性: 既存の機能が壊れていないことを保証できるため、コードの改善や構造変更を自信を持って行えます。
  3. 生きたドキュメント: テストコードは、その機能がどのように振る舞うべきかを示す具体的な仕様書として機能します。
  4. 設計の改善: テストしやすいコードを書こうとすることで、自然と疎結合で凝集度の高い設計(SOLID原則など)が促進されます。

本ドキュメントで扱うツールの役割

  • xUnit: テストの実行環境を提供し、テストメソッドの定義やライフサイクルを管理します。
  • Shouldly: テスト結果の検証(アサーション)を自然言語に近い形で記述でき、失敗時のエラーメッセージを分かりやすくします。
  • NSubstitute: 外部依存(データベースやWeb APIなど)を模倣(モック)し、テスト対象を隔離して純粋なロジックのみを検証可能にします。

テスト用語の定義 (Test Doubles)

単体テストでは、テスト対象が依存しているコンポーネント(データベース、外部API、時刻など)を代用オブジェクトに置き換えることがよくあります。これらを総称してテストダブル (Test Double) と呼びます。

以下は代表的なテストダブルの種類です。NSubstituteなどのライブラリは、これらを統一的なインターフェースで作成・管理します。

1. スタブ (Stub)

「事前定義された値を返す」 オブジェクトです。 テスト対象が依存コンポーネントからデータを取得する場合に使用します。

  • 目的: テスト対象への入力(状態)を制御する。
  • : IUserRepository.GetById(1) が呼ばれたら、特定の User オブジェクトを返す。

2. モック (Mock)

「呼び出しを検証する」 オブジェクトです。 テスト対象が依存コンポーネントに対して正しいアクション(メソッド呼び出し)を行ったかを確認します。

  • 目的: テスト対象からの出力(振る舞い)を検証する。
  • : IEmailService.Send(...) が、正しい引数で1回だけ呼ばれたことを確認する。

3. フェイク (Fake)

「実際に動作する実装を持つ」 オブジェクトですが、本番環境には適さない簡易的なものです。

  • : 実際のデータベースの代わりに、メモリ上のリスト (List<T>) を使ってデータを保存・取得するリポジトリクラス。

4. ダミー (Dummy)

「受け渡されるだけ」 のオブジェクトです。 引数リストを埋めるためだけに使われ、実際には使用されません。

Note: NSubstituteでは、Substitute.For<T>() で作成したオブジェクトがスタブとしてもモックとしても機能します。

  • Returns() を使えば スタブ
  • Received() を使えば モック

として振る舞います。

環境セットアップ

テストプロジェクトを作成し、必要なNuGetパッケージをインストールします。

# テストプロジェクトの作成
dotnet new xunit -n MyProject.Tests

# パッケージのインストール
dotnet add package Shouldly
dotnet add package NSubstitute

基本的なテストの実装 (xUnit + Shouldly)

xUnitでテストメソッドを定義し、Shouldlyを使って直感的なアサーション記述を行います。

テスト対象のコード

public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}

public double Divide(int a, int b)
{
if (b == 0) throw new DivideByZeroException();
return (double)a / b;
}
}

基本的なテストコード

using Xunit;
using Shouldly;

public class CalculatorTests
{
private readonly Calculator _calculator;

public CalculatorTests()
{
_calculator = new Calculator();
}

[Fact]
public void Add_ShouldReturnSum_WhenInputsAreValid()
{
// Arrange
var a = 5;
var b = 3;

// Act
var result = _calculator.Add(a, b);

// Assert
result.ShouldBe(8);
}

[Theory]
[InlineData(10, 2, 5)]
[InlineData(9, 3, 3)]
public void Divide_ShouldReturnQuotient_WhenDivisorIsNotZero(int a, int b, double expected)
{
// Act
var result = _calculator.Divide(a, b);

// Assert
result.ShouldBe(expected);
}

[Fact]
public void Divide_ShouldThrowException_WhenDivisorIsZero()
{
// Act & Assert
Should.Throw<DivideByZeroException>(() => _calculator.Divide(10, 0));
}
}

Shouldlyを使用することで、Assert.Equal(8, result) ではなく result.ShouldBe(8) と記述でき、エラーメッセージもより詳細になります。

モックを使用したテスト (NSubstitute)

外部依存(データベース、APIなど)を持つクラスをテストする場合、NSubstituteを使用して依存関係をモック化します。

テスト対象のコード(依存関係あり)

public interface IUserRepository
{
User GetById(int id);
void Save(User user);
}

public class UserService
{
private readonly IUserRepository _repository;

public UserService(IUserRepository repository)
{
_repository = repository;
}

public string GetUserName(int id)
{
var user = _repository.GetById(id);
return user?.Name ?? "Unknown";
}

public void UpdateUserEmail(int id, string newEmail)
{
var user = _repository.GetById(id);
if (user != null)
{
user.Email = newEmail;
_repository.Save(user);
}
}
}

public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}

モックを使用したテストコード

using Xunit;
using Shouldly;
using NSubstitute;

public class UserServiceTests
{
private readonly IUserRepository _userRepository;
private readonly UserService _userService;

public UserServiceTests()
{
// モックの作成
_userRepository = Substitute.For<IUserRepository>();
_userService = new UserService(_userRepository);
}

[Fact]
public void GetUserName_ShouldReturnName_WhenUserExists()
{
// Arrange
var userId = 1;
var expectedName = "Test User";

// モックの振る舞いを定義
_userRepository.GetById(userId).Returns(new User { Id = userId, Name = expectedName });

// Act
var result = _userService.GetUserName(userId);

// Assert
result.ShouldBe(expectedName);
}

[Fact]
public void GetUserName_ShouldReturnUnknown_WhenUserDoesNotExist()
{
// Arrange
_userRepository.GetById(Arg.Any<int>()).Returns((User)null);

// Act
var result = _userService.GetUserName(999);

// Assert
result.ShouldBe("Unknown");
}

[Fact]
public void UpdateUserEmail_ShouldSaveUser_WhenUserExists()
{
// Arrange
var userId = 1;
var user = new User { Id = userId, Name = "Test User", Email = "old@example.com" };
_userRepository.GetById(userId).Returns(user);

// Act
_userService.UpdateUserEmail(userId, "new@example.com");

// Assert
// Saveメソッドが呼び出されたことを検証
_userRepository.Received(1).Save(Arg.Is<User>(u => u.Email == "new@example.com"));
}
}

ベストプラクティス

  1. AAAパターン (Arrange, Act, Assert)

    • テストコードを準備、実行、検証の3つのセクションに分けて記述し、可読性を高めます。
  2. テストメソッドの命名

    • MethodName_StateUnderTesting_ExpectedBehavior のような命名規則を採用し、テストの内容を名前だけで理解できるようにします。
    • 例: Add_WhenInputsAreValid_ShouldReturnSum
  3. 1つのテストで1つの検証

    • 1つのテストメソッドで複数の異なる条件を検証せず、論理的に1つの振る舞いを検証するようにします。
  4. 決定論的なテスト

    • ランダムな値や現在時刻(DateTime.Now)に依存するテストは避け、これらを注入可能な依存関係として扱います。

まとめ

  • xUnit: シンプルで拡張性の高いテストランナー。[Fact][Theory] を使い分ける。
  • Shouldly: 自然言語に近いアサーション記述が可能。失敗時のメッセージが分かりやすい。
  • NSubstitute: 簡潔な構文でモックを作成・操作できる。Returns で戻り値を設定し、Received で呼び出しを検証する。

これらを組み合わせることで、保守性が高く読みやすい単体テストを作成できます。