幾年前我寫過這個,但不太詳細。這是同一想法的更精緻的版本。
單元測試對開發人員來說既是福也是禍。它們允許快速測試功能、可讀的使用範例、快速實驗所涉及組件的場景。但它們也可能變得混亂,需要在每次程式碼更改時進行維護和更新,並且如果懶惰地完成,則無法隱藏錯誤而不是揭示錯誤。
我認為單元測試如此困難的原因是它與測試相關,而不是程式碼編寫,而且單元測試的編寫方式與我們編寫的大多數其他程式碼相反。
在這篇文章中,我將為您提供一種編寫單元測試的簡單模式,該模式將增強所有好處,同時消除與正常程式碼的大部分認知失調。單元測試將保持可讀性和靈活性,同時減少重複程式碼並且不添加額外的依賴項。
但首先,讓我們先定義一個好的單元測試套件。
要正確測試一個類,必須以某種方式編寫它。在這篇文章中,我們將介紹使用建構函式註入進行依賴項的類,這是我推薦的進行依賴項注入的方法。
然後,為了測試它,我們需要:
但這說來容易做來難,因為它也意味著:
誰喜歡這個?
解決方案是使用建構器軟體模式在 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 方法。
此模式現在使測試編寫與程式碼編寫保持一致:
現在寫單元測驗既簡單又一致:
抽象並不止於模擬框架。相同的模式可以應用於每種程式語言!對於 TypeScript 或 JavaScript 或其他東西來說,模擬管理器建構將非常不同,但單元測試看起來幾乎是一樣的。
希望這有幫助!
以上是單元測試中的 MockManager - 用於模擬的建構器模式的詳細內容。更多資訊請關注PHP中文網其他相關文章!