ホームページ >バックエンド開発 >C++ >単体テストの MockManager - モックに使用されるビルダー パターン

単体テストの MockManager - モックに使用されるビルダー パターン

Mary-Kate Olsen
Mary-Kate Olsenオリジナル
2024-12-19 12:27:10222ブラウズ

MockManager in unit tests - a builder pattern used for mocks

数年前、私はこれについて書きましたが、それほど詳しくはありませんでした。同じアイデアのさらに洗練されたバージョンを次に示します。

イントロ

単体テストは開発者にとって恩恵でもあり、害でもあります。これらにより、機能の迅速なテスト、わかりやすい使用例、関係するコンポーネントのみのシナリオの迅速な実験が可能になります。しかし、煩雑になる可能性があり、コードを変更するたびにメンテナンスと更新が必要になり、怠惰に実行すると、バグを明らかにするどころか隠すことができなくなります。

単体テストが非常に難しい理由は、単体テストがコード作成以外のテストに関連していることと、単体テストが私たちが作成する他のほとんどのコードとは逆の方法で作成されるためだと思います。

この投稿では、通常のコードとの認知的不協和のほとんどを排除しながら、すべての利点を強化する単体テストを作成する簡単なパターンを紹介します。単体テストは可読性と柔軟性を維持しながら、重複コードを減らし、余分な依存関係を追加しません。

単体テストのやり方

しかし、その前に、優れた単体テスト スイートを定義しましょう。

クラスを適切にテストするには、クラスを特定の方法で記述する必要があります。この投稿では、依存関係のコンストラクター注入を使用するクラスについて説明します。これは、依存関係の注入を行う私が推奨する方法です。

次に、それをテストするには、次のことを行う必要があります:

  • 肯定的なシナリオをカバーします - 機能全体をカバーするためにセットアップと入力パラメーターのさまざまな組み合わせを使用して、クラスが本来の動作を実行する場合
  • ネガティブなシナリオ - セットアップまたは入力パラメータが間違っているときにクラスが正しい方法で失敗する場合をカバーします
  • すべての外部依存関係をモックします
  • テストのセットアップ、アクション、アサーションのすべてを同じテスト内に保持します (通常、Arrange-Act-Assert 構造と呼ばれるもの)

しかし、これは言うは易く行うは難し、次のことも意味するからです。

  • すべてのテストに同じ依存関係を設定するため、多くのコードをコピーして貼り付ける必要があります
  • 2 つのテスト間で 1 つだけ変更を加え、非常によく似たシナリオを設定し、再び大量のコードを繰り返します
  • 何も一般化してカプセル化しない。これは開発者がすべてのコードで通常行うことです
  • 少数の肯定的なケースに対して多くの否定的なケースを作成すると、機能コードよりもテスト コードの方が多くなるように感じられます
  • テスト対象のクラスに変更を加えるたびに、これらのテストをすべて更新する必要があります

それが好きな人はいるでしょうか?

解決

解決策は、ビルダー ソフトウェア パターンを使用して、Arrange-Act-Assert 構造で流動的で柔軟で読みやすいテストを作成し、同時に特定のサービスの単体テスト スイートを補完するクラスにセットアップ コードをカプセル化することです。私はこれを MockManager パターンと呼んでいます。

簡単な例から始めましょう:

// the tested class
public class Calculator
{
    private readonly ITokenParser tokenParser;
    private readonly IMathOperationFactory operationFactory;
    private readonly ICache cache;
    private readonly ILogger logger;

    public Calculator(
        ITokenParser tokenParser,
        IMathOperationFactory operationFactory,
        ICache cache,
        ILogger logger)
    {
        this.tokenParser = tokenParser;
        this.operationFactory = operationFactory;
        this.cache = cache;
        this.logger = logger;
    }

    public int Calculate(string input)
    {
        var result = cache.Get(input);
        if (result.HasValue)
        {
            logger.LogInformation("from cache");
            return result.Value;
        }
        var tokens = tokenParser.Parse(input);
        IOperation operation = null;
        foreach(var token in tokens)
        {
            if (operation is null)
            {
                operation = operationFactory.GetOperation(token.OperationType);
                continue;
            }
            if (result is null)
            {
                result = token.Value;
                continue;
            }
            else
            {
                if (result is null)
                {
                    throw new InvalidOperationException("Could not calculate result");
                }
                result = operation.Execute(result.Value, token.Value);
                operation = null;
            }
        }
        cache.Set(input, result.Value);
        logger.LogInformation("from operation");
        return result.Value;
    }
}

伝統どおり、これは電卓です。文字列を受け取り、整数値を返します。また、特定の入力の結果をキャッシュし、いくつかの情報をログに記録します。実際の操作は IMathOperationFactory によって抽象化され、入力文字列は ITokenParser によってトークンに変換されます。心配しないでください。これは実際のクラスではなく、単なる例です。 「従来の」テストを見てみましょう:

[TestMethod]
public void Calculate_AdditionWorks()
{
    // Arrange
    var tokenParserMock = new Mock<ITokenParser>();
    tokenParserMock
        .Setup(m => m.Parse(It.IsAny<string>()))
        .Returns(
            new List<CalculatorToken> {
                CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
            }
        );

    var mathOperationFactoryMock = new Mock<IMathOperationFactory>();

    var operationMock = new Mock<IOperation>();
    operationMock
        .Setup(m => m.Execute(1, 1))
        .Returns(2);

    mathOperationFactoryMock
        .Setup(m => m.GetOperation(OperationType.Add))
        .Returns(operationMock.Object);

    var cacheMock = new Mock<ICache>();
    var loggerMock = new Mock<ILogger>();

    var service = new Calculator(
        tokenParserMock.Object,
        mathOperationFactoryMock.Object,
        cacheMock.Object,
        loggerMock.Object);

    // Act
    service.Calculate("");

    //Assert
    mathOperationFactoryMock
        .Verify(m => m.GetOperation(OperationType.Add), Times.Once);
    operationMock
        .Verify(m => m.Execute(1, 1), Times.Once);
}

少し開梱してみましょう。たとえば、実際にはロガーやキャッシュを気にしない場合でも、コンストラクターの依存関係ごとにモックを宣言する必要がありました。オペレーション ファクトリの場合、別のモックを返すモック メソッドも設定する必要がありました。

この特定のテストでは、セットアップの大部分、Act の 1 行と Assert の 2 行を書きました。さらに、クラス内でキャッシュがどのように機能するかをテストしたい場合は、全体をコピーして貼り付け、キャッシュ モックの設定方法を変更するだけで済みます。

そして、検討すべき陰性検査もあります。私は、「失敗するはずのものだけをセットアップし、失敗することをテストする」というようなことを行う否定的なテストを多く見てきました。これは多くの問題を引き起こします。主な理由は、まったく異なる理由で失敗する可能性があり、ほとんどの場合、これらのテストが失敗するためです。クラスの要件ではなく、クラスの内部実装に従っています。適切な陰性テストは、実際には、条件が 1 つだけ間違っているだけで完全に陽性となるテストです。簡単にするために、ここでは当てはまりません。

それでは、これ以上苦労せずに、MockManager を使用した同じテストを示します。

[TestMethod]
public void Calculate_AdditionWorks_MockManager()
{
    // Arrange
    var mockManager = new CalculatorMockManager()
        .WithParsedTokens(new List<CalculatorToken> {
            CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
        })
        .WithOperation(OperationType.Add, 1, 1, 2);

    var service = mockManager.GetService();

    // Act
    service.Calculate("");

    //Assert
    mockManager
        .VerifyOperationExecute(OperationType.Add, 1, 1, Times.Once);
}

解凍では、セットアップが必要ないため、キャッシュやロガーについては言及されていません。全てが詰まっていて読み応えがあります。これをコピーして貼り付け、いくつかのパラメーターや行を変更することは、もはや醜いものではありません。 Arrange では 3 つのメソッドが実行され、1 つは Act で、もう 1 つは Assert で実行されます。核心的なモックの詳細のみが抽象化されています。ここでは Moq フレームワークについては言及されていません。実際、このテストは、使用するモック フレームワークに関係なく、同じように見えます。

MockManager クラスを見てみましょう。これは複雑に見えますが、これを一度書くだけで何度も使用することに注意してください。クラス全体の複雑さは、単体テストを人間が読みやすく、理解し、更新、保守しやすいものにするためにあります。

public class CalculatorMockManager
{
    private readonly Dictionary<OperationType,Mock<IOperation>> operationMocks = new();

    public Mock<ITokenParser> TokenParserMock { get; } = new();
    public Mock<IMathOperationFactory> MathOperationFactoryMock { get; } = new();
    public Mock<ICache> CacheMock { get; } = new();
    public Mock<ILogger> LoggerMock { get; } = new();

    public CalculatorMockManager WithParsedTokens(List<CalculatorToken> tokens)
    {
        TokenParserMock
            .Setup(m => m.Parse(It.IsAny<string>()))
            .Returns(
                new List<CalculatorToken> {
                    CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
                }
            );
        return this;
    }

    public CalculatorMockManager WithOperation(OperationType operationType, int v1, int v2, int result)
    {
        var operationMock = new Mock<IOperation>();
        operationMock
            .Setup(m => m.Execute(v1, v2))
            .Returns(result);

        MathOperationFactoryMock
            .Setup(m => m.GetOperation(operationType))
            .Returns(operationMock.Object);

        operationMocks[operationType] = operationMock;

        return this;
    }

    public Calculator GetService()
    {
        return new Calculator(
                TokenParserMock.Object,
                MathOperationFactoryMock.Object,
                CacheMock.Object,
                LoggerMock.Object
            );
    }

    public CalculatorMockManager VerifyOperationExecute(OperationType operationType, int v1, int v2, Func<Times> times)
    {
        MathOperationFactoryMock
            .Verify(m => m.GetOperation(operationType), Times.AtLeastOnce);
        var operationMock = operationMocks[operationType];
        operationMock
            .Verify(m => m.Execute(v1, v2), times);
        return this;
    }
}

テスト クラスに必要なモックはすべてパブリック プロパティとして宣言されているため、単体テストをカスタマイズできます。 GetService メソッドがあり、すべての依存関係が完全にモック化された、テストされたクラスのインスタンスを常に返します。次に、さまざまなシナリオをアトミックに設定し、常にモック マネージャーを返す With* メソッドがあるため、チェーンできるようになります。アサーション用の特定のメソッドを使用することもできますが、ほとんどの場合、出力を期待値と比較することになるため、これらは Moq フレームワークの Verify メソッドを抽象化するためだけにここにあります。

結論

このパターンは、テストの記述とコードの記述を調整するようになりました:

  • どんな文脈でも気にしないものを抽象化する
  • 一度書いたら何度も使える
  • 人間が読める、自己文書化されたコード
  • 循環的複雑性が低い小さなメソッド
  • 直感的なコード記述

今単体テストを書くのは簡単で一貫性があります:

  1. テストしたいクラスのモックマネージャーをインスタンス化します (または上記の手順に基づいてモックマネージャーを作成します)
  2. テスト用の特定のシナリオを作成します (すでにカバーされている既存のシナリオ ステップについてはオートコンプリートを使用します)
  3. テストパラメータを使用してテストしたいメソッドを実行します
  4. すべてが期待どおりであることを確認してください

抽象化はモックフレームワークにとどまりません。同じパターンはどのプログラミング言語にも適用できます。モック マネージャーの構造は、TypeScript や JavaScript などでは大きく異なりますが、単体テストはほぼ同じになります。

これがお役に立てば幸いです!

以上が単体テストの MockManager - モックに使用されるビルダー パターンの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。