次の方法で共有


.NET の単体テストのベスト プラクティス

単体テストを記述する利点は多数あります。 回帰に役立ち、ドキュメントを提供し、優れた設計を容易にします。 しかし、単体テストが読みにくく、壊れにくい場合は、コード ベースに大混乱を引き起こす可能性があります。 この記事では、.NET Core および .NET Standard プロジェクトをサポートするための単体テストを設計するためのベスト プラクティスについて説明します。 テストの回復性を維持し、理解しやすい方法を学習します。

ジョン・リースよりロイ・オシェロヴに特別な感謝を込めて

ユニット テストのメリット

次のセクションでは、.NET Core および .NET Standard プロジェクトの単体テストを記述するいくつかの理由について説明します。

機能テストの実行時間の短縮

機能テストはコストがかかります。 通常、アプリケーションを開き、期待される動作を検証するためにユーザー (または他のユーザー) が従う必要がある一連の手順を実行する必要があります。 これらの手順は、常にテスターに認識されるとは限りません。 彼らはテストを実行するために、その地域でより知識のある人に手を差し伸べる必要があります。 テスト自体は、簡単な変更には数秒かかることができ、大きな変更の場合は数分かかる場合があります。 最後に、システムで行うすべての変更に対して、このプロセスを繰り返す必要があります。 一方、単体テストはミリ秒かかっており、ボタンを押すと実行でき、システムに関する知識は必ずしも必要ありません。 テストランナーがテストの合否を判断し、個人が判断するわけではありません。

回帰の防止

回帰の欠陥は、アプリケーションに変更が加えられたときに発生するエラーです。 テスト担当者は、新しい機能をテストするだけでなく、事前に存在していた機能をテストして、既存の機能が引き続き期待どおりに機能することを確認するのが一般的です。 単体テストでは、ビルドのたびに、またはコード行を変更した後でも、一連のテスト全体を再実行できます。 この方法は、新しいコードが既存の機能を壊さないという確信を高めるのに役立ちます。

実行可能なドキュメント

特定のメソッドが何を行うか、または特定の入力によってどのように動作するかは、必ずしも明らかであるとは限りません。 空白の 文字列または null を渡すと、このメソッドはどのように動作しますか。 一連の適切な名前の単体テストがある場合、各テストでは、特定の入力に対して予想される出力を明確に説明する必要があります。 さらに、テストは実際に動作することを確認できる必要があります。

結合が少ないコード

コードが密結合されていると、単体テストが困難になる場合があります。 作成しているコードの単体テストを作成しないと、結合があまり明らかでない場合があります。 したがって、コードに対するテストを記述する際には、必然的にコードを分離します。そうしないと、テストが困難になるためです。

優れた単体テストの特性

適切な単体テストを定義する重要な特性がいくつかあります。

  • 高速: 成熟したプロジェクトで何千もの単体テストを実施することは珍しくありません。 単体テストの実行には少し時間がかかります。 ミリ秒。
  • 分離: 単体テストはスタンドアロンであり、分離して実行でき、ファイル システムやデータベースなどの外部要因への依存関係はありません。
  • 反復可能: 単体テストの実行は、その結果と一致している必要があります。 実行の間に何も変更しない場合、テストは常に同じ結果を返します。
  • 自己チェック: テストは、人間の操作なしで成功または失敗したかどうかを自動的に検出する必要があります。
  • タイムリー: 単体テストでは、テスト対象のコードと比較して、書き込みに大きな時間を要してはなりません。 コードのテストにコードの記述と比較して時間がかかることが判明した場合は、よりテスト可能な設計を検討してください。

コードカバレッジとコード品質

多くの場合、コード カバレッジの割合が高いと、コードの品質が向上します。 ただし、測定自体はコードの品質を判断 できません 。 過度に野心的なコード カバレッジ率の目標を設定すると、逆効果になる可能性があります。 何千もの条件付き分岐を含む複雑なプロジェクトについて考えて、95% コード カバレッジの目標を設定するとします。 現在、このプロジェクトは 90% のコード カバレッジを維持しています。 残りの 5% のすべてのエッジ ケースを考慮に入れるのにかかる時間は膨大な作業になり、価値提案はすぐに減少します。

コードカバレッジの割合が高くても、それが成功の指標であるとは限らず、コードの品質が高いことを意味するわけではありません。 単体テストでカバーされるコードの量を表すだけです。 詳細については、 単体テストのコード カバレッジに関する記事を参照してください。

単体テストの用語

単体テストのコンテキストでは、 モックスタブなど、いくつかの用語が頻繁に使用されます。 残念ながら、これらの用語は誤って適用される可能性があるため、正しい使用方法を理解することが重要です。

  • 偽物: 偽物は、スタブまたはモック オブジェクトを記述するために使用できる一般的な用語です。 オブジェクトがスタブかモックかは、オブジェクトが使用されるコンテキストによって異なります。 つまり、フェイクはスタブとモックのどちらにもなりえます。

  • モック: モック オブジェクトは、単体テストに合格するか失敗するかを決定するシステム内の偽のオブジェクトです。 モックは最初から偽物であり、Assert 操作に入るまでその状態のままです。

  • スタブ: スタブは、システム内の既存の依存関係 (またはコラボレーター) の制御可能な代替です。 スタブを使用すると、依存関係を直接処理せずにコードをテストできます。 既定では、スタブは最初はフェイクとして機能します。

次のコードについて考えてみましょう。

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

このコードは、モックと呼ばれるスタブを示しています。 しかし、このシナリオでは、スタブは純粋にスタブとしてのみ機能します。 コードの目的は、 Purchase (テスト対象のシステム) オブジェクトをインスタンス化する手段として順序を渡すことです。 順序はスタブであり、モックではないため、クラス名 MockOrder は誤解を招きます。

次のコードは、より正確な設計を示しています。

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

クラスの名前を FakeOrder に変更すると、クラスはより汎用的になります。 このクラスは、テスト ケースの要件に従って、モックまたはスタブとして使用できます。 最初の例では、 FakeOrder クラスはスタブとして使用され、 Assert 操作中は使用されません。 このコードは、コンストラクターの要件を満たすために、 FakeOrder クラスを Purchase クラスに渡します。

このクラスをモックとして使用するには、コードを更新します。

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

この設計では、コードは偽物のプロパティをチェックします (それに対してアサートする)、したがって、 mockOrder クラスはモックです。

Von Bedeutung

用語を正しく実装することが重要です。 スタブを "モック" と呼ぶと、他の開発者が意図について誤った想定を行う予定です。

モックとスタブについて覚えておくべき主な点は、モックは Assert プロセスを除いて、スタブと同じことです。 モック オブジェクトに対して Assert 操作を実行しますが、スタブに対して実行することはできません。

ベスト プラクティス

単体テストを記述するときは、いくつかの重要なベスト プラクティスに従う必要があります。 次のセクションでは、コードにベスト プラクティスを適用する方法を示す例を示します。

インフラストラクチャの依存関係を回避する

単体テストの作成時にインフラストラクチャに依存関係を導入しないようにしてください。 依存関係により、テストは遅く、脆弱になり、統合テスト用に予約する必要があります。 明示的な依存関係 の原則 に従い、 .NET 依存関係の挿入を使用することで、アプリケーションでこれらの依存関係を回避できます。 統合テストとは別のプロジェクトに単体テストを保持することもできます。 この方法により、単体テスト プロジェクトがインフラストラクチャ パッケージへの参照や依存関係を持たないようにします。

テストの名前付け標準に従う

テストの名前は、次の 3 つの部分で構成されている必要があります。

  • テスト対象のメソッドの名前
  • メソッドがテストされているシナリオ
  • シナリオが呼び出されたときの予期される動作

名前付け標準は、テストの目的とアプリケーションを表すのに役立つため、重要です。 テストは、コードが動作することを確認するだけではありません。 また、ドキュメントも提供します。 単体テストのスイートを見るだけで、コードの動作を推測でき、コード自体を見る必要はありません。 さらに、テストが失敗した場合、どのシナリオが期待を満たしていないかを正確に確認できます。

元のコード

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

ベスト プラクティスを適用する

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

テストを配置する

"準備、行動、検証" パターンは、単体テストを記述するための一般的なアプローチです。 名前が示すように、パターンは次の 3 つの主要なタスクで構成されます。

  • オブジェクトを配置し、必要に応じて作成し、構成する
  • オブジェクトに対して操作する
  • 何かが期待どおりになっていることをアサートする

パターンに従うと、テスト対象を配置タスクと Assert タスクから明確に分離できます。 このパターンは、Act タスクのコードとアサーションが混在する機会を減らすのにも役立ちます。

読みやすさは、単体テストを記述するときの最も重要な側面の 1 つです。 テスト内で各パターン アクションを分離すると、コードの呼び出しに必要な依存関係、コードの呼び出し方法、アサートしようとしている内容が明確に強調表示されます。 いくつかの手順を組み合わせてテストのサイズを小さくすることは可能ですが、全体的な目標は、テストを可能な限り読みやすくすることです。

元のコード

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

ベスト プラクティスを適用する

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

最小限の情報で合格するテストを記述する

単体テストの入力は、現在テストしている動作を確認するために必要な最も簡単な情報である必要があります。 最小限のアプローチは、コードベースの将来の変更に対するテストの回復力を高め、実装に対する動作の検証に焦点を当てるのに役立ちます。

現在のテストに合格するために必要以上の情報を含むテストでは、テストにエラーが発生する可能性が高くなり、テストの意図が明確でなくなる可能性があります。 テストを記述するときは、動作に焦点を当てる必要があります。 モデルに追加のプロパティを設定することや、必要ない場合に0以外の値を使用することは、確認しようとしている内容を損なうだけです。

元のコード

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

ベスト プラクティスを適用する

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

魔法の文字列を避ける

マジック文字列 は、コードの追加コメントやコンテキストなしで、単体テストで直接ハードコーディングされた文字列値です。 これらの値により、コードの読みやすさが低下し、保守が困難になります。 マジック文字列は、テストのリーダーに混乱を引き起こす可能性があります。 文字列が通常の値から外れている場合、パラメーターまたは戻り値に対して特定の値が選択された理由を疑問に思うかもしれません。 この種類の文字列値は、テストに焦点を当てるのではなく、実装の詳細を詳しく調べるようになる可能性があります。

ヒント

単体テスト コードでできるだけ多くの意図を表現することを目標にします。 マジック文字列を使用するのではなく、ハードコーディングされた値を定数に割り当てます。

元のコード

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

ベスト プラクティスを適用する

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

単体テストでのコーディング ロジックの回避

単体テストを記述するときは、手動での文字列連結、 ifwhileforswitchなどの論理条件、およびその他の条件は避けてください。 テスト スイートにロジックを含める場合、バグが発生する可能性が大幅に高くなります。 バグを見つける最後の場所は、テスト スイート内にあります。 テストが機能する高いレベルの信頼度が必要です。それ以外の場合は、それらを信頼できません。 信頼できないテストでは、値は提供されません。 テストが失敗した場合、コードに問題があり、無視できないという感覚が必要です。

ヒント

テストにロジックを追加するのは避けられないと思われる場合は、ロジック要件を制限するために、テストを 2 つ以上の異なるテストに分割することを検討してください。

元のコード

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

ベスト プラクティスを適用する

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

初期化とティアダウンの代わりにヘルパーメソッドを使用する

テストに同様のオブジェクトまたは状態が必要な場合は、 Setup ではなくヘルパー メソッドを使用し、属性が存在する場合は Teardown します。 ヘルパー メソッドは、いくつかの理由から、これらの属性よりも優先されます。

  • 各テスト内からすべてのコードが表示されるため、テストの読み取り時の混乱が少なくなります
  • 指定されたテストに対して設定が多すぎる、または設定が少なすぎる可能性が低い
  • テスト間で状態を共有する可能性が低く、テスト間に不要な依存関係が作成されます

単体テスト フレームワークでは、テスト スイート内の各単体テストの前に、 Setup 属性が呼び出されます。 一部のプログラマは、この動作が役に立つと見なしますが、多くの場合、テストが肥大化し、読みにくい結果になります。 通常、各テストには、セットアップと実行に関して異なる要件があります。 残念ながら、 Setup 属性を使用すると、各テストでまったく同じ要件を使用するように強制されます。

SetUp属性とTearDown属性は、xUnit バージョン 2.x 以降では削除されます。

元のコード

ベスト プラクティスを適用する

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

複数の Act タスクを回避する

テストを記述するときは、テストごとに 1 つの Act タスクのみを含めてみてください。 1 つの Act タスクを実装するための一般的な方法としては、Act ごとに個別のテストを作成したり、パラメーター化されたテストを使用したりする方法があります。 テストごとに 1 つの Act タスクを使用すると、いくつかの利点があります。

  • テストが失敗した場合に失敗している Act タスクを簡単に識別できます。
  • テストが 1 つのケースのみに集中していることを確認できます。
  • テストが失敗した理由を明確に把握できます。

複数の Act タスクを個別にアサートする必要があり、すべての Assert タスクが実行されることを保証することはできません。 ほとんどの単体テスト フレームワークでは、単体テストで Assert タスクが失敗した後、後続のすべてのテストは自動的に失敗と見なされます。 一部の機能が失敗と解釈される可能性があるため、このプロセスは混乱する可能性があります。

元のコード

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

ベスト プラクティスを適用する

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

パブリック メソッドを使用してプライベート メソッドを検証する

ほとんどの場合、コードでプライベート メソッドをテストする必要はありません。 プライベート メソッドは実装の詳細であり、単独では存在しません。 開発プロセスのある時点で、実装の一部としてプライベート メソッドを呼び出す公開メソッドを導入します。 単体テストを記述するときに気になるのは、プライベート メソッドを呼び出すパブリック メソッドの最終的な結果です。

次のコード シナリオを考えてみましょう。

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

テストの観点から、最初の反応は、 TrimInput メソッドが期待どおりに動作することを確認するためのテストを記述することです。 ただし、 ParseLogLine メソッドが予期しない方法で sanitizedInput オブジェクトを操作する可能性があります。 不明な動作により、 TrimInput メソッドに対するテストが役に立たない可能性があります。

このシナリオのより良いテストは、一般向けの ParseLogLine メソッドを確認することです。

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

プライベート メソッドが発生したら、プライベート メソッドを呼び出すパブリック メソッドを見つけ、パブリック メソッドに対してテストを記述します。 プライベート メソッドが予期した結果を返すからといって、最終的にプライベート メソッドを呼び出すシステムで結果が正しく使用されるわけではありません。

シームによりスタブの静的参照を処理する

単体テストの原則の 1 つは、テスト対象のシステムを完全に制御する必要があるということです。 ただし、運用環境のコードに静的参照の呼び出し ( DateTime.Now など) が含まれている場合、この原則は問題になる可能性があります。

次のコード シナリオを確認します。

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

このコードの単体テストを記述できますか? priceで Assert タスクを実行してみてください。

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

残念ながら、テストに問題があることにすぐに気付きます。

  • テスト スイートが火曜日に実行された場合、2 番目のテストは成功しますが、最初のテストは失敗します。
  • テスト スイートが他の日に実行された場合、最初のテストは成功しますが、2 番目のテストは失敗します。

これらの問題を解決するには、運用コードに 継ぎ目 を導入する必要があります。 1 つの方法は、インターフェイスで制御する必要があるコードをラップし、運用コードがそのインターフェイスに依存するようにすることです。

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

また、テスト スイートの新しいバージョンを記述する必要もあります。

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

これで、テスト スイートは DateTime.Now 値を完全に制御できるようになり、メソッドを呼び出すときに任意の値をスタブできます。