このような時代において、単体テストを書くことのメリットは非常に大きいです。最近開始されたプロジェクトのほとんどには単体テストが含まれていると思います。多くのビジネス ロジックを含むエンタープライズ アプリケーションでは、単体テストが最も重要なテストです。単体テストは高速であり、実装が正しいことを即座に確認できるからです。ただし、プロジェクト内の優れたテストには問題があることがよくあります。ただし、これらのテストの利点は、優れた単体テストが存在する場合にのみ大きく現れます。したがって、これらの例では、優れた単体テストを作成するために何をすべきかについて、いくつかのヒントを共有したいと思います。
読みやすいバージョン: https://testing-tips.sarvendev.com/
カミル・ルチンスキ
ブログ: https://sarvendev.com/
LinkedIn: https://www.linkedin.com/in/kamilruczynski/
あなたのサポートは私にとって世界を意味します!このガイドを気に入っていただき、共有された知識に価値があると感じた場合は、BuyMeCoffee で私をサポートすることを検討してください。
または、単にリポジトリにスターを付けて、Twitter と Github で私をフォローして、すべての更新情報を入手してください。あなたの寛大な気持ちが、より洞察力に富んだコンテンツを作成するという私の情熱に火をつけます。
改善のアイデアや書きたいトピックがある場合は、お気軽にプル リクエストを準備するか、私に知らせてください。
無料の電子書籍を購読して単体テストをマスターしましょう!
詳細
単体テストに関するこのガイドの改善に関するかなり長い TODO リストがまだあります。近い将来、それらを紹介する予定です。
導入
著者
テストダブルス
ネーミング
AAAパターン
オブジェクトの母
ビルダー
オブジェクトのアサート
パラメータ化されたテスト
単体テストの 2 つの流派
クラシック
モック主義者
依存関係
モック vs スタブ
単体テストの 3 つのスタイル
出力
州
コミュニケーション
機能アーキテクチャとテスト
観察可能な動作と実装の詳細
行動の単位
謙虚なパターン
簡単なテスト
脆弱性試験
テストフィクスチャ
一般的なテストのアンチパターン
プライベートな状態を公開する
ドメイン詳細の漏洩
具象クラスのモック化
プライベートメソッドのテスト
不安定な依存関係としての時間
100% のテスト カバレッジを目標にするべきではありません
おすすめの本
テスト ダブルは、テストで使用される偽の依存関係です。
ダミーは何も行わない単純な実装です。
最終クラス Mailer は MailerInterface を実装します {パブリック関数 send(Message $message): void{ } }
フェイクは、元の動作をシミュレートするための簡略化された実装です。
最終クラス InMemoryCustomerRepository は CustomerRepositoryInterface を実装します {/** * @var Customer[] */プライベート配列 $customers;パブリック関数 __construct() {$this->顧客 = []; }パブリック関数ストア(Customer $customer): void{$this->customers[(string) $customer->id()->id()] = $customer; } public function get(CustomerId $id): Customer{if (!isset($this->customers[(string) $id->id()])) {throw 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() をスローします。 } }
スタブは、ハードコードされた動作を持つ最も単純な実装です。
最終クラス UniqueEmailSpecificStub は UniqueEmailSpecificInterface を実装します {パブリック関数 isUnique(Email $email): bool{return true; } }
$specationStub = $this->createStub(UniqueEmailSpecificInterface::class);$specationStub->method('isUnique')->willReturn(true);
スパイは、特定の動作を検証するための実装です。
最終クラス Mailer は MailerInterface を実装します {/** * @var Message[] */private 配列 $messages; パブリック関数 __construct() {$this->メッセージ = []; }パブリック関数 send(Message $message): void{$this->messages[] = $message; }パブリック関数 getCountOfSentMessages(): int{return count($this->messages); } }
モックは、コラボレーターの呼び出しを検証するために構成された模倣です。
$message = new Message('[email protected]', 'Test', 'Test test test');$mailer = $this->createMock(MailerInterface::class);$mailer->expects($this->一度()) ->メソッド('送信') ->with($this->equalTo($message));
[!ATTENTION] 受信インタラクションを検証するにはスタブを使用しますが、出力インタラクションを検証するにはモックを使用します。
詳細: モックとスタブ
[!警告|スタイル:フラット|ラベル:良くありません]
最終クラス TestExample は TestCase を拡張します {/** * @test */public function sends_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);$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|スタイル:フラット|ラベル:BETTER]
リファクタリングに対する耐性の向上
特定のメソッドで Refactor->Rename を使用してもテストは中断されません
可読性の向上
保守コストの削減
洗練されたモックフレームワークを学ぶ必要はありません
シンプルなプレーン PHP コードだけ
最終クラス TestExample は TestCase を拡張します {/** * @test */public function sends_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = 新しい SpyMailer();$sut = 新しい NoticeService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!警告|スタイル:フラット|ラベル:良くありません]
public function test(): 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{ }
[!TIP|スタイル:フラット|ラベル:BETTER]
アンダースコアを使用すると読みやすくなります
名前は実装ではなく動作を説明する必要があります
技術的なキーワードを含まない名前を使用してください。プログラマーでない人でも読めるはずです。
パブリック関数sign_in_with_invalid_credentials_is_not_possible(): void{ }パブリック関数creating_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」も一般的です。
テストを 3 つのセクションに分けます。
Arrange : テスト対象のシステムを希望の状態にします。依存関係と引数を準備し、最後に SUT を構築します。
Act : テストされた要素を呼び出します。
Assert : 結果、最終状態、または共同作業者とのコミュニケーションを検証します。
[!TIP|スタイル:フラット|ラベル:GOOD]
public function aaa_pattern_example_test(): void{//Arrange|Given$sut = SubscriptionMother::new();//Act|When$sut->activate();//Assert|Thenself::assertSame(Status::activated( )、$sut->status()); }
このパターンは、いくつかのテストで再利用できる特定のオブジェクトを作成するのに役立ちます。そのため、アレンジセクションが簡潔になり、テスト全体が読みやすくなります。
最終クラス定期購読母 {パブリック静的関数 new(): サブスクリプション{return new Subscription(); }パブリック静的関数 activate(): サブスクリプション{$subscription = new Subscription();$subscription->activate();return $subscription; }パブリック静的関数 deactivated(): サブスクリプション{$subscription = self::activated();$subscription->deactivate();return $subscription; } }
最終クラス ExampleTest {public function example_test_with_activated_subscription(): void{$activatedSubscription = SubscriptionMother::activated();// 何かをする// 何かをチェックする}public function example_test_with_deactivated_subscription(): void{$deactivatedSubscription = SubscriptionMother::deactivated();// 何かをする// 何かをチェックしてください} }
ビルダーは、テストでオブジェクトを作成するのに役立つもう 1 つのパターンです。 Object Mother と比較して、パターン ビルダーはより複雑なオブジェクトの作成に適しています。
最終クラス OrderBuilder {private DateTimeImmutable|null $createdAt = null;/** * @var OrderItem[] */private array $items = [];public function createdAt(DateTimeImmutable $createdAt): self{$this->createdAt = $createdAt;return $this; }public function withItem(string $name, int $price): self{$this->items[] = new OrderItem($name, $price);return $this; }パブリック関数 build(): オーダー{ Assert::notEmpty($this->items);return new Order($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();// 何かをする // 何かをチェックする} }
Assert オブジェクト パターンは、より読みやすい Assert セクションを作成するのに役立ちます。いくつかのアサートを使用する代わりに、抽象化を準備し、自然言語を使用して期待される結果を説明することができます。
最終クラス ExampleTest は TestCase を拡張します {/** * @test */public function example_test_with_asserter(): void{$currentTime = new DateTimeImmutable('2022-11-10 20:00:00');$sut = new OrderService();$order = $sut ->作成($currentTime); OrderAsserter::assertThat($order) ->wasCreatedAt($currentTime) ->hasTotal(6000); } }
PHPUnitFrameworkAssert を使用する;最終クラス OrderAsserter {public function __construct(private readonly Order $order) {}public static functionassertThat(Order $order): self{return new OrderAsserter($order); }パブリック関数 wasCreatedAt(DateTimeImmutable $createdAt): self{ Assert::assertEquals($createdAt, $this->order->createdAt);return $this; }パブリック関数 hasTotal(int $total): self{ Assert::assertSame($total, $this->order->getTotal());return $this; } }
パラメーター化テストは、コードを繰り返さずに多くのパラメーターを使用して SUT をテストする場合に適したオプションです。
警告
この種のテストは読みにくくなります。可読性を少し高めるために、否定的な例と肯定的な例を異なるテストに分割する必要があります。
最終クラス ExampleTest は TestCase を拡張します {/** * @test * @dataProvider getInvalidEmails */public function detects_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $結果); }/** * @test * @dataProvider getValidEmails */public function detects_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $結果); }public function getInvalidEmails(): iterable{yield '@ のない無効なメール' => ['test'];yield '@ の後にドメインのない無効なメール' => ['test@'];yield '無効なメールTLD なし' => ['test@test'];//...}public function getValidEmails(): iterable{yield '小文字を含む有効なメール' => ['[email protected]'];yield '小文字と数字を含む有効なメール' => ['[email protected]'];yield '大文字と数字を含む有効なメール' => ['Test123@ test.com'];//...} }
注記
読みやすさを向上させるために、 yield
を使用し、ケースにテキスト説明を追加します。
ユニットは単一の動作単位であり、関連するいくつかのクラスである場合があります。
すべてのテストは他のテストから分離する必要があります。したがって、それらを並列または任意の順序で呼び出すことができなければなりません。
最終クラス TestExample は TestCase を拡張します {/** * @test */public function stopding_an_subscription_with_can_always_suspend_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy();$sut = new Subscription();$result = $sut->suspend($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspend(), $sut->status()); } }
単位は単一クラスです。
ユニットはすべての協力者から隔離される必要があります。
最終クラス TestExample は TestCase を拡張します {/** * @test */public function stopding_an_subscription_with_can_always_suspend_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class);$canAlwaysSuspendPolicy->method('suspend')->willReturn(true);$sut = new Subscription();$result = $sut->suspend($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspend(), $sut->status()); } }
注記
脆弱なテストを避けるには、古典的なアプローチを使用することをお勧めします。
[TODO]
例:
最終クラスNotificationService {public function __construct(private readonly MailerInterface $mailer,private readonly MessageRepositoryInterface $messageRepository) {}public function send(): void{$messages = $this->messageRepository->getAll();foreach ($messages as $message) { $this->メーラー->send($message); } } }
[!警告|スタイル:フラット|ラベル:BAD]
スタブとの相互作用をアサートすると脆弱なテストが発生する
最終クラス TestExample は TestCase を拡張します {/** * @test */public function sends_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(); } }
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラス TestExample は TestCase を拡張します {/** * @test */public function sends_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new notificationService($mailer, $messageRepository);// スタブとのアサーティング対話を削除$mailer->expects(self::exactly(2))->method ('送信') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|スタイル:フラット|ラベル:SPY をさらに効果的に使用する]
最終クラス TestExample は TestCase を拡張します {/** * @test */public function sends_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = 新しい SpyMailer();$sut = 新しい NoticeService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!TIP|スタイル:フラット|ラベル:最良のオプション]
リファクタリングに対する最高の耐性
最高の精度
保守コストが最も低い
可能であれば、この種のテストを選択する必要があります
最終クラス ExampleTest は TestCase を拡張します {/** * @test * @dataProvider getInvalidEmails */public function detects_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $結果); }/** * @test * @dataProvider getValidEmails */public function detects_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $結果); }パブリック関数 getInvalidEmails(): array{return [ ['テスト']、 ['テスト@']、 ['test@test'],//...]; }パブリック関数getValidEmails():array{return [ ['[email protected]']、 ['[email protected]']、 ['[email protected]'],//...]; } }
[!警告|スタイル:フラット|ラベル:最悪のオプション]
リファクタリングに対する耐性の悪化
精度の悪化
保守コストが高くなる
最終クラス ExampleTest は TestCase を拡張します {/** * @test */public functionadding_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]); } }
[!ATTENTION|スタイル:フラット|ラベル:最悪の選択肢]
リファクタリングに対する最悪の抵抗
最悪の精度
保守コストが最も高い
最終クラス ExampleTest は TestCase を拡張します {/** * @test */public function sends_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new notificationService($mailer, $messageRepository);$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!警告|スタイル:フラット|ラベル:BAD]
最終クラス NameService {パブリック関数 __construct(private readonly 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); } }
このようなコードをテストするにはどうすればよいでしょうか?ファイルシステムに関わるインフラコードを直接利用するため、結合テストのみで可能です。
[!TIP|スタイル:フラット|ラベル:GOOD]
関数型アーキテクチャと同様に、副作用のあるコードとロジックのみを含むコードを分離する必要があります。
最終クラス 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])); } $names を返します。 } }
最終クラス 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 functionloadNames(): void{$namesData = $this->fileLoader->load();$names = $this->parser->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); self::assertSame( [新しい名前('ジョン'、新しい性別('M'))、新しい名前('レノン'、新しい性別('U'))、新しい名前('サラ'、新しい性別('W')) ],$結果); } }
[!警告|スタイル:フラット|ラベル:BAD]
最終クラス ApplicationService {public function __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}public function renewSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionId);if (!$subscription->getStatus() ->isEqual(Status::expired())) {return false; }$subscription->setStatus(Status::active());$subscription->setModifiedAt(new DateTimeImmutable()); true を返します。 } }
最終クラスのサブスクリプション {パブリック関数 __construct(private Status $status, private DateTimeImmutable $modifiedAt) {}パブリック関数 getStatus(): Status{return $this->status; }パブリック関数 setStatus(Status $status): void{$this->status = $status; }パブリック関数 getModifiedAt(): DateTimeImmutable{return $this->modifiedAt; }パブリック関数 setModifiedAt(DateTimeImmutable $modifiedAt): void{$this->modifiedAt = $modifiedAt; } }
最終クラス InvalidTestExample は TestCase を拡張します {/** * @test */public function renew_an_expired_subscription_is_possible(): void{$modifiedAt = new DateTimeImmutable();$expiredSubscription = new Subscription(Status::expired(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($expiredSubscription));$result = $sut->renewSubscription(1);self::assertSame(Status::active(), $expiredSubscription->getStatus());self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt());self:: assertTrue($result); }/** * @test */public function renew_an_active_subscription_is_not_possible(): void{$modifiedAt = new DateTimeImmutable();$activeSubscription = new Subscription(Status::active(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());self::assertFalse($result); } private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface{return new class ($expiredSubscription)implements SubscriptionRepositoryInterface {public function __construct(private readonly Subscription $subscription) {} public function findById(int $id): サブスクリプション{return $this->subscription; } }; } }
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラス ApplicationService {public function __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}public function renewSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionId);return $subscription->renew(new DateTimeImmutable( )); } }
最終クラスのサブスクリプション {プライベート ステータス $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 を返します。 }パブリック関数 active(DateTimeImmutable $modifiedAt): void{//simplified$this->status = Status::active();$this->modifiedAt = $modifiedAt; }パブリック関数expired(DateTimeImmutable $modifiedAt): void{//simplified$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));$result = $sut- >renewSubscription(1);// 監視対象の一部ではないため、modifiedAt のチェックをスキップします 行動。この値を確認するには、// おそらくテスト目的のみで、modifiedAt のゲッターを追加する必要があります。self::assertTrue($expiredSubscription->isActive());self::assertTrue($result); }/** * @test */public function renew_an_active_subscription_is_not_possible(): void{$activeSubscription = SubscriptionMother::active();$sut = new ApplicationService($this->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertTrue($activeSubscription->isActive());self::assertFalse($result); } private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface{return new class ($expiredSubscription)implements SubscriptionRepositoryInterface {public function __construct(private readonly Subscription $subscription) {} public function findById(int $id): サブスクリプション{return $this->subscription; } }; } }
注記
最初のサブスクリプション モデルの設計は不適切です。 1 つのビジネス オペレーションを呼び出すには、3 つのメソッドを呼び出す必要があります。また、ゲッターを使用して動作を検証することも良い習慣ではありません。この場合、 modifiedAt
の変更のチェックはスキップされます。おそらく、更新操作中に特定のmodifiedAt
設定すると、有効期限切れのビジネス操作でテストできます。 modifiedAt
のゲッターは必要ありません。もちろん、テスト専用に提供されているゲッターを回避する可能性を見つけるのが非常に難しい場合もありますが、常にそれらを導入しないように努めるべきです。
[!警告|スタイル:フラット|ラベル:BAD]
クラス CannotSuspendExpiredSubscriptionPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {return false; } true を返します。 } }
クラス CannotSuspendExpiredSubscriptionPolicyTest extends TestCase {/** * @test */public function it_returns_false_when_a_subscription_is_expired(): void{$policy = new CannotSuspendExpiredSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isExpired')->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::class);$subscription->method('isExpired')->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
クラス CannotSuspendNewSubscriptionPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{if ($subscription->isNew()) {return false; } true を返します。 } }
クラス CannotSuspendNewSubscriptionPolicyTest は TestCase を拡張します {/** * @test */public function it_returns_false_when_a_subscription_is_new(): void{$policy = new CannotSuspendNewSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isNew')->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->method('isNew')->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
クラス CanSuspendAfterOneMonthPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable:: createFromMutable($oneMonthEarlierDate)); } }
クラス CanSuspendAfterOneMonthPolicyTest は TestCase を拡張します {/** * @test */public function it_returns_true_when_a_subscription_is_older_than_one_month(): void{$date = new DateTimeImmutable('2021-01-29');$policy = new CanSuspendAfterOneMonthPolicy();$subscription = new Subscription(new DateTimeImmutable('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 CanSuspendAfterOneMonthPolicy();$subscription = new Subscription(new DateTimeImmutable('2020-01-01'));self::assertTrue($policy->suspend($subscription, $date)); } }
クラスステータス {プライベート const EXPIRED = '期限切れ';プライベート const ACTIVE = 'アクティブ';プライベート const NEW = 'new';プライベート const SUSPENDED = '一時停止';プライベート関数 __construct(プライベート読み取り専用文字列 $status) {$this->ステータス = $ステータス; }パブリック静的関数expired(): self{return new self(self::EXPIRED); }パブリック静的関数 active(): self{return new self(self::ACTIVE); }パブリック静的関数 new(): self{return new self(self::NEW); }パブリック静的関数suspend(): self{return new self(self::SUSPENDED); }パブリック関数 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 function stopding_a_subscription_is_possible_when_a_policy_returns_true(): void{$policy = $this->createMock(SuspendingPolicyInterface::class);$policy->expects($this->once())->method( '一時停止')->willReturn(true);$sut = 新しいSubscription(new DateTimeImmutable());$result = $sut->suspend($policy, new DateTimeImmutable());self::assertTrue($result);self::assertTrue($sut->isSuspended()); }/** * @test */public function stopding_a_subscription_is_not_possible_when_a_policy_returns_false(): void{$policy = $this->createMock(SuspendingPolicyInterface::class);$policy->expects($this->once())->method( '一時停止')->willReturn(false);$sut = 新しいSubscription(new DateTimeImmutable());$result = $sut->suspend($policy, new DateTimeImmutable());self::assertFalse($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'));$sut = newサブスクリプション($date);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 DateInterval('P1D'));$sut = newサブスクリプション($date);self::assertTrue($sut->isOlderThan($futureDate)); } }
[!ATTENTION] 1:1、1 クラス : 1 テストのコードを記述しないでください。それは脆弱なテストにつながり、リファクタリングが困難になります。
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラス CannotSuspendExpiredSubscriptionPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {return false; } true を返します。 } }
最終クラス CannotSuspendNewSubscriptionPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{if ($subscription->isNew()) {return false; } true を返します。 } }
最終クラス CanSuspendAfterOneMonthPolicy は SuspendingPolicyInterface を実装します {public function stop(Subscription $subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable:: createFromMutable($oneMonthEarlierDate)); } }
最終クラスのステータス {プライベート const EXPIRED = '期限切れ';プライベート const ACTIVE = 'アクティブ';プライベート const NEW = 'new';プライベート const SUSPENDED = '一時停止';プライベート関数 __construct(プライベート読み取り専用文字列 $status) {$this->ステータス = $ステータス; }パブリック静的関数expired(): self{return new self(self::EXPIRED); }パブリック静的関数 active(): self{return new self(self::ACTIVE); }パブリック静的関数 new(): self{return new self(self::NEW); }パブリック静的関数suspended(): self{return new self(self::SUSPENDED); }パブリック関数 isEqual(self $status): bool{return $this->status === $status->status; } }
最終クラスのサブスクリプション {プライベート ステータス $status;プライベート DateTimeImmutable $createdAt;パブリック関数 __construct(DateTimeImmutable $createdAt) {$this->status = Status::new();$this->createdAt = $createdAt; }public function stop(SuspendingPolicyInterface $suspendingPolicy, DateTimeImmutable $at): bool{$result = $suspendingPolicy->suspend($this, $at);if ($result) {$this->status = Status::suspended() ; $result を返します; }パブリック関数 isOlderThan(DateTimeImmutable $date): bool{return $this->createdAt < $date; }パブリック関数 activate(): void{$this->status = Status::active(); }パブリック関数expired(): 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 */public function stopding_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$sut->expire();$result = $sut ->一時停止(新規CannotSuspendExpiredSubscriptionPolicy()、new DateTimeImmutable());self::assertFalse($result); }/** * @test */public function stopding_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new DateTimeImmutable());self::assertFalse($result); }/** * @test */public function stopding_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy() 、 新しいDateTimeImmutable());self::assertTrue($result); }/** * @test */public function stopding_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy() 、 新しいDateTimeImmutable());self::assertTrue($result); }/** * @test */public function stopding_an_subscription_before_a_one_month_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(),新しいDateTimeImmutable('2020-01-10'));self::assertFalse($result); }/** * @test */public function stopding_an_subscription_after_a_one_month_is_possible(): void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(),新しいDateTimeImmutable('2020-02-02'));self::assertTrue($result); } }
このようなクラスを適切に単体テストするにはどうすればよいでしょうか?
クラスApplicationService {public function __construct(private readonly OrderRepository $orderRepository,private readonly FormRepository $formRepository) {}public function changeFormStatus(int $orderId): void{$order = $this->orderRepository->getById($orderId);$soapResponse = $ this->getSoapClient()->getStatusByOrderId($orderId);$form = $this->formRepository->getByOrderId($orderId);$form->setStatus($soapResponse['status']);$form->setModifiedAt(new DateTimeImmutable());if ($soapResponse['status'] = == '承諾') {$order->setStatus('支払い済み'); }$this->formRepository->save($form);$this->orderRepository->save($order); }プライベート関数 getSoapClient(): SoapClient{return new SoapClient('https://legacy_system.pl/Soap/WebService', []); } }
[!TIP|スタイル:フラット|ラベル:GOOD]
複雑すぎるコードを別のクラスに分割する必要があります。
最終クラス ApplicationService {public function __construct(private readonly OrderRepositoryInterface $orderRepository,private readonly FormRepositoryInterface $formRepository,private readonly FormApiInterface $formApi,private readonly ChangeFormStatusService $changeFormStatusService) {}public function changeFormStatus(int $orderId): void{$order = $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 $order, Form $form, string $formStatus): void{$status = FormStatus::createFromString($formStatus);$form->changeStatus($status);if ($form->isAccepted( )) {$order->changeStatus(OrderStatus::paid()); } } }
最終クラス ChangingFormStatusTest は TestCase を拡張します {/** * @test */public function Changing_a_form_status_to_accepted_changes_an_order_status_to_paid(): void{$order = new Order();$form = new Form();$status = 'accepted';$sut = new ChangeFormStatusService();$sut ->changeStatus($order, $form, $status);self::assertTrue($form->isAccepted());self::assertTrue($order->isPaid()); }/** * @test */public function Changing_a_form_status_to_raised_not_changes_an_order_status(): void{$order = new Order();$form = new Form();$status = 'new';$sut = new ChangeFormStatusService();$sut ->changeStatus($order, $form, $status);self::assertFalse($form->isAccepted());self::assertFalse($order->isPaid()); } }
ただし、ApplicationService は、おそらく、モック化された FormApiInterface のみを使用した統合テストによってテストする必要があります。
[!警告|スタイル:フラット|ラベル:BAD]
最終クラスのお客様 {パブリック関数 __construct(プライベート文字列 $name) {}パブリック関数 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 {パブリック静的関数 getSubscribedEvents(): array{return ['event' => 'onEvent']; }パブリック関数onEvent(): void{ } }
最終クラス EventSubscriberTest は TestCase を拡張します {パブリック関数 testGetSubscribedEvents(): void{$result = EventSubscriber::getSubscribedEvents();self::assertSame(['event' => 'onEvent'], $result); } }
[!ATTENTION] 複雑なロジックをまったく使用せずにコードをテストするのは無意味ですが、テストが脆弱になることにもつながります。
[!警告|スタイル:フラット|ラベル:BAD]
最終クラス UserRepository {パブリック関数 __construct(private readonly Connection $connection) {}パブリック関数 getUserNameByEmail(string $email): ?array{return $this->connection->createQueryBuilder() ->from('ユーザー', 'u') ->where('u.email = :email') ->setParameter('電子メール', $電子メール) ->実行() ->フェッチ(); } }
最終クラス 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()) ->メソッド('setParameter') ->with('電子メール', $電子メール) ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->メソッド('実行') - > willreturn($ result); $ result-> expects($ this-> wone()) - > method( 'fetch') - > willreturn(['email' => $ email]); $ result = $ userrepository-> getusernamebyemail($ email); self :: assertsame(['email' => $ email]、$ result); } }
[!ATTENTION] この方法でリポジトリをテストすると脆弱なテストが発生し、リファクタリングが困難になります。リポジトリをテストするには、統合テストを作成します。
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラスのグッドテストはテストケースを拡張します {private subscriptionFactory $ sut; public function setup():void {$ this-> sut = new SubscriptionFactory(); }/** * @test */public function creates_a_subscription_for_a_given_date_range():void {$ result = $ this-> sut-> create(new DateTimeImMutable()、new DateTimeImMutable( 'Now +1 Year')) (サブスクリプション::クラス、$ result); }/** * @test */public function throws_an_exception_on_in_invalid_date_range():void {$ this-> expectexception(createSubscriptionException :: class); $ result = $ this-> sut-> create(new DateTimeImMutable( 'Now -1 Year')、new DateTimeImMutable()); } }
注記
setUp メソッドを使用する最適なケースは、ステートレス オブジェクトをテストする場合です。
setUp
内で行われた構成はすべてテストを結合し、すべてのテストに影響を与えます。
テスト間で状態が共有されることを避け、テスト方法に応じて初期状態を構成することをお勧めします。
適切なテスト方法で作成された構成と比較して、可読性が悪くなります。
[!TIP|スタイル:フラット|ラベル:BETTER]
最終クラスのBettertestはテストケースを拡張します {/** * @test */publicFunts sundsubsendindend_an_active_subscription_with_cannot_suspend_new_policy_is_is_possible():void {$ sut = $ this-> createanactivesubscription(); $ result = $ sut-> sut-> suspend(new cannotsusubsubsubspolicy() DateTimeImMutable()); self :: asserttrue($ result); }/** * @test */public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void{$sut = $this->createAnActiveSubscription();$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new DateTimeImMutable()); self :: asserttrue($ result); }/** * @test */public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible():void {$ sut = $ $ this-> createanewsubscription(); $ result = $ sut-> suspend(new connotsuspendnewsubsubsubspoicy(new connotspendnewsubspoicy(new connotspendnewsubsubspoicy(new connotspendnewsubspoical)) DateTimeImMutable()); self :: assertfalse($ result); } private function createanewsubscription():subscription {return new Subscription(new DateTimeImMutable()); } private function createanactiveSubscription():subscription {$ subscription = new subscription(new DateTimeImMutable()); $ subscription-> activate(); $ subscriptionを返します。 } }
注記
このアプローチにより、可読性が向上し、分離が明確になります (コードは書き込まれるよりも読み取られる方が多くなります)。
プライベート ヘルパーは明示的な意図を提供しますが、各テスト メソッドで使用するのは面倒な場合があります。
複数のテスト クラス間で同様のテスト オブジェクトを共有するには、次を使用します。
オブジェクトの母
ビルダー
[!警告|スタイル:フラット|ラベル:BAD]
最終クラスの顧客 {private customertype $ type; private discountcalculationpolicyinterface $ discountcalculationpolicy; public function __construct() {$ this-> type = customertype :: normal(); $ this-> discountCalculationPolicy = new remormDiscountPolicy(); } public function makevip():void {$ this-> type = customertype :: vip(); $ this-> discountCalculationPolicy = new VipDiscountPolicy(); } public function getCustomerType():customerType {return $ this-> type; } public function getPercentageSiscount():int {return $ this-> discountCalculationPolicy-> getPercentageSist(); } }
最終クラスInvalidTestはテストケースを拡張します {public function testmakevip():void {$ sut = new Customer(); $ sut-> makevip(); self :: assertype(customertype :: vip()、$ sut-> getCustomertype()); } }
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラスの顧客 {private customertype $ type; private discountcalculationpolicyinterface $ discountcalculationpolicy; public function __construct() {$ this-> type = customertype :: normal(); $ this-> discountCalculationPolicy = new remormDiscountPolicy(); } public function makevip():void {$ this-> type = customertype :: vip(); $ this-> discountCalculationPolicy = new VipDiscountPolicy(); } public function getPercentageSiscount():int {return $ this-> discountCalculationPolicy-> getPercentageSist(); } }
最終クラスのvalidtestはテストケースを拡張します {/** * @test */public function a_vip_customer_has_a_25_percentage_discount():void {$ sut = new customer(); $ sut-> makevip(); self :: assertsame(25、$ sut-> getpercentadeadiscount(); } }
[!ATTENTION] テストで状態を確認するためだけに追加の実稼働コード (getter getCustomerType() など) を追加することは、悪い習慣です。別のドメインの重要な値 (この場合は getPercentageDiscount()) によって検証する必要があります。もちろん、動作を検証する別の方法を見つけるのが難しい場合があり、テストで正確さを検証するために製品コードを追加しなければならない場合もありますが、それは避けるように努めるべきです。
最終クラスの割引カルシュレーター {public function calculate(int $ isvipfromyears):int { assert :: greaterthaneq($ isvipfromyears、0); return min(($ isvipfromyears * 10) + 3、80); } }
[!警告|スタイル:フラット|ラベル:BAD]
最終クラスInvalidTestはテストケースを拡張します {/** * @dataprovider DiscountDataprovider */public function testCalculate(int $ vipdaysfrom、int $ expected):void {$ sut = new dissivercalculator(); self :: assertsame($ equide、$ sut-> calculate($ vipdaysfrom($ vipdaysfrom) ); } public function DiscountDataprovider():array {return [ [0、0 * 10 + 3]、//ドメインの漏れの詳細[1、1 * 10 + 3]、 [5、5 * 10 + 3]、 [8、80] ]; } }
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラスのvalidtestはテストケースを拡張します {/** * @dataprovider DiscountDataprovider */public function testCalculate(int $ vipdaysfrom、int $ expected):void {$ sut = new dissivercalculator(); self :: assertsame($ equide、$ sut-> calculate($ vipdaysfrom($ vipdaysfrom) ); } public function DiscountDataprovider():array {return [ [0、3]、 [1、13]、 [5、53]、 [8、80] ]; } }
注記
テストでは本番ロジックを複製しないでください。ハードコードされた値によって結果を確認するだけです。
[!警告|スタイル:フラット|ラベル:BAD]
クラスディスカウントカルシュレーター {public function calculateinternaldiscount(int $ isvipfromyears):int { assert :: greaterthaneq($ isvipfromyears、0); return min(($ isvipfromyears * 10) + 3、80); } public function calculateadditionaldiscountfromexternalsystem():int {//外部システムからデータを取得して、discountreturn 5を計算します。 } }
クラスOrderservice {public function __construct(private readonly discountcalculator $ discountcalculator){} public function getTotalPriceWithDiscount(int $ $ $ gipfromdays):int {$ internalDiscount = $ this-> discountcalculator-> calucate > discountCalculator-> calculateadditionaldiscountfromexternalSystem(); $ distimentsum = $ internalDiscount + $ externaldIscount; Return $ TotalPrice - (int)Ceil(($ totalprice * $ distimentsum) / 100); } }
最終クラスInvalidTestはテストケースを拡張します {/** * @dataprovider orderdataprovider */public function testgettotalpricewithdiscount(int $ $ $ $ gipdaysfrom、int $ heady):void {$ discountcalculator = $ $ this-> createpartialmock(discountcalculator :: class、 ['calculateadditionaldiscountfromexternalsystem']); $ discountcalculator-> method( 'calculateadditionaldiscountfromexternalsystem') - > willreturn(5); $ sut = new Orderservice($ discountcalculator); self :: assertsame($ sut-> gettalpricewithdiscount( 、$ vipdaysfrom)); } public function orderdataprovider():array {return [ [1000、0、920]、 [500、1、410]、 [644、5、270]、 ]; } }
[!TIP|スタイル:フラット|ラベル:GOOD]
インターフェイスexternIscountCalculatorInterface {public function calculate():int; }
最終クラス内部discountCalculator {public function calculate(int $ isvipfromyears):int { assert :: 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 $ totalprice、int $ vipfromdays):Int {Internaldiscount = $ $ $ $ cuptculation; $ externaldiscount = $ this-> externaldiscountCalculator-> calculate(); $ distimentsum = $ internaldiscount + $ externaldiscount; return $ totalprice-(int)ceil(($ totalprice * $ distaversum) / 100); } }
最終クラスのvalidtestはテストケースを拡張します {/** * @dataprovider orderdataprovider */public function testgettotalpricewithdiscount(int $ $ $ otalprice、int $ vipdaysfrom、int $ equient):void {$ externaldiscountCalculator = new class = new class()は、ExternAldiscountCalcuraturerInterfaceを実装します。 } }; $ sut = new Orderservice(new InternalDiscountCalculator()、$ externaldIscountCalculator); self :: assertsame($ redict、$ sut-> gettotalpricewithdiscount($ totalprice、$ vipdaysfrom)); } public function orderdataprovider():array {return [ [1000、0、920]、 [500、1、410]、 [644、5、270]、 ]; } }
注記
具象クラスをモックしてその動作の一部を置き換える必要があるということは、このクラスがおそらく複雑すぎて、単一責任の原則に違反していることを意味します。
最終クラスの順序付け {public function __construct(public readonly int $ total){} }
最終クラスの注文 {/** * @param OrderItem [] $ items * @param int $ transportcost */public function __construct(private array $ items、private int $ transportcost){} public function getTotal():int {return $ $ $> getItemstotal () + $ this-> TransportCost; } private function getItemStotal():int {return array_reduce(array_map(fn(orderitem $ item)=> $ item-> $ item-> $ this-> items)、fn(int $ sum、int $ total)=> $ sum + + + = $合計、0); } }
[!警告|スタイル:フラット|ラベル:BAD]
最終クラスInvalidTestはテストケースを拡張します {/** * @test * @dataprovider ordersdataprovider */public function get_total_returns_a_total_cost_of_a_whole_order(order $ order、int $ heasttotal):void {self :: assertsame($ expected total、$ order- $ - gettotal(); }/** * @test * @dataprovider orderitemsdataprovider */public function get_items_total_returns_a_total_cost_of_all_items(order $ order、int $ heasttotal):void {self::assertsame($ equesttotal、$ thisprivatemetheTotAl); } public function ordersdataprovider():array {return [ [New Order([New OrderItem(20)、New OrderItem(20)、New OrderItem(20)]、15)、75]、 [New Order([New OrderItem(20)、New OrderItem(30)、New OrderItem(40)]、0)、90]、 [New Order([New OrderItem(99)、New OrderItem(99)、New OrderItem(99)]、9)、306] ]; } public function orderitemsdataprovider():array {return [ [New Order([New OrderItem(20)、New OrderItem(20)、New OrderItem(20)]、15)、60]、 [New Order([New OrderItem(20)、New OrderItem(30)、New OrderItem(40)]、0)、90]、 [New Order([New OrderItem(99)、New OrderItem(99)、New OrderItem(99)]、9)、297] ]; } private function invokeprivatemethodgetitemstotal(order&$ order):int {$ reflection = new ReflectionClass(get_class($ order)); $ method = $ reflection-> getMethod( 'getItemstotal'); $ method-> invokeargs($ order、[]); } }
[!TIP|スタイル:フラット|ラベル:GOOD]
最終クラスのvalidtestはテストケースを拡張します {/** * @test * @dataprovider ordersdataprovider */public function get_total_returns_a_total_cost_of_a_whole_order(order $ order、int $ heasttotal):void {self :: assertsame($ expected total、$ order- $ - gettotal(); } public function ordersdataprovider():array {return [ [New Order([New OrderItem(20)、New OrderItem(20)、New OrderItem(20)]、15)、75]、 [New Order([New OrderItem(20)、New OrderItem(30)、New OrderItem(40)]、0)、90]、 [New Order([New OrderItem(99)、New OrderItem(99)、New OrderItem(99)]、9)、306] ]; } }
[!ATTENTION] テストではパブリック API のみを検証する必要があります。
時間は非決定的であるため、不安定な依存関係になります。呼び出しごとに異なる結果が返されます。
[!警告|スタイル:フラット|ラベル:BAD]
最終クラスの時計 {public static dateTime | null $ currentDateTime = null; public static function getCurrentDateTime():datetime {if(null === self :: $ currentDateTime){self :: $ currentDateTime = new DateTime(); } self :: $ currentDateTime; } public static function set(datetime $ datetime):void {self :: $ currentDateTime = $ datetime; } public static function reset():void {self :: $ currentDateTime = null; } }
最終クラスの顧客 {private dateTime $ createdat; public function __construct() {$ this-> createdat = clock :: getCurrentDateTime(); } public function isvip():bool {return $ this-> createdat-> diff(clock :: getCurrentDatetime()) - > y> = 1; } }
最終クラスInvalidTestはテストケースを拡張します {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_is_a_vip():void { clock :: set(new DateTime( '2019-01-01')); $ sut = new Customer(); クロック:: reset(); //共有されたSTASSERSEEDのリセットについて覚えておく必要があります:: Asserttrue($ sut-> isvip()); }/** * @test */public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip():void { clock :: set((new DateTime()) - > sub(new DateInterval( 'p2m'))); $ sut = new Customer(); クロック:: reset(); //共有Statesselfのリセットについて覚えておく必要があります:: assertfalse($ sut-> isvip()); } }
[!TIP|スタイル:フラット|ラベル:GOOD]
インターフェイスクロックインターフェイス {public function getCurrentTime():DateTimeImMutable; }
最終クラスの時計は、clockinterfaceを実装します {private function __construct() { } public static function create():self {return new self(); } public function getCurrentTime():dateTimeImMutable {return new DateTimeImMutable(); } }
最終クラスの固定クロックは、クロックインターフェイスを実装します {private function __construct(private readonly dateTimeImmutable $ sixeddate){} public static function create(dateTimeImmutable $ sixeddate):self {return new self($ fixeddate); } public function getCurrentTime():dateTimeImMutable {return $ this-> sixedDate; } }
最終クラスの顧客 {public function __construct(private readonly dateTimeImmutable $ createdat){} public function isvip(datetimeimmutable $ currentDate):bool {return $ this-> created-> diff($ currentDate) - > y> = 1; } }
最終クラスのvalidtestはテストケースを拡張します {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_is_a_vip():void {$ sut = new customer(sixedclock :: create(new(new) DateTimeImMutable( '2019-01-01')) - > getCurrentTime()); self :: asserttrue($ sut-> isvip(sixedclock :: 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(sixedclock :: create(new(new) DateTimeImMutable( '2019-01-01')) - > getCurrentTime()); self :: assertfalse($ sut-> isvip(sixedclock :: create(new DateTimeImMutable( '2019-05-02')) - > getCurrentTime( ))); } }
注記
時間と乱数はドメイン コードで直接生成しないでください。動作をテストするには、決定的な結果が必要であるため、上記の例のように、これらの値をドメイン オブジェクトに注入する必要があります。
100% のカバレッジは目標ではなく、むしろ望ましくありません。100% のカバレッジがある場合、テストはおそらく非常に脆弱になり、リファクタリングが非常に困難になるからです。突然変異テストにより、テストの品質に関するより良いフィードバックが得られます。続きを読む
テスト駆動型開発:Example / Kent Beck -The Classic
単体テストの原則、実践、パターン / Vladimir Khorikov - 私が今まで読んだテストに関する最高の本
カミル・ルチンスキ
Twitter: https://twitter.com/Sarvendev
ブログ: https://sarvendev.com/
LinkedIn: https://www.linkedin.com/in/kamilruczynski/