在這個時代,編寫單元測試的好處是巨大的。我認為大多數最近啟動的項目都包含單元測試。在具有大量業務邏輯的企業應用程式中,單元測試是最重要的測試,因為它們速度很快並且可以讓我們立即確保我們的實作是正確的。然而,我經常在專案中看到良好測試的問題,儘管只有當您擁有良好的單元測試時,這些測試的好處才會巨大。因此,在這些範例中,我將嘗試分享一些有關如何編寫良好的單元測試的技巧。
易於閱讀的版本: https://testing-tips.sarvendev.com/
卡米爾·魯欽斯基
部落格: https://sarvendev.com/
領英: https://www.linkedin.com/in/kamilruczynski/
您的支持對我來說意味著整個世界!如果您喜歡本指南並發現共享知識的價值,請考慮在 BuyMeCoffee 上支持我:
或者只是在存儲庫上留下一個星號,然後在 Twitter 和 Github 上關注我,以了解所有更新。您的慷慨激起了我為您創造更具洞察力的內容的熱情。
如果您有任何改進想法或要寫的主題,請隨時準備好拉取請求或直接告訴我。
訂閱我的免費電子書並掌握單元測試!
細節
我仍然有一個很長的待辦事項列表,其中列出了本關於單元測試的指南的改進,我將在不久的將來介紹它們。
介紹
作者
測試雙打
命名
AAA圖案
對象母親
建設者
斷言對象
參數化測試
單元測試的兩個流派
古典
模仿者
依賴關係
模擬與存根
單元測試的三種風格
輸出
狀態
溝通
功能架構和測試
可觀察的行為與實現細節
行為單位
謙卑的格局
簡單的測試
易碎測試
測試治具
一般測試反模式
暴露私人國家
洩露域名詳細信息
模擬具體類
測試私有方法
時間作為不穩定的依賴項
100% 測試覆蓋率不應該是目標
推薦書籍
測試替身是測試中使用的虛假依賴項。
虛擬物件只是一個簡單的實現,不執行任何操作。
最終類別 Mailer 實作 MailerInterface {公共函數發送(訊息$訊息):無效{ } }
假貨是模擬原始行為的簡化實作。
最終類別 InMemoryCustomerRepository 實作 CustomerRepositoryInterface {/** * @var Customer[] */私有陣列 $customers;公用函數 __construct() {$this->客戶=[]; }public function store(Customer $customer): void{$this->customers[(string) $customer->id()->id()] = $customer; }公用函數 get(CustomerId $id): Customer{if (!isset($this->customers[(string) $id->id()])) {拋出 new CustomerNotFoundException(); }return $this->customers[(string) $id->id()]; }public function findByEmail(Email $email): Customer{foreach ($this->customers as $customer) {if ($customer->getEmail()->isEqual($email)) {return $customer; } }拋出新的CustomerNotFoundException(); } }
存根是具有硬編碼行為的最簡單實作。
最終類別 UniqueEmailSpecificationStub 實作 UniqueEmailSpecificationInterface {公用函數 isUnique(電子郵件 $email): bool{return true; } }
$specationStub = $this->createStub(UniqueEmailSpecificationInterface::class);$specationStub->method('isUnique')->willReturn(true);
間諜是驗證特定行為的實作。
最終類別 Mailer 實作 MailerInterface {/** * @var Message[] */私有數組$messages; 公共函數 __construct() {$this->訊息=[]; }公用函數send(message): void{$this->messages[] = $message; }公用函數 getCountOfSentMessages(): int{return count($this->messages); } }
模擬是一種配置的模仿,用於驗證對協作者的呼叫。
$message = new Message('[email protected]', '測試', '測試測試測試');$mailer = $this->createMock(MailerInterface::class);$mailer->expects($this->一次()) ->方法('發送') ->with($this->equalTo($message));
[!注意] 要驗證傳入的交互,請使用存根,但要驗證傳出的交互,請使用模擬。
更多:模擬與存根
[!
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = $this->createMock(MessageRepositoryInterface::class);$messageRepository ->method('getAll')->willReturn([$message1, $message2]);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);$郵件程式->期望(self::exactly(2))->方法('發送') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!
更好地抵抗重構
對特定方法使用 Refactor->Rename 不會破壞測試
更好的可讀性
降低可維護成本
不需要學習那些複雜的mock框架
只需簡單的純 PHP 程式碼
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository-Repository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->儲存($message2);$mailer = new SpyMailer();$sut = new NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!
公用函數測試(): void{$subscription = SubscriptionMother::new();$subscription->activate();self::assertSame(Status::activated(), $subscription->status()); }
[!TIP|style:flat|label:明確指定您正在測試的內容]
public function sut(): void{// sut = 測試中的系統$sut = SubscriptionMother::new();$sut->activate();self::assertSame(Status::activated(), $sut-> status ()); }
[!
公用函式 it_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void{ }公用函數 testCreatingWithATooShortPasswordIsNotPossible(): void{ }公用函數 testDeactivateASubscription(): void{ }
[!
使用下劃線提高可讀性
名稱應該描述行為,而不是實現
使用沒有技術關鍵字的名稱。它對於非程式設計師來說應該是可讀的。
公用函數sign_in_with_invalid_credentials_is_not_possible():void { }公用函數create_with_a_too_short_password_is_not_possible(): void{ }公共函數 deactivating_an_activated_subscription_is_valid(): void{ }公用函數 deactivating_an_inactive_subscription_is_invalid(): void{ }
筆記
描述行為對於測試領域場景非常重要。如果您的程式碼只是一個實用程序,那麼它就不那麼重要了。
為什麼非程式設計師閱讀單元測驗會有用?
如果有一個專案的領域邏輯很複雜,那麼這個邏輯對每個人來說都必須非常清晰,所以然後測試描述領域細節,而不需要技術關鍵字,你可以用這些測驗中的語言與業務進行對話。所有與域相關的程式碼都應該不含技術細節。非程式設計師不會閱讀這些測驗。如果您想談論該網域,這些測試將有助於了解該網域的用途。會有一些沒有技術細節的描述,例如傳回null、拋出異常等。
也很常見的是「Given」、「When」、「Then」。
測驗分為三個部分:
安排:使受測系統處於所需狀態。準備依賴項、參數並最終建置 SUT。
Act :呼叫經過測試的元素。
Assert :驗證結果、最終狀態或與協作者的溝通。
[!
public function aaa_pattern_example_test(): void{//Arrange|Given$sut = SubscriptionMother::new();//Act|When$sut->activate();//Assert|Thenself::assertSame(Status::activated( ), $sut->status()); }
此模式有助於建立可以在一些測試中重複使用的特定物件。因此,安排部分很簡潔,整個測試更具可讀性。
最後一堂訂閱媽媽課 {public static function new(): 訂閱{return new Subscription(); }公用靜態函式啟動():訂閱{$訂閱=新訂閱();$訂閱->啟動();回傳$訂閱; }public static function deactivated(): Subscription{$subscription = self::activated();$subscription->deactivate();return $subscription; } }
最終類別範例測試 {public function example_test_with_activated_subscription(): void{$activatedSubscription = SubscriptionMother::activated();// 做某件事// 檢查某事}public function example_test_with_deactivated_subscription(): void}public function example_test_with_deactivated_subscription(): voids/1000>>>看到/看到/]]做某事// 檢查一些東西} }
Builder 是另一種幫助我們在測試中建立物件的模式。與物件母體模式產生器相比,它更適合建立更複雜的物件。
最終類別 OrderBuilder {private DateTimeImmutable|null $createdAt = null;/** * @var OrderItem[] */私有陣列 $items = [];public function createdAt(DateTimeImmutable $createdAt): self{$this->createdAt = $createdAtal;return $這個; }public function withItem(string $name, int $price): self{$this->items[] = new OrderItem($name, $price);return $this; }公用函數 build(): 訂單{ 斷言::notEmpty($this->items);傳回新訂單($this->createdAt ?? new DateTimeImmutable(),$this->items, ); } }
最終類別ExampleTest擴充了TestCase {/** * @test */公用函數 example_test_with_order_builder(): void{$order = (new OrderBuilder()) ->createdAt(new DateTimeImmutable('2022-11-10 20:00:00')) ->withItem('項目 1', 1000) ->withItem('項目 2', 2000) ->withItem('項目 3', 3000) ->build();// 做某事// 檢查某事} }
斷言物件模式有助於編寫更具可讀性的斷言部分。我們可以準備一個抽象,並使用自然語言來描述預期的結果,而不是使用一些斷言。
最終類別ExampleTest擴充了TestCase {/** * @test */public function example_test_with_asserter(): void{$currentTime = new DateTimeImmutable('2022-11-10 20:00:00');$sut = new OrderService();$order = $sut ->創建($當前時間); OrderAsserter::assertThat($order) ->wasCreatedAt($currentTime) -> 總數量(6000); } }
使用 PHPUnitFrameworkAssert;最終類別 OrderAsserter {public function __construct(private readonly Order $order) {}public static function assertThat(Order $order): self{return new OrderAsserter($order); }公用函數 wasCreatedAt(DateTimeImmutable $createdAt): self{ 斷言::assertEquals($createdAt, $this->order->createdAt);return $this; }公用函數 hasTotal(int $total): self{ 斷言::assertSame($total, $this->order->getTotal());return $this; } }
參數化測試是一個很好的選擇,可以使用許多參數來測試 SUT,而無需重複程式碼。
警告
這種測試的可讀性較差。為了增加一點可讀性,負面和正面的例子應該分成不同的測驗。
最終類別ExampleTest擴充了TestCase {/** * @test * @dataProvider getInvalidEmails */public function detectors_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($eassermail); $結果); }/** * @test * @dataProvider getValidEmails */public function detectors_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email); $結果); }public function getInvalidEmails(): iterable{yield '一封沒有@的無效電子郵件' => ['test'];yield '@後沒有網域的無效電子郵件' => ['test@'];yield '一封無效電子郵件without TLD' => ['test@test'];//...}public function getValidEmails(): iterable{yield '帶有小寫字母的有效電子郵件' => ['test@test .com'];yield '包含小寫字母和數字的有效電子郵件' => ['[email protected]'];yield '包含大寫字母和數字的有效電子郵件' => ['[email protected] '];//...} }
筆記
使用yield
並為case添加文字描述以提高可讀性。
該單元是單一行為單元,它可以是幾個相關的類別。
每個測試都應該與其他測試隔離。因此必須可以並行或以任何順序調用它們。
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數 suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy()asser$sut = new Subscription(); ($結果);self::assertSame(狀態::掛起(), $sut->status()); } }
該單元是一個班級。
該單位應與所有合作者隔離。
最終類別 TestExample 擴展了 TestCase {/** * @test */public 函式 suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterm| sut = 新訂閱();$result = $sut->掛起($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(狀態::掛起(), $sut->status() ); } }
筆記
經典方法最好避免脆弱的測試。
[待辦事項]
例子:
最終類通知服務 {public function __construct(private readonly MailerInterface $mailer,private readonly MessageRepositoryInterface $messageRepository) {}public function send(): void{$messages = $this->messageRepository-getAll(); $this->郵件程式->發送($message); } } }
[!
斷言與存根的交互作用會導致脆弱的測試
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = $this->createMock(MessageRepositoryInterface::class);$messageRepository ->method('getAll')->willReturn([$message1, $message2]);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);$ messageRepository->expects(self::once())->method('getAll');$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository-Repository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);// 刪除了與存根的交互斷言$mailer - >期望(self::exactly(2))->方法('發送') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|style:flat|label:使用 SPY 效果更好]
最終類別 TestExample 擴展了 TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository-Repository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->儲存($message2);$mailer = new SpyMailer();$sut = new NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!TIP|樣式:扁平|標籤:最佳選擇]
最好的重構抵抗力
最佳準確度
最低的可維護性成本
如果可能的話,你應該更喜歡這種測試
最終類別ExampleTest擴充了TestCase {/** * @test * @dataProvider getInvalidEmails */public function detectors_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($eassermail); $結果); }/** * @test * @dataProvider getValidEmails */public function detectors_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email); $結果); }公用函數 getInvalidEmails(): 陣列{return [ ['測試'], ['測試@'], ['測試@測試'],//...]; }公用函數 getValidEmails(): 陣列{return [ ['[email protected]'], ['[email protected]'], ['[email protected]'],//...]; } }
[!
更糟糕的重構阻力
準確度較差
可維護性成本較高
最終類別ExampleTest擴充了TestCase {/** * @test */公用函數adding_an_item_to_cart(): void{$item = new CartItem('Product');$sut = new Cart();$sut->addItem($item);self::assertSame (1, $sut->getCount());self::assertSame($item, $sut->getItems()[0]); } }
[!注意|風格:扁平|標籤:最糟糕的選擇]
重構的最大阻力
最差的準確度
可維護性成本最高
最終類別ExampleTest擴充了TestCase {/** * @test */公用函數send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository-Repository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);$mailer->expects(self::exactly( 2))->方法('發送') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!
最終類別NameService {公用函數__construct(私有唯讀CacheStorageInterface $cacheStorage) {}公用函式loadAll(): void{$namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv'));$names = [ ];foreach ($namesCsv as $nameData) {if (!isset($nameData[0], $nameData[1])) {繼續; }$names[] = 新名稱($nameData[0], 新性別($nameData[1])); }$this->cacheStorage->store('names', $names); } }
如何測試這樣的程式碼?這只能透過整合測試來實現,因為它直接使用與檔案系統相關的基礎設施程式碼。
[!
就像在函數式架構中一樣,我們需要將具有副作用的程式碼和僅包含邏輯的程式碼分開。
最終類別NameParser {/** * @param array<string[]> $namesData * @return Name[] */public function parse(array $namesData): array{$names = [];foreach ($namesData as $nameData) {if (!isset($nameData[0], $nameData[1])) {繼續; }$names[] = 新名稱($nameData[0], 新性別($nameData[1])); }回傳$名稱; } }
最終類別 CsvNamesFileLoader {公用函數load(): array{return array_map('str_getcsv', file(__DIR__.'/../names.csv')); } }
最終類別ApplicationService {public function __construct(private readonly CsvNamesFileLoader $fileLoader,private readonly NameParser $parser,private readonly CacheStorageInterface $cacheStorage) {}public function loadNames(): void{$namesader =this-load. $this->解析器->parse($namesData);$this->cacheStorage->store('names', $names); } }
最終類別 ValidUnitExampleTest 擴展了 TestCase {/** * @test */public function parse_all_names(): void{$namesData = [ ['約翰','M'], ['列儂','U'], ['莎拉','W'] ];$sut = new NameParser();$result = $sut->parse($namesData); 自我::斷言相同( [新名字('約翰',新性別('M')),新名字('列儂',新性別('U')),新名字('莎拉',新性別('W')) ],$結果); } }
[!
最終類別ApplicationService {public function __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}public function renewSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionIdsubion ($subscriptionIdsubscription); ->isEqual(Status::expired())) {回傳false; }$subscription->setStatus(Status::active());$subscription->setModifiedAt(new DateTimeImmutable());回傳 true; } }
最後一堂課訂閱 {public function __construct(private Status $status, private DateTimeImmutable $modifiedAt) {}public function getStatus(): Status{return $this->status; }公用函數 setStatus(狀態 $status): void{$this->status = $status; }公用函數 getModifiedAt(): DateTimeImmutable{return $this->modifiedAt; }公用函式 setModifiedAt(DateTimeImmutable $modifiedAt): void{$this->modifiedAt = $modifiedAt; } }
最終類別 InvalidTestExample 擴展了 TestCase {/** * @test */public 函數 renew_an_expired_subscription_is_possible(): void{$modifiedAt = new DateTimeImmutable();$expiredSubscription = new Subscription(Status::expired(), $modifiedAt);$sServiceA ->createRepository($expiredSubscription));$result = $sut->renewSubscription(1);self::assertSame(Status::active(), $expiredSubscription->getStatus());self::assertGreaterThan($modmodAt, $expiredSubscription->getModifiedAt());self::assertTrue($result); }/** * @test */public 函式 renew_an_active_subscription_is_not_possible(): void{$modifiedAt = new DateTimeImmutable();$activeSubscription = new Subscription(Status::active(), $modifiedAt);$activeSubscription = new Subscription(Status::active(), $modifiedAt);$sut = new ApplicationS$ ->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());self::assertFalse($result); } private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface{傳回新類別 ($expiredSubscription) 實作 SubscriptionRepositoryInterface {public function __construct(private readonly Subscription $subscription) {} 公用函數 findById(int $id): 訂閱{return $this->訂閱; } }; } }
[!
最終類別ApplicationService {public function __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}public function renewSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionIdImmable(newsubscription); )); } }
最後一堂課訂閱 {私有狀態$status;私有DateTimeImmutable $modifiedAt;公用函數__construct(DateTimeImmutable $modifiedAt) {$this->status = Status::new();$this->modifiedAt = $modifiedAt; }public function renew(DateTimeImmutable $modifiedAt): bool{if (!$this->status->isEqual(Status::expired())) {return false; } }$this->status = Status::active();$this->modifiedAt = $modifiedAt;回傳 true; }public function active(DateTimeImmutable $modifiedAt): void{//簡化$this->status = Status::active();$this->modifiedAt = $modifiedAt; }public function expire(DateTimeImmutable $modifiedAt): void{//簡化$this->status = Status::expired();$this->modifiedAt = $modifiedAt; }公用函數 isActive(): bool{return $this->status->isEqual(Status::active()); } }
最終類 ValidTestExample 擴展了 TestCase {/** * @test */public function renew_an_expired_subscription_is_possible(): void{$expiredSubscription = SubscriptionMother::expired();$sut = new ApplicationService($this->createRepository($expiredSubscription)); >renewSubscription(1);// 跳過檢查modifiedAt,因為它不是可觀察行為的一部分。要檢查這個值,我們//必須為modifiedAt加上一個getter,可能只用於測試目的。 }/** * @test */public function renew_an_active_subscription_is_not_possible(): void{$activeSubscription = SubscriptionMother::active();$sut = new ApplicationService($this->createRepository($activeSubscription));$result = $ut-$this->createRepository($activeSubscription));$result = $ut-$this-> >renewSubscription(1);self::assertTrue($activeSubscription->isActive());self::assertFalse($result); } private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface{傳回新類別 ($expiredSubscription) 實作 SubscriptionRepositoryInterface {public function __construct(private readonly Subscription $subscription) {} 公用函數 findById(int $id): 訂閱{return $this->訂閱; } }; } }
筆記
第一個訂閱模式的設計很糟糕。要呼叫一項業務操作,您需要呼叫三個方法。另外,使用 getter 來驗證操作也不是一個好的做法。在這種情況下,它會跳過檢查modifiedAt
的更改,可能在續訂作業期間設定特定的modifiedAt
可以透過過期業務操作進行測試。 modifiedAt
的 getter 不是必需的。當然,在某些情況下,要找到避免僅為測試提供 getter 的可能性會非常困難,但我們始終應該盡量不要引入它們。
[!
類別 CannotSuspendExpiredSubscriptionPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱 $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {回傳 false; }返回真; } }
類別 CannotSuspendExpiredSubscriptionPolicyTest 擴展了 TestCase {/** * @test */public function it_returns_false_when_a_subscription_is_expired(): void{$policy = new CannotSuspendExpiredSubscriptionPolicy();$subscription = $this->createStub(Subscription::s( ->willReturn(true);self::assertFalse($policy->suspend($subscription, new DateTimeImmutable())); }/** * @test */public function it_returns_true_when_a_subscription_is_not_expired(): void{$policy = new CannotSuspendExpiredSubscriptionPolicy();$subscription = $this->createStub(Subscription-method(Subscription-method( ->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
類別 CannotSuspendNewSubscriptionPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱 $subscription,DateTimeImmutable $at): bool{if ($subscription->isNew()) {回傳 false; }返回真; } }
類別 CannotSuspendNewSubscriptionPolicyTest 擴充 TestCase {/** * @test */public function it_returns_false_when_a_subscription_is_new(): void{$policy = new CannotSuspendNewSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription::class); ->willReturn(true);self::assertFalse($policy->suspend($subscription, new DateTimeImmutable())); }/** * @test */public function it_returns_true_when_a_subscription_is_not_new(): void{$policy = new CannotSuspendNewSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription::class); ->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
類別 CanSuspendAfterOneMonthPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱$subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmable: : createFromMutable($oneMonthEarlierDate)); } }
CanSuspendAfterOneMonthPolicyTest 類別擴展了 TestCase {/** * @test */public 函數it_returns_true_when_a_subscription_is_older_than_one_month(): void{$date = new DateTimeImmutable('2021-01-29');$policy = new CanSuspendAfterOnethPolate(Mate); ) 2020-12-28'));self::assertTrue($policy->suspend($subscription, $date)); }/** * @test */public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void{$date = new DateTimeImmutable('2021-01-29');$policy = new DateTimeImmutable('2021-01-29');$policy = new Datedate(Im); ) 2020-01-01'));self::assertTrue($policy->suspend($subscription, $date)); } }
班級狀態 {private const EXPIRED = '過期';private const ACTIVE = '活動';private const NEW = '新';private const SUSPENDED = '掛起';私有函數 __construct(private readonly string $status) {$this->狀態=$狀態; }公共靜態函數expired(): self{傳回新的self(self::EXPIRED); }public static function active(): self{傳回新的 self(self::ACTIVE); }公用靜態函數new(): self{傳回新的self(self::NEW); }公共靜態函數掛起(): self{返回新的self(self::SUSPENDED); }public function isEqual(self $status): bool{return $this->status === $status->status; } }
類別 StatusTest 擴充 TestCase {公用函數 testEquals(): void{$status1 = Status::active();$status2 = Status::active();self::assertTrue($status1->isEqual($status2)); }公用函數 testNotEquals(): void{$status1 = Status::active();$status2 = Status::expired();self::assertFalse($status1->isEqual($status2)); } }
類別 SubscriptionTest 擴充 TestCase {/** * @test */public 函數 suspending_a_subscription_is_possible_when_a_policy_returns_true(): void{$policy = $this->createMock(SuspendingPolicyInterface::class);$policy->expects($this->method());$policy-> '掛起')->willReturn(true);$sut = 新訂閱(new DateTimeImmutable());$result = $sut->掛起($policy, new DateTimeImmutable());self::assertTrue($result ); self::assertTrue($sut->isSuspended()); }/** * @test */公用函式 suspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void{$policy = $this->createMock(SuspendingPolicy::class);$policy->expects($this-fod( '掛起')->willReturn(false);$sut = 新訂閱(new DateTimeImmutable());$result = $sut->掛起($policy, new DateTimeImmutable());self::tFalse($result ); self::assertFalse($sut->isSuspended()); }/** * @test */public function it_returns_true_when_a_subscription_is_older_than_one_month(): void{$date = new DateTimeImmutable();$futureDate = $date->add(new DateInterval('P1M');$futureDate = $date->add(new DateInterval('P1M');$futureDate = $date->add(new DateInterval('P1M');日期);self::assertTrue($sut->isOlderThan($futureDate)); }/** * @test */public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void{$date = new DateTimeImmutable();$futureDate = $date->add(new DateIntervalion('P1D');日期);self::assertTrue($sut->isOlderThan($futureDate)); } }
[!注意]不要以 1:1、1 類別 : 1 測試的方式編寫程式碼。它導致脆弱的測試,使得重構變得困難。
[!
最終類別 CannotSuspendExpiredSubscriptionPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱 $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {回傳 false; }返回真; } }
最終類別 CannotSuspendNewSubscriptionPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱 $subscription,DateTimeImmutable $at): bool{if ($subscription->isNew()) {回傳 false; }返回真; } }
最終類別 CanSuspendAfterOneMonthPolicy 實作 SuspendingPolicyInterface {公用函數掛起(訂閱$subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmable: : createFromMutable($oneMonthEarlierDate)); } }
最後一堂課狀態 {private const EXPIRED = '過期';private const ACTIVE = '活動';private const NEW = '新';private const SUSPENDED = '掛起';私有函數 __construct(private readonly string $status) {$this->狀態=$狀態; }公共靜態函數expired(): self{傳回新的self(self::EXPIRED); }public static function active(): self{傳回新的 self(self::ACTIVE); }公用靜態函數new(): self{傳回新的self(self::NEW); }公共靜態函數掛起(): self{返回新的self(self::SUSPENDED); }public function isEqual(self $status): bool{return $this->status === $status->status; } }
最後一堂課訂閱 {私有狀態$status;私有DateTimeImmutable $createdAt;公用函數__construct(DateTimeImmutable $createdAt) {$this->status = Status::new();$this->createdAt = $createdAt; }公用函數掛起(SuspendingPolicyInterface $ suspendingPolicy,DateTimeImmutable $ at):bool {$結果= $ suspendingPolicy - >掛起($ this,$ at); if($結果){$ 掛 - >狀態=狀態::掛起() ; }回傳$結果; }公用函數 isOlderThan(DateTimeImmutable $date): bool{return $this->createdAt < $date; }公用函式activate(): void{$this->status = Status::active(); }公用函數expire(): void{$this->status = Status::expired(); }公用函數 isExpired(): bool{return $this->status->isEqual(Status::expired()); }公用函數 isActive(): bool{return $this->status->isEqual(Status::active()); }公用函數 isNew(): bool{return $this->status->isEqual(Status::new()); }公用函數 isSuspended(): bool{return $this->status->isEqual(Status::suspended()); } }
最終類別 SubscriptionSuspendingTest 擴充了 TestCase {/** * @test */公用函式 suspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate(pult); -> 暫停(新的CannotSuspendExpiredSubscriptionPolicy(),新的DateTimeImmutable()); self::assertFalse($結果); }/** * @test */公用函數 suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$result = $sut->suspend(new DateTimeImmutable());$result = $sut->suspend(newbateImm); ::assertFalse($結果); }/** * @test */公用函式 suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result =$ress>Suult; , new DateTimeImmutable());self::assertTrue($result); }/** * @test */公用函式 suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $notd-doodprodicdic; , new DateTimeImmutable());self::assertTrue($result); }/** * @test */public function suspending_an_subscription_before_a_one_month_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result new DateTimeImmutable('2020-01-10'));self::assertFalse($result); }/** * @test */public function suspending_an_subscription_after_a_one_month_is_possible(): void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result = $sut-Suspend(new,new, Month)(new)5) 月new DateTimeImmutable('2020-02-02'));self::assertTrue($result); } }
如何正確地對這樣的類別進行單元測試?
應用服務類 {public function __construct(private readonly OrderRepository $orderRepository,private readonly FormRepository $formRepository) {}public function changeFormStatus(int $orderId): void{$order = $this->orderRepository->getById($Reponse); this->getSoapClient()->getStatusByOrderId($orderId);$form = $this->formRepository->getByOrderId($orderId);$form->setStatus($soapResponse['status']);$form->setModifiedAtAtAtAtAtus($soapResponse['status']);$form->setModifiedAtAtAtAtAt (new DateTimeImmutable());if ($soapResponse['status'] === '已接受') {$order->setStatus('paid'); }$this->formRepository->save($form);$this->orderRepository->save($order); }私有函數 getSoapClient(): SoapClient{return new SoapClient('https://legacy_system.pl/Soap/WebService', []); } }
[!
需要將過於複雜的程式碼拆分為單獨的類別。
最終類別ApplicationService {公用函數__construct(私人唯讀OrderRepositoryInterface $orderRepository,私人唯讀FormRepositoryInterface $formRepository,私人唯讀FormApiInterface $formApi,私人唯讀ChangeFormStatusService $changeFormStatusService) {}公用函數$this->orderRepository ->getById($orderId);$form = $this->formRepository->getByOrderId($orderId);$status = $this->formApi->getStatusByOrderId($orderId);$this->changeFormStatusService ->changeStatus($ order, $form, $status);$this->formRepository->save($form);$this->orderRepository->save($order); } }
最終類別 ChangeFormStatusService {公用函數changeStatus(訂單$order,表單$form,字串$formStatus): void{$status = FormStatus::createFromString($formStatus);$form->changeStatus($status);if ($form->isAccepted ( )) {$order->changeStatus(OrderStatus::paid()); } } }
最終類別ChangingFormStatusTest擴充了TestCase {/** * @test */公用函數changing_a_form_status_to_accepted_changes_an_order_status_to_paid(): void{$order = new Order();$form = new Form();$status = '已接受';$sut = new ChangeForm$us(); sut ->changeStatus($order, $form, $status);self::assertTrue($form->isAccepted());self::assertTrue($order->isPaid()); }/** * @test */公用函數changing_a_form_status_to_refused_not_changes_an_order_status(): void{$order = new Order();$form = new Form();$status = 'new';$sut = new ChangeFormStatusService();$s;$sut = new ChangeFormStatusService(); ->changeStatus($order, $form, $status);self::assertFalse($form->isAccepted());self::assertFalse($order->isPaid()); } }
但是,ApplicationService 可能應該透過僅使用模擬的 FormApiInterface 進行整合測試來進行測試。
[!
最後一堂課客戶 {public function __construct(private string $name) {}public function getName(): string{return $this->name; }公用函數 setName(string $name): void{$this->name = $name; } }
最終類別 CustomerTest 擴充了 TestCase {public function testSetName(): void{$customer = new Customer('Jack');$customer->setName('John');self::assertSame('John', $customer->getName()); } }
最終類別 EventSubscriber {public static function getSubscribedEvents(): array{return ['event' => 'onEvent']; }公用函式 onEvent(): void{ } }
最終類別 EventSubscriberTest 擴展了 TestCase {公用函式 testGetSubscribedEvents(): void{$result = EventSubscriber::getSubscribedEvents();self::assertSame(['event' => 'onEvent'], $result); } }
[!注意] 在沒有任何複雜邏輯的情況下測試程式碼是沒有意義的,而且還會導致脆弱的測試。
[!
最終類別 UserRepository {public function __construct(private readonly Connection $connection) {}public function getUserNameByEmail(string $email): ?array{return $this->connection->createQueryBuilder() ->來自('用戶', 'u') ->where('u.email = :email') ->setParameter('email', $email) ->執行() ->獲取(); } }
最終類別 TestUserRepository 擴展了 TestCase {公用函數 testGetUserNameByEmail(): void{$email = '[email protected]';$connection = $this->createMock(Connection::class);$queryBuilder = $this->createMock(QueryBuilder::class); $result = $this->createMock(ResultStatement::class);$userRepository = new UserRepository($connection);$connection->expects($this->once()) ->方法('createQueryBuilder') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->方法('來自') ->with('用戶', 'u') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->方法('哪裡') ->with('u.email = :email') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->方法('設定參數') ->with('電子郵件', $電子郵件) ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->方法('執行') ->willReturn($result);$result->expects($this->once()) -> 方法('獲取') ->willReturn(['email' => $email]);$result = $userRepository->getUserNameByEmail($email);self::assertSame(['email' => $email], $result); } }
[!注意] 以這種方式測試儲存庫會導致測試脆弱,然後重構就很困難。要測試儲存庫,請編寫整合測試。
[!
最終類別 GoodTest 擴充了 TestCase {private SubscriptionFactory $sut;public function setUp(): void{$this->sut = new SubscriptionFactory(); }/** * @test */public function create_a_subscription_for_a_given_date_range(): void{$result = $this->sut->create(new DateTimeImmutable(), new DateTimeImmutable('現在+1年'));selff::tInstanceOd (訂閱::類,$結果); }/** * @test */public function throws_an_exception_on_invalid_date_range(): void{$this->expectException(CreateSubscriptionException::class); $result = $this->sut->create(new DateTimeImmutable('現在-1年'), new DateTimeImmutable()); } }
筆記
使用 setUp 方法的最佳情況是測試無狀態物件。
setUp
中進行的任何配置都會將測試結合在一起,並對所有測試產生影響。
最好避免測試之間共用狀態並根據測試方法配置初始狀態。
與透過正確的測試方法進行的配置相比,可讀性較差。
[!
最終類別 BetterTest 擴展了 TestCase {/** * @test */公用函式 suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void{$sut = $this->createAnActiveSubscription();$result = $newsut->suspend(new CannotAnspy), nscription); :assertTrue($結果); }/** * @test */公用函式 suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void{$sut = $this->createAnActiveSubscription();$result = $sut->suspend(new CanbateIpiredm); :assertTrue($結果); }/** * @test */公用函式 suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void{$sut = $this->createANewSubscription();$result = $sut->suspend(new CannotSuwate,Iubscription); :assertFalse($結果); }私有函數 createANewSubscription(): 訂閱{return new Subscription(new DateTimeImmutable()); }私有函數 createAnActiveSubscription(): 訂閱{$subscription = new Subscription(new DateTimeImmutable());$subscription->activate(); 返回$訂閱; } }
筆記
這種方法提高了可讀性並澄清了分離(程式碼的讀多於寫)。
儘管私人助理提供了明確的意圖,但在每個測試方法中使用它們可能很乏味。
若要在多個測試類別之間共用相似的測試對象,請使用:
對象母親
建設者
[!
最後一堂課客戶 {私有 CustomerType $type;私有 DiscountCalculationPolicyInterface $discountCalculationPolicy;公用函數 __construct() {$this->type = CustomerType::NORMAL();$this->discountCalculationPolicy = new NormalDiscountPolicy(); }公用函數 makeVip(): void{$this->type = CustomerType::VIP();$this->discountCalculationPolicy = new VipDiscountPolicy(); }公用函數 getCustomerType(): CustomerType{return $this->type; }公用函數 getPercentageDiscount(): int{return $this->discountCalculationPolicy->getPercentageDiscount(); } }
最終類別 InvalidTest 擴展了 TestCase {公用函式 testMakeVip(): void{$sut = new Customer();$sut->makeVip();self::assertSame(CustomerType::VIP(), $sut->getCustomerType()); } }
[!
最後一堂課客戶 {私有 CustomerType $type;私有 DiscountCalculationPolicyInterface $discountCalculationPolicy;公用函數 __construct() {$this->type = CustomerType::NORMAL();$this->discountCalculationPolicy = new NormalDiscountPolicy(); }公用函數 makeVip(): void{$this->type = CustomerType::VIP();$this->discountCalculationPolicy = new VipDiscountPolicy(); }公用函數 getPercentageDiscount(): int{return $this->discountCalculationPolicy->getPercentageDiscount(); } }
最終類 ValidTest 擴展了 TestCase {/** * @test */public function a_vip_customer_has_a_25_percentage_discount(): void{$sut = new Customer();$sut->makeVip();self::assertSame(25, $sut->getPercentageDiscount()); } }
[!注意] 僅為了驗證測試中的狀態而添加額外的生產程式碼(例如 getter getCustomerType())是一種不好的做法。它應該由另一個域有效值(在本例中為 getPercentageDiscount())進行驗證。當然,有時很難找到另一種方法來驗證操作,我們可能會被迫添加額外的生產程式碼來驗證測試的正確性,但我們應該盡量避免這種情況。
最後一課折扣計算器 {公用函數計算(int $isVipFromYears): int{ 斷言::greaterThanEq($isVipFromYears, 0);return min(($isVipFromYears * 10) + 3, 80); } }
[!
最終類別 InvalidTest 擴展了 TestCase {/** * @dataProvider discountDataProvider */public function testCalculate(int $vipDaysFrom, int $expected): void{$sut = new DiscountCalculator();self::assertSame($expected, $sut-Daycalte($vipculasFrom) ); }公用函數discountDataProvider(): array{return [ [0, 0 * 10 + 3], //洩漏網域詳細資料[1, 1 * 10 + 3], [5, 5 * 10 + 3], [8, 80] ]; } }
[!
最終類 ValidTest 擴展了 TestCase {/** * @dataProvider discountDataProvider */public function testCalculate(int $vipDaysFrom, int $expected): void{$sut = new DiscountCalculator();self::assertSame($expected, $sut-Daycalte($vipculasFrom) ); }公用函數discountDataProvider(): array{return [ [0, 3], [1, 13], [5, 53], [8, 80] ]; } }
筆記
不要在測試中重複生產邏輯。只需透過硬編碼值驗證結果即可。
[!
折扣計算器類 {公用函數calculateInternalDiscount(int $isVipFromYears): int{ 斷言::greaterThanEq($isVipFromYears, 0);return min(($isVipFromYears * 10) + 3, 80); }public functioncalculateAdditionalDiscountFromExternalSystem(): int{//從外部系統取得資料計算折扣return 5; } }
訂單服務類 {public function __construct(private readonly DiscountCalculator $discountCalculator) {}public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int{$internalDiscount = $this-discountCalInter樣> >discountCalculator->calculateAdditionalDiscountFromExternalSystem();$discountSum = $internalDiscount + $externalDiscount;回傳$totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); } }
最終類別 InvalidTest 擴展了 TestCase {/** * @dataProvider orderDataProvider */public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void{$discountCalculator = $this->createPartialMock(Discountculator,class,]); $discountCalculator->method('calculateAdditionalDiscountFromExternalSystem')->willReturn(5);$sut = new OrderService($discountCalculator);self::assertSame($expected, $sut->getTotalPriceWithDiscount($toSame($expected, $sut->getTotalPriceWithDiscount($toithPrice,a); }公用函數 orderDataProvider(): 陣列{return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
[!
接口 外部折扣計算器接口 {公共函數calculate(): int; }
最終類別InternalDiscountCalculator {公用函數計算(int $isVipFromYears): int{ 斷言::greaterThanEq($isVipFromYears, 0);return min(($isVipFromYears * 10) + 3, 80); } }
最終類別 OrderService {public function __construct(private readonly InternalDiscountCalculator $discountCalculator,private readonly externalDiscountCalculatorInterface $externalDiscountCalculator) {}public function getTotalPriceWithDiscount(int $totalal, int $alvip. $vipFromDays); $externalDiscount = $this->externalDiscountCalculator->calculate();$discountSum = $internalDiscount + $externalDiscount;回傳$totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); } }
最終類 ValidTest 擴展了 TestCase {/** * @dataProvider orderDataProvider */public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void{$externalDiscountCalculator = new class() 實作問題(InterternalDiscount } };$sut = new OrderService(new InternalDiscountCalculator(), $externalDiscountCalculator);self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom)); }公用函數 orderDataProvider(): 陣列{return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
筆記
需要模擬具體類別來替換其部分行為,這意味著該類別可能過於複雜並且違反了單一職責原則。
最終類訂單項 {公共函數 __construct(公共 readonly int $total) {} }
最後一堂課順序 {/** * @param OrderItem[] $items * @param int $transportCost */public function __construct(private array $items, private int $transportCost) {}public function getTotal(): int{return $this->getItemsTotal () + $this->運輸成本; }私人函數 getItemsTotal(): int{return array_reduce(array_map(fn (OrderItem $item) => $item->total, $this->items),fn (int $sum, int $total) => $sum + = $總計,0); } }
[!
最終類別 InvalidTest 擴展了 TestCase {/** * @test * @dataProviderordersDataProvider */public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void{self::assertSame($expectedTotal, $ }/** * @test * @dataProvider orderItemsDataProvider */public function get_items_total_returns_a_total_cost_of_all_items(Order $order, int $expectedTotal): void{selfasser::tSame($expectedTotal, $ateItem }公用函數ordersDataProvider():陣列{返回[ [新訂單項([新訂單項(20), 新訂單項(20), 新訂單項目(20)], 15), 75], [新訂單項([新訂單項目(20), 新訂單項目(30), 新訂單項目(40)], 0), 90], [新訂單([新訂單項目(99),新訂單項目(99),新訂單項目(99)],9),306] ]; }公用函數 orderItemsDataProvider(): 陣列{return [ [新訂單項([新訂單項(20), 新訂單項(20), 新訂單項目(20)], 15), 60], [新訂單項([新訂單項目(20), 新訂單項(30), 新訂單項目(40)], 0), 90], [新訂單([新訂單項目(99),新訂單項目(99),新訂單項目(99)],9),297] ]; }私有函數invokePrivateMethodGetItemsTotal(Order &$order): int{$reflection = new ReflectionClass(get_class($order));$method = $reflection->getMethod('getItemsTotal');$method->setAccessible(true);return $method->invokeArgs($order, []); } }
[!
最終類 ValidTest 擴展了 TestCase {/** * @test * @dataProviderordersDataProvider */public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void{self::assertSame($expectedTotal, $ }公用函數ordersDataProvider():陣列{返回[ [新訂單項([新訂單項(20), 新訂單項(20), 新訂單項目(20)], 15), 75], [新訂單項([新訂單項(20), 新訂單項(30), 新訂單項目(40)], 0), 90], [新訂單([新訂單項目(99),新訂單項目(99),新訂單項目(99)],9),306] ]; } }
[!注意] 測試應該只驗證公共 API。
時間是一個不穩定的依賴項,因為它是不確定的。每次呼叫都會傳回不同的結果。
[!
最後一堂課時鐘 {public static DateTime|null $currentDateTime = null;public static function getCurrentDateTime(): DateTime{if (null === self::$currentDateTime) {self::$currentDateTime = new DateTime(); }返回自我::$currentDateTime; }公用靜態函式集(DateTime $dateTime): void{self::$currentDateTime = $dateTime; }公共靜態函式reset(): void{self::$currentDateTime = null; } }
最後一堂課客戶 {私有日期時間$createdAt;公用函數__construct() {$this->createdAt = Clock::getCurrentDateTime(); }公用函數 isVip(): bool{return $this->createdAt->diff(Clock::getCurrentDateTime())->y >= 1; } }
最終類別 InvalidTest 擴展了 TestCase {/** * @test */公用函數a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void{ 時鐘::設定(new DateTime('2019-01-01'));$sut = new Customer(); 時鐘::重置(); // 你必須記得重置共享狀態 self::assertTrue($sut->isVip()); }/** * @test */公用函式a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void{ 時鐘::設定((new DateTime())->sub(new DateInterval('P2M')));$sut = new Customer(); 時鐘::重置(); // 你必須記得重置共享狀態 self::assertFalse($sut->isVip()); } }
[!
接口 時脈介面 {公用函數 getCurrentTime(): DateTimeImmutable; }
最終類別 Clock 實作 ClockInterface {私有函數__construct() { }公用靜態函數create(): self{return new self(); }公用函數 getCurrentTime(): DateTimeImmutable{return new DateTimeImmutable(); } }
最終類別 FixClock 實作 ClockInterface {私有函數 __construct(私有 readonly DateTimeImmutable $fixedDate) {}公用靜態函式 create(DateTimeImmutable $fixedDate): self{return new self($fixedDate); }公用函數 getCurrentTime(): DateTimeImmutable{return $this->fixedDate; } }
最後一堂課客戶 {public function __construct(private readonly DateTimeImmutable $createdAt) {}public function isVip(DateTimeImmutable $currentDate): bool{return $this->createdAt->diff($currentDate)->y >= 1; } }
最終類 ValidTest 擴展了 TestCase {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void{$sut = new Customer(FixedClock::create(new DateTimeImmutable('2019-01-019-01-01)-01-008)-01-008)-01-0019-01-008)-01-008)-01-008); assertTrue($sut->isVip(FixedClock::create(new DateTimeImmutable('2020-01-02'))->getCurrentTime())); }/** * @test */public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void{$sut = new Customer(FixedClock::create(new DateTimeImmutable('2019-01-01)); assertFalse($sut->isVip(FixedClock::create(new DateTimeImmutable('2019-05-02'))->getCurrentTime())); } }
筆記
時間和隨機數不應直接在域代碼中產生。為了測試行為,我們必須有確定性的結果,因此我們需要將這些值注入到域物件中,如上例所示。
100% 覆蓋率不是目標,甚至是不可取的,因為如果有 100% 覆蓋率,測試可能會非常脆弱,這意味著重構將非常困難。突變測試可以更好地反饋測試品質。閱讀更多
測試驅動開發:舉例 / Kent Beck - 經典
單元測試原則、實踐和模式 / Vladimir Khorikov - 我讀過的關於測試的最好的書
卡米爾·魯欽斯基
推特: https://twitter.com/Sarvendev
部落格: https://sarvendev.com/
領英: https://www.linkedin.com/in/kamilruczynski/