本文與測驗無關。這是關於採用一種工作流程,讓您在開發功能時保持控制。測試只是這個過程的引擎和美好的結果。
這個工作流程徹底改變了我的編碼方式,它總是讓我臉上露出笑容。我希望它對你也有同樣的作用。最後,您將擁有一個完全開發的功能,滿足所有業務規則,以及一個測試套件,可以在不到一秒鐘的時間內對其進行驗證。
我使用 PHP 進行演示,但此工作流程完全適用於任何語言。
當需要開發新功能時,通常很難知道從哪裡開始。您應該從業務邏輯、控制器還是前端開始?
知道何時停止也同樣棘手。如果沒有明確的流程,衡量進度的唯一方法就是手動測試。一種乏味且容易出錯的方法。
你認識這個人。該文件發生了無法解釋的事情。您需要更改一行,但每次嘗試似乎都會破壞整個專案。如果沒有測試的安全網,重構就像在峽谷上走鋼索。
為了避免這種混亂,您決定透過端到端測試來測試每個功能。好主意!直到您意識到在等待測試套件完成時您有足夠的時間喝五杯咖啡。生產力?窗外。
為了建立即時回饋,您決定為所有課程編寫細粒度的測驗。然而,現在您所做的每項更改都會導致一系列損壞的測試,並且您最終會花費更多的時間來修復它們,而不是進行實際的更改。這是令人沮喪的、低效的,並且讓你害怕每一次重構。
回饋是軟體開發每個階段的關鍵。在開發專案核心時,我需要即時回饋以立即了解我的任何業務規則是否被破壞。在重構過程中,有一個助手告訴我程式碼是否被破壞或我是否可以安全地繼續,這是一個無價的優勢。
專案能夠做什麼,專案行為,是最重要的面向。這些行為應該以使用者為中心。即使實現(為使功能正常運作而創建的程式碼)發生變化,使用者的意圖也將保持不變。
例如,當使用者下訂單時,他們希望收到通知。通知使用者的方式有很多種:電子郵件、簡訊、郵件等。雖然使用者的意圖不會改變,但實施往往會改變。
如果測試代表了使用者的意圖並且程式碼不再滿足該意圖,則測試應該失敗。但是,在重構期間,如果程式碼仍然符合使用者的意圖,則測試不應該中斷。
想像一下在創建新功能的過程中一步步被引導。透過先編寫測試,它將成為您編寫正確功能的指南。可能還不完全清楚,但我希望我的測試在編碼時能充當我的 GPS。透過這種方法,我不需要考慮下一步,我只需按照 GPS 指示操作即可。如果這個概念仍然不清楚,別擔心!我將在工作流程部分詳細解釋。 ?
正如我在簡介中提到的,本文不是關於測試,而是關於創建功能。為此,我需要一個功能。更具體地說,我需要一個帶有範例定義的驗收測試的功能。對於每項功能,團隊應建立驗收標準或規則,並為每項規則建立一個或多個範例。
⚠️ 這些範例必須以使用者/網域為中心,不描述任何技術方面。檢查範例是否定義良好的一個簡單方法是問自己:「我的範例是否適用於任何實作(Web 前端、HTTP API、終端 CLI、現實生活等)?」
例如,如果我想開發「將產品加入購物籃」功能,我的範例可能如下所示:
這些範例將作為測試的基礎。對於更複雜的場景,我使用給定/何時/然後模式來獲取更多細節,但對於更簡單的場景則沒有必要。
即時回饋和對行為的關注,這不是一個很大的挑戰嗎?使用 Symfony MVC 服務架構,要實現這一點可能很困難。這就是為什麼我將使用以領域為中心的架構,我在本文中詳細介紹了這一點:建構 Symfony 專案的另一種方法。
為了專注於行為,我需要以正確的方式建立測驗。首先,我描述使用者操作之前系統的狀態。接下來,我執行使用者操作,這會更改系統的狀態。最後,我斷言系統的狀態符合我的期望。
以這種方式測試,測試並不關心系統如何處理使用者操作;它只檢查操作是否成功。這意味著如果我們更改使用者操作的實現,測試不會中斷。但是,如果實作無法如預期更新系統狀態,則測試將中斷-而這正是我們想要的!
為了實現即時回饋,需要模擬系統的某些部分。以域為中心的架構使這成為可能,因為域僅依賴與外部程式庫互動的介面。這使得偽造這些依賴關係變得非常容易,從而使功能測試運行得非常快。當然,真正的實作也將使用整合測試進行測試(儘管不在本文中)。
在這個工作流程中,測試的是我的 GPS!我設定一個目的地,讓它引導我,並在我到達時通知我。測試將根據團隊提供的範例進行。
為了測試業務邏輯和使用者意圖,我使用功能測試:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
此文件將包含此功能的所有測試,但讓我們從第一個開始。
在第一部分中,我描述了應用程式的狀態。這裡,有一個空籃子供 ID 為「1」的顧客使用。
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
handler和command代表了使用者的意圖,這樣就很明確了,大家都明白。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
? :我決定將這個專案中的命令和查詢分開,但我們可以有一個 AddProductToBasketUseCase。
最後,是時候描述一下最終結果應該是什麼樣子了。我希望我的購物籃裡有合適的產品。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
這是將第一個範例轉換為程式碼的測試。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
此時,我的 IDE 到處都顯示錯誤,因為我在此測試中使用的任何內容都不存在。但這些真的是錯誤嗎,還是只是我們邁向目的地的下一步?我將這些錯誤視為指令,每次執行測試時,它都會告訴我們下一步該做什麼。這樣我就不用想太多;我只是跟著GPS走。
? 提示:為了改善開發人員體驗,您可以啟用檔案觀察器,該觀察器會在您進行變更時自動執行測試套件。由於測試套件速度非常快,因此您將在每次更新時獲得即時回饋。
在這個例子中,我將一一解決每個錯誤。當然,如果你有信心,你可以走捷徑。 ?
那麼,讓我們來進行測試吧!對於每個錯誤,我都會執行下一步所需的最低限度的操作。
❌? :「錯誤:找不到類別「AppTestsFunctionalBasket」」
由於這是此功能的第一次測試,因此大多數類別尚不存在。所以第一步,我建立一個籃子物件:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? :「錯誤:找不到類別「AppTestsFunctionalInMemoryBasketRepository」」
我建立了 InMemoryBasketRepository :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? :「錯誤:找不到類別「AppTestsFunctionalAddProductToBasketCommandHandler」」
我在 Catalog/Application 資料夾中建立 AddProductToBasketCommandHandler,該處理程序將成為「將產品新增至購物籃」功能的入口點。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
為了尊重以領域為中心的架構,我為儲存庫創建了一個接口,這樣我們就反轉了依賴關係。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
❌? : 「TypeError : AppCatalogApplicationCommandAddProductToBasketAddProductToBasketCommandHandler::__construct(): 參數 #1 ($basketRepository) 必須是 AppCatalogDomainBasketRepository、AppCatalogetRepository類型給定“
現在,InMemory 實作必須實作該介面才能注入到命令處理程序中。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
❌? :_「錯誤:找不到類別「AppTestsFunctionalAddProductToBasketCommand」」
_
我建立了一個命令,因為命令始終是 DTO,所以我將其標記為唯讀並將所有屬性公開。
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
❌? : 「斷言兩個物件相等失敗。」
測試正在編譯中!它還是紅色的,所以我們還沒有完成,讓我們繼續吧! ?
是時候編寫業務邏輯了。就像我編寫測試時一樣,即使什麼都不存在,我也會編寫我期望處理程序處理命令的方式。它無法編譯,但測試將再次引導我進行下一步。
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
❌? : 「錯誤:呼叫未定義的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::get()」
我在儲存庫介面中建立 get 方法:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? : 「PHP 致命錯誤:類別 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository 包含 1 個抽象方法,因此必須宣告為抽象方法或實作其餘方法 (AppCatalogDomainBasketRepository::getget)」
現在我在 InMemory 儲存庫中實作它。由於它只是為了測試,我創建了一個非常簡單的實作:
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? :
add「錯誤:呼叫未定義的方法 AppCatalogDomainBasket::add()」
我創建
方法並在籃子物件上實現它
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
❌? :
錯誤:呼叫未定義的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::save()
同樣,我在介面中創建方法並在記憶體存儲庫中實現它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
✅? :
目的地到達! ? ? ?
透過這個工作流程,我已經將開發者體驗遊戲化了。在完成紅色測驗的挑戰後,看到它變成綠色,就會注射多巴胺??
重構時間
測試
範例:
檢定:
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
程式碼
由於測驗是綠色的,現在我可以隨心所欲地重構,測驗有我的支持。 ?
其他例子
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
我已經通過不同的測試多次遵循此工作流程,並且我的程式碼的各個部分已經發展到與業務規則保持一致。正如您在第一個測試中看到的那樣,我的物件非常簡單,有時甚至是基本的。然而,隨著新業務規則的引入,它們促使我開發一個更聰明的領域模型。這是我的資料夾結構:
為了簡化測試,同時保留域模型的封裝,我引入了建構器和快照模式。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
我要如何使用它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
由於我完全專注於業務邏輯,因此我的測試會在每一步中指導我,告訴我每次添加新程式碼時要做什麼。此外,我不再需要執行手動測試來確保一切正常,這顯著提高了我的效率。
在本文中,我示範了一個非常簡單的範例,其中我主要建立了類別。然而,隨著業務邏輯更加複雜,每條新規則都會增加領域模型的複雜性。在重構過程中,我的目標是簡化領域模型,同時保持測試綠色。這是一個真正的挑戰,但當它最終發揮作用時,會令人難以置信的滿足。
我在這裡展示的只是完整工作流程的第一部分。正如我所提到的,為了擁有完整的功能,我仍然需要實現真正的適配器。在本例中,這將包括使用測試優先方法和真實資料庫建立儲存庫(購物籃、客戶和產品)。完成後,我只需在依賴項注入配置中配置真正的實作即可。
我使用純PHP,不依賴任何框架,只專注於封裝所有業務邏輯。有了這個策略,我就不用再擔心測試效能了。無論有數百甚至數千個測試,它們仍然會在短短幾秒鐘內運行,在編碼時提供即時回饋。
為了讓您了解效能,這裡是重複 10,000 次的測試套件:
此工作流程位於多個概念的交叉點。讓我介紹其中一些,以便您可以在需要時進一步探索它們。
為了描述一個功能,最好的方法是提供盡可能多的範例。團隊會議可以透過確定該功能如何運作的具體範例來幫助您深入了解該功能。
Given-When-Then 框架是用來形式化這些範例的一個很好的工具。
要將這些範例轉換為程式碼,Gherkin 語言(在 PHP 中使用 Behat)可能會有所幫助。然而,我個人更喜歡直接在 PHPUnit 測試中工作並使用這些關鍵字命名我的函數。
「我們想要什麼?」部分可以用縮寫 F.I.R.S.T 來概括:
現在您有一個清單可以知道您的測驗是否做得很好。
所有這些架構的目的是將業務邏輯與實作細節隔離。無論您使用連接埠和適配器、六角形還是乾淨架構,核心思想都是使業務邏輯框架與框架無關且易於測試。
一旦您記住了這一點,就會有各種各樣的實現,而最好的實現取決於您的上下文和偏好。這種架構的一個主要優點是,透過隔離業務邏輯,它可以實現更有效率的測試。
我猜你已經熟悉了測試金字塔,相反我更喜歡使用菱形表示。 Thomas Pierrain 描述的策略是用例驅動的,重點關注我們應用程式的行為。
此策略還鼓勵對應用程式進行黑盒測試,重點關注其產生的結果,而不是其產生結果的方式。這種方法使重構變得更加容易。
正如您在整個工作流程中看到的那樣,在測試和命令處理程序中,我總是在它存在之前就寫下我想要的內容。我許下了一些「願望」。這種方法稱為一廂情願的程式設計。它允許您在決定如何實現之前想像系統的結構以及與之互動的理想方式。當與測試相結合時,它成為指導您編程的強大方法。
這種模式有助於有效地建立測試。首先,將系統設定為正確的初始狀態。接下來,執行狀態修改器,例如命令或使用者操作。最後,進行斷言以確保系統在操作後處於預期狀態。
我們已經介紹了以領域為中心的架構與驗收測試驅動開發相結合如何改變您的開發體驗。即時回饋、強大的測試以及對用戶意圖的關注使編碼不僅更有效率,而且更有趣。
嘗試這個工作流程,你可能會發現每次測試變綠時你自己都會微笑✅ ➡️ ?
請在評論中告訴我你的完美工作流程是什麼,或者你會在這個工作流程中改進什麼!
以上是在不到一秒的時間內測試您的所有業務邏輯的詳細內容。更多資訊請關注PHP中文網其他相關文章!