首頁 >後端開發 >C++ >單元測試中的 MockManager - 用於模擬的建構器模式

單元測試中的 MockManager - 用於模擬的建構器模式

Mary-Kate Olsen
Mary-Kate Olsen原創
2024-12-19 12:27:10237瀏覽

MockManager in unit tests - a builder pattern used for mocks

幾年前我寫過這個,但不太詳細。這是同一想法的更精緻的版本。

簡介

單元測試對開發人員來說既是福也是禍。它們允許快速測試功能、可讀的使用範例、快速實驗所涉及組件的場景。但它們也可能變得混亂,需要在每次程式碼更改時進行維護和更新,並且如果懶惰地完成,則無法隱藏錯誤而不是揭示錯誤。

我認為單元測試如此困難的原因是它與測試相關,而不是程式碼編寫,而且單元測試的編寫方式與我們編寫的大多數其他程式碼相反。

在這篇文章中,我將為您提供一種編寫單元測試的簡單模式,該模式將增強所有好處,同時消除與正常程式碼的大部分認知失調。單元測試將保持可讀性和靈活性,同時減少重複程式碼並且不添加額外的依賴項。

如何進行單元測試

但首先,讓我們先定義一個好的單元測試套件。

要正確測試一個類,必須以某種方式編寫它。在這篇文章中,我們將介紹使用建構函式註入進行依賴項的類,這是我推薦的進行依賴項注入的方法。

然後,為了測試它,我們需要:

  • 涵蓋正面的場景 - 當類別執行其應該執行的操作時,使用設定和輸入參數的各種組合來覆蓋整個功能
  • 涵蓋負面場景 - 當設定或輸入參數錯誤時,類別以正確的方式失敗
  • 模擬所有外部依賴
  • 將所有測試設定、操作和斷言保留在同一個測試中(通常稱為 Arrange-Act-Assert 結構)

但這說來容易做來難,因為它也意味著:

  • 為每個測試設定相同的依賴項,從而複製和貼上大量程式碼
  • 設定非常相似的場景,兩次測試之間僅進行一次更改,再次重複大量程式碼
  • 什麼都不概括和封裝,這是開發人員通常在所有程式碼中所做的事情
  • 為很少的正例寫了很多負例,感覺就像測試程式碼比功能程式碼多
  • 必須為測試類別的每次變更更新所有這些測試

誰喜歡這個?

解決方案

解決方案是使用建構器軟體模式在 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 和兩行 Assert。此外,如果我們想測試快取在類別中的工作原理,我們必須複製和貼上整個內容,然後更改我們設定快取模擬的方式。

還有一些負面測驗需要考慮。我見過許多負面測試做了類似的事情:“設定應該失敗的內容。測試它失敗”,這引入了很多問題,主要是因為它可能會因完全不同的原因而失敗,並且大多數時候這些測試遵循類別的內部實作而不是其要求。正確的陰性測試實際上是完全陽性的測試,只有一個錯誤的條件。為了簡單起見,這裡的情況並非如此。

所以,言歸正傳,這裡是相同的測試,但使用了 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 中執行了三種方法,一種在 Act 中執行,一種在 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中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn