요즘에는 단위 테스트 작성의 이점이 엄청납니다. 최근 시작한 프로젝트에는 대부분 단위 테스트가 포함되어 있는 것 같아요. 비즈니스 로직이 많은 엔터프라이즈 애플리케이션에서 단위 테스트는 가장 중요한 테스트입니다. 단위 테스트는 빠르고 구현이 올바른지 즉시 확인할 수 있기 때문입니다. 그러나 나는 종종 프로젝트에서 좋은 테스트에서 문제를 발견합니다. 그러나 이러한 테스트의 이점은 좋은 단위 테스트가 있을 때만 엄청납니다. 따라서 이 예제에서는 좋은 단위 테스트를 작성하기 위해 해야 할 일에 대한 몇 가지 팁을 공유하려고 합니다.
읽기 쉬운 버전: https://testing-tips.sarvendev.com/
카밀 루친스키
블로그: https://sarvendev.com/
링크드인: https://www.linkedin.com/in/kamilruczynski/
당신의 지원은 나에게 세상을 의미합니다! 이 가이드가 마음에 들었고 공유된 지식에서 가치를 찾았다면 BuyMeCoffee에서 저를 지원해 보세요.
또는 저장소에 별표를 남기고 Twitter 및 Github에서 나를 팔로우하면 모든 업데이트에 대한 최신 정보를 얻을 수 있습니다. 여러분의 관대함은 여러분을 위해 더욱 통찰력 있는 콘텐츠를 만들고자 하는 저의 열정을 불러일으킵니다.
개선 아이디어나 작성하고 싶은 주제가 있으면 언제든지 끌어오기 요청을 준비하거나 저에게 알려주세요.
무료 eBook을 구독하고 유닛 테스트를 마스터하세요!
세부
나는 아직도 이 가이드에 대한 단위 테스트에 대한 개선 사항에 대한 꽤 긴 TODO 목록을 가지고 있으며 가까운 시일 내에 이를 소개할 것입니다.
소개
작가
테스트 더블
명명
AAA 패턴
개체 어머니
빌더
객체 어설션
매개변수화된 테스트
단위 테스트의 두 가지 학교
고전
모의주의자
종속성
모의 대 스텁
단위 테스트의 세 가지 스타일
산출
상태
의사소통
기능적 아키텍처 및 테스트
관찰 가능한 동작과 구현 세부정보
행동의 단위
겸손한 패턴
사소한 테스트
깨지기 쉬운 테스트
테스트 설비
일반적인 테스트 안티 패턴
비공개 상태 노출
도메인 세부정보 유출
구체적인 클래스 모의
비공개 메서드 테스트
일시적인 종속성으로서의 시간
100% 테스트 커버리지가 목표가 되어서는 안 됩니다.
추천도서
테스트 더블은 테스트에 사용되는 가짜 종속성입니다.
더미는 아무것도 하지 않는 단순한 구현입니다.
최종 클래스 메일러는 MailerInterface를 구현합니다. {공용 함수 보내기(메시지 $message): 무효{ } }
가짜는 원래 동작을 시뮬레이션하기 위해 단순화된 구현입니다.
최종 클래스 InMemoryCustomerRepository는 CustomerRepositoryInterface를 구현합니다. {/** * @var Customer[] */private 배열 $customers;public 함수 __construct() {$this->고객 = []; }공용 함수 저장소(고객 $customer): void{$this->customers[(string) $customer->id()->id()] = $customer; }공용 함수 get(CustomerId $id): Customer{if (!isset($this->customers[(string) $id->id()])) {throw new CustomerNotFoundException(); }return $this->customers[(string) $id->id()]; }공용 함수 findByEmail(Email $email): 고객{foreach ($this->고객을 $customer로 지정) {if ($customer->getEmail()->isEqual($email)) {return $customer; } }새 CustomerNotFoundException()을 발생시킵니다. } }
스텁은 하드코딩된 동작을 사용하는 가장 간단한 구현입니다.
최종 클래스 UniqueEmailSpecificationStub은 UniqueEmailSpecificationInterface를 구현합니다. {공용 함수 isUnique(Email $email): bool{return true; } }
$specationStub = $this->createStub(UniqueEmailSpecificationInterface::class);$specationStub->method('isUnique')->willReturn(true);
스파이는 특정 동작을 확인하기 위한 구현입니다.
최종 클래스 메일러는 MailerInterface를 구현합니다. {/** * @var Message[] */개인 배열 $messages; 공개 함수 __construct() {$this->메시지 = []; }공용 함수 send(Message $message): void{$this->messages[] = $message; }공용 함수 getCountOfSentMessages(): int{return count($this->messages); } }
모의는 협력자의 통화를 확인하기 위해 구성된 모방입니다.
$message = new Message('[email protected]', '테스트', '테스트 테스트 테스트');$mailer = $this->createMock(MailerInterface::class);$mailer->expects($this-> 한 번()) ->방법('보내기') ->($this->equalTo($message));
주의 들어오는 상호 작용을 확인하려면 스텁을 사용하고, 나가는 상호 작용을 확인하려면 모의를 사용하세요.
더 보기: 모의 vs 스텁
[!경고|스타일:플랫|레이블:좋지 않음]
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 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 = newNotificationService($mailer, $messageRepository);$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!팁|스타일:플랫|레이블:더 좋음]
리팩토링에 대한 저항력 향상
특정 메서드에서 Refactor->Rename을 사용해도 테스트가 중단되지 않습니다.
더 나은 가독성
유지 관리 비용 절감
정교한 모의 프레임워크를 배울 필요가 없습니다.
그냥 간단한 일반 PHP 코드
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = 신규 SpyMailer();$sut = 새로운 알림 서비스($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!경고|스타일:플랫|레이블:좋지 않음]
공개 함수 테스트(): void{$subscription = SubscriptionMother::new();$subscription->activate();self::assertSame(Status::activated(), $subscription->status()); }
[!TIP|스타일:플랫|레이블:테스트 중인 항목을 명시적으로 지정]
공개 함수 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(): 무효{ }공개 함수 testCreatingWithATooShortPasswordIsNotPossible(): void{ }공개 함수 testDeactivateASubscription(): void{ }
[!팁|스타일:플랫|레이블:더 좋음]
밑줄을 사용하면 가독성이 향상됩니다.
이름은 구현이 아닌 동작을 설명해야 합니다.
기술적인 키워드 없이 이름을 사용하세요. 프로그래머가 아닌 사람도 읽을 수 있어야 합니다.
공개 함수 sign_in_with_invalid_credentials_is_not_possible(): 무효{ }공개 함수 Creating_with_a_too_short_password_is_not_possible(): void{ }공개 함수 deaactivate_an_activated_subscription_is_valid(): 무효{ }공개 함수 deaactivate_an_inactive_subscription_is_invalid(): 무효{ }
메모
도메인 시나리오를 테스트하려면 동작을 설명하는 것이 중요합니다. 귀하의 코드가 단지 유틸리티 코드라면 덜 중요합니다.
프로그래머가 아닌 사람이 단위 테스트를 읽는 것이 왜 유용한가요?
복잡한 도메인 로직이 포함된 프로젝트가 있는 경우 이 로직은 모든 사람에게 매우 명확해야 하므로 테스트에서는 기술적 키워드 없이 도메인 세부 정보를 설명하고 이러한 테스트와 같은 언어로 비즈니스와 대화할 수 있습니다. 도메인과 관련된 모든 코드에는 기술적 세부사항이 없어야 합니다. 프로그래머가 아닌 사람은 이 테스트를 읽을 수 없습니다. 도메인에 대해 이야기하고 싶다면 이 테스트는 이 도메인이 무엇을 하는지 아는 데 유용할 것입니다. 기술적인 세부 사항 없이 설명이 있을 것입니다(예: null 반환, 예외 발생 등). 이러한 종류의 정보는 도메인과 관련이 없으므로 이러한 키워드를 사용해서는 안 됩니다.
또한 일반적인 Give, When, Then입니다.
테스트의 세 가지 섹션을 구분합니다.
배열 : 테스트 중인 시스템을 원하는 상태로 만듭니다. 종속성, 인수를 준비하고 마지막으로 SUT를 구성합니다.
Act : 테스트된 요소를 호출합니다.
Assert : 결과, 최종 상태 또는 협력자와의 커뮤니케이션을 확인합니다.
[!TIP|스타일:플랫|레이블:좋음]
공개 함수 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(); }공개 정적 함수 활성화(): 구독{$subscription = 새 구독();$subscription->activate();return $subscription; }공용 정적 함수 비활성화(): 구독{$subscription = self::activated();$subscription->deactivate();return $subscription; } }
최종 수업 예제테스트 {공용 함수 example_test_with_activated_subscription(): void{$activatedSubscription = SubscriptionMother::activated();// 뭔가 수행// 뭔가 확인}공개 함수 example_test_with_deactivated_subscription(): void{$deactivatedSubscription = SubscriptionMother::deactivated();// 뭔가 수행 // 뭔가 확인} }
Builder는 테스트에서 객체를 생성하는 데 도움이 되는 또 다른 패턴입니다. Object Mother Pattern Builder에 비해 더 복잡한 객체를 생성하는 데 더 좋습니다.
최종 클래스 OrderBuilder {private DateTimeImmutable|null $createdAt = null;/** * @var OrderItem[] */private 배열 $items = [];public 함수createdAt(DateTimeImmutable $createdAt): self{$this->createdAt = $createdAt;return $this; }public 함수 withItem(string $name, int $price): self{$this->items[] = new OrderItem($name, $price);return $this; }공개 함수 빌드(): 순서{ Assert::notEmpty($this->items);return new Order($this->createdAt ?? new DateTimeImmutable(),$this->items, ); } }
최종 클래스 exampleTest는 TestCase를 확장합니다. {/** * @test */public 함수 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 함수 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 function 주장(Order $order): self{return new OrderAsserter($order); }공개 함수 wasCreatedAt(DateTimeImmutable $createdAt): self{ 주장::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 함수 discovers_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $ 결과); }/** * @test * @dataProvider getValidEmails */public 함수 discovers_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'];//...}공용 함수 getValidEmails(): iterable{yield '소문자로 된 유효한 이메일' => ['[email protected]'];yield '소문자와 숫자가 포함된 유효한 이메일' => ['[email protected]'];yield '대문자와 숫자가 포함된 유효한 이메일' => ['Test123@ test.com'];//...} }
메모
가독성을 높이려면 yield
사용하고 케이스에 텍스트 설명을 추가하세요.
단위는 동작의 단일 단위이며 몇 가지 관련 클래스일 수 있습니다.
모든 테스트는 다른 테스트와 격리되어야 합니다. 따라서 이들을 병렬로 또는 임의의 순서로 호출하는 것이 가능해야 합니다.
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 suspensing_an_subscription_with_can_always_pens_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy();$sut = new Subscription();$result = $sut->일시중단($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::중단(), $sut->status()); } }
단위는 단일 클래스입니다.
해당 장치는 모든 협력자와 격리되어야 합니다.
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 suspending_an_subscription_with_can_always_pens_policy_is_always_possible(): void{$canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class);$canAlwaysSuspendPolicy->method('suspend')->willReturn(true);$ 수트 = 새로운 구독();$result = $sut->일시중단($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspens(), $sut->status()); } }
메모
깨지기 쉬운 테스트를 피하려면 고전적인 접근 방식이 더 좋습니다.
[할 일]
예:
최종 클래스 알림 서비스 {공용 함수 __construct(private readonly MailerInterface $mailer,private readonly MessageRepositoryInterface $messageRepository) {}공용 함수 send(): void{$messages = $this->messageRepository->getAll();foreach ($messages as $message) { $this->mailer->send($message); } } }
[!경고|스타일:플랫|레이블:BAD]
스텁과의 상호 작용을 확인하면 취약한 테스트가 발생합니다.
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 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 알림 서비스($mailer, $messageRepository);$messageRepository->expects(self::once())->method('getAll');$mailer- >기대(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = newNotificationService($mailer, $messageRepository);// 스텁 $mailer->expects(self::exactly(2))->method와의 어설션 상호 작용을 제거했습니다. ('보내다') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|스타일:플랫|레이블:SPY를 사용하는 것이 훨씬 더 좋습니다.]
최종 클래스 TestExample은 TestCase를 확장합니다. {/** * @test */public 함수 send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = 신규 SpyMailer();$sut = 새로운 알림 서비스($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!TIP|스타일:플랫|레이블:가장 좋은 옵션]
리팩토링에 대한 최고의 저항
최고의 정확성
유지 관리 비용이 가장 낮습니다.
가능하다면 이런 종류의 테스트를 선호하는 것이 좋습니다
최종 클래스 exampleTest는 TestCase를 확장합니다. {/** * @test * @dataProvider getInvalidEmails */public 함수 discovers_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $결과); }/** * @test * @dataProvider getValidEmails */public 함수 discovers_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 함수 added_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 함수 send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = newNotificationService($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를 $nameData로) {if (!isset($nameData[0], $nameData[1])) {계속; }$names[] = 새로운 이름($nameData[0], 새로운 성별($nameData[1])); }$this->cacheStorage->store('names', $names); } }
이와 같은 코드를 테스트하는 방법은 무엇입니까? 파일 시스템 관련 인프라 코드를 직접 사용하기 때문에 통합 테스트로만 가능하다.
[!TIP|스타일:플랫|레이블:좋음]
기능적 아키텍처와 마찬가지로 부작용이 있는 코드와 논리만 포함된 코드를 분리해야 합니다.
최종 클래스 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])); }$name을 반환합니다. } }
최종 클래스 CsvNamesFileLoader {공용 함수 로드(): array{return array_map('str_getcsv', file(__DIR__.'/../names.csv')); } }
최종 클래스 ApplicationService {공용 함수 __construct(개인 읽기 전용 CsvNamesFileLoader $fileLoader,개인 읽기 전용 NameParser $parser,개인 읽기 전용 CacheStorageInterface $cacheStorage) {}공용 함수 loadNames(): void{$namesData = $this->fileLoader->load();$names = $this->parser->parse($namesData);$this->cacheStorage->store('names', $names); } }
최종 클래스 ValidUnitExampleTest는 TestCase를 확장합니다. {/** * @test */public 함수parse_all_names(): void{$namesData = [ ['존', 'M'], ['레논', 'U'], ['사라', 'W'] ];$sut = 새로운 NameParser();$result = $sut->parse($namesData); self::assert동일( [new Name('John', new Gender('M')),new Name('Lennon', new Gender('U')),new Name('Sarah', new Gender('W')) ],$결과); } }
[!경고|스타일:플랫|레이블:BAD]
최종 클래스 ApplicationService {공용 함수 __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}공용 함수 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());return true; } }
최종 수업 구독 {public function __construct(private Status $status, private DateTimeImmutable $modifiedAt) {}public function 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 함수 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:: 주장True($result); }/** * @test */public 함수 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); } 비공개 함수 createRepository(Subscription $subscription): SubscriptionRepositoryInterface{새 클래스 반환($expiredSubscription)은 SubscriptionRepositoryInterface를 구현합니다. {public function __construct(private readonly Subscription $subscription) {} 공개 함수 findById(int $id): 구독{return $this->subscription; } }; } }
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 ApplicationService {공용 함수 __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}공용 함수 renewSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionId);return $subscription->renew(new DateTimeImmutable( ))); } }
최종 수업 구독 {개인 상태 $status; 개인 DateTimeImmutable $modifiedAt; 공개 함수 __construct(DateTimeImmutable $modifiedAt) {$this->status = 상태::new();$this->modifiedAt = $modifiedAt; }공용 함수 갱신(DateTimeImmutable $modifiedAt): bool{if (!$this->status->isEqual(Status::expired())) {return false; }$this->status = 상태::active();$this->modifiedAt = $modifiedAt;return true; }공용 함수 active(DateTimeImmutable $modifiedAt): void{//simplified$this->status = Status::active();$this->modifiedAt = $modifiedAt; }공개 함수 만료(DateTimeImmutable $modifiedAt): void{//simplified$this->status = Status::expired();$this->modifiedAt = $modifiedAt; }공용 함수 isActive(): bool{return $this->status->isEqual(Status::active()); } }
최종 클래스 ValidTestExample은 TestCase를 확장합니다. {/** * @test */public 함수 renew_an_expired_subscription_is_possible(): void{$expiredSubscription = SubscriptionMother::expired();$sut = new ApplicationService($this->createRepository($expiredSubscription));$result = $sut- >renewSubscription(1);// 관찰 가능한 동작의 일부가 아니기 때문에 수정된 At 확인을 건너뜁니다. 이 값을 확인하려면 // 아마도 테스트 목적으로만 수정된 At에 대한 getter를 추가해야 할 것입니다.self::assertTrue($expiredSubscription->isActive());self::assertTrue($result); }/** * @test */public 함수 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); } 비공개 함수 createRepository(Subscription $subscription): SubscriptionRepositoryInterface{새 클래스 반환($expiredSubscription)은 SubscriptionRepositoryInterface를 구현합니다. {public function __construct(private readonly Subscription $subscription) {} 공개 함수 findById(int $id): 구독{return $this->subscription; } }; } }
메모
첫 번째 구독 모델은 디자인이 좋지 않습니다. 하나의 비즈니스 작업을 호출하려면 세 가지 메서드를 호출해야 합니다. 또한 작동을 확인하기 위해 getter를 사용하는 것은 좋은 습관이 아닙니다. 이 경우 modifiedAt
변경 확인을 건너뛰었습니다. 아마도 갱신 작업 중 특정 modifiedAt
설정을 만료 비즈니스 작업으로 테스트할 수 있습니다. modifiedAt
에 대한 getter는 필요하지 않습니다. 물론 테스트용으로만 제공되는 게터를 피할 가능성을 찾는 것이 매우 어려운 경우도 있지만 항상 이를 도입하지 않도록 노력해야 합니다.
[!경고|스타일:플랫|레이블:BAD]
CannotSuspendExpiredSubscriptionPolicy 클래스는 SuspendingPolicyInterface를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {return false; }참을 반환합니다; } }
CannotSuspendExpiredSubscriptionPolicyTest 클래스는 TestCase를 확장합니다. {/** * @test */public 함수 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->suspens($subscription, new DateTimeImmutable())); }/** * @test */public 함수 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->suspens($subscription, new DateTimeImmutable())); } }
CannotSuspendNewSubscriptionPolicy 클래스는 SuspendingPolicyInterface를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{if ($subscription->isNew()) {return false; }참을 반환합니다; } }
CannotSuspendNewSubscriptionPolicyTest 클래스는 TestCase를 확장합니다. {/** * @test */public 함수 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->일시 중지($subscription, 새로운 DateTimeImmutable())); }/** * @test */public 함수 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를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable:: 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 CanSuspendAfterOneMonthPolicy();$subscription = new Subscription(new DateTimeImmutable('2020-12-28'));self::assertTrue($policy->suspens($subscription, $date)); }/** * @test */public 함수 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->suspens($subscription, $date)); } }
수업현황 {private const EXPIRED = '만료됨';private const ACTIVE = 'active';private const NEW = 'new';private const SUSPENDED = '일시 중지됨';private 함수 __construct(private readonly string $status) {$this->상태 = $status; }공용 정적 함수 만료(): self{return new self(self::EXPIRED); }공용 정적 함수 active(): self{return new self(self::ACTIVE); }공용 정적 함수 new(): self{return new self(self::NEW); }공용 정적 함수 일시 중단(): 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 함수 suspending_a_subscription_is_possible_when_a_policy_returns_true(): void{$policy = $this->createMock(SuspendingPolicyInterface::class);$policy->expects($this->once())->method( '일시 중지')->willReturn(true);$sut = 새 구독(새 항목) DateTimeImmutable());$result = $sut->suspended($policy, new DateTimeImmutable());self::assertTrue($result);self::assertTrue($sut->isSuspended()); }/** * @test */public 함수 suspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void{$policy = $this->createMock(SuspendingPolicyInterface::class);$policy->expects($this->once())->method( '일시 중지')->willReturn(false);$sut = 신규 구독(new DateTimeImmutable());$result = $sut->suspended($policy, new DateTimeImmutable());self::assertFalse($result);self::assertFalse($sut->isSuspended()); }/** * @test */public 함수 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 함수 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)); } }
주의] 코드를 1:1, 1클래스:1테스트로 작성하지 마세요. 이는 리팩토링을 어렵게 만드는 취약한 테스트로 이어집니다.
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 CannotSuspendExpiredSubscriptionPolicy는 SuspendingPolicyInterface를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{if ($subscription->isExpired()) {return false; }참을 반환합니다; } }
최종 클래스 CannotSuspendNewSubscriptionPolicy는 SuspendingPolicyInterface를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{if ($subscription->isNew()) {return false; }참을 반환합니다; } }
최종 클래스 CanSuspendAfterOneMonthPolicy는 SuspendingPolicyInterface를 구현합니다. {공용 함수 일시 중지(구독 $subscription, DateTimeImmutable $at): bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable:: createFromMutable($oneMonthEarlierDate)); } }
최종 수업 현황 {private const EXPIRED = '만료됨';private const ACTIVE = 'active';private const NEW = 'new';private const SUSPENDED = '일시 중지됨';private 함수 __construct(private readonly string $status) {$this->상태 = $status; }공용 정적 함수 만료(): self{return new self(self::EXPIRED); }공용 정적 함수 active(): self{return new self(self::ACTIVE); }공용 정적 함수 new(): self{return new self(self::NEW); }공용 정적 함수 일시 중단(): self{return new self(self::SUSPENDED); }공용 함수 isEqual(self $status): bool{return $this->status === $status->status; } }
최종 수업 구독 {개인 상태 $status; 개인 DateTimeImmutable $createdAt; 공개 함수 __construct(DateTimeImmutable $createdAt) {$this->status = 상태::new();$this->createdAt = $createdAt; }공용 함수 일시 중지(SuspendingPolicyInterface $suspendingPolicy, DateTimeImmutable $at): bool{$result = $suspendingPolicy->suspens($this, $at);if ($result) {$this->status = Status::suspensed() ; }$결과를 반환합니다. }공개 함수 isOlderThan(DateTimeImmutable $date): bool{return $this->createdAt < $date; }공용 함수 activate(): void{$this->status = Status::active(); }공용 함수 만료(): 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 함수 suspensing_an_expired_subscription_with_cannot_pens_expired_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$sut->expire();$result = $sut ->일시 중지(새 CannotSuspendExpiredSubscriptionPolicy(), 새 DateTimeImmutable());self::assertFalse($result); }/** * @test */public 함수 suspending_a_new_subscription_with_cannot_suspens_new_policy_is_not_possible(): void{$sut = new Subscription(new DateTimeImmutable());$result = $sut->stop(new CannotSuspendNewSubscriptionPolicy(), new DateTimeImmutable());self ::assertFalse($result); }/** * @test */public 함수 suspensing_an_active_subscription_with_cannot_suspens_new_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy() , 새로운 DateTimeImmutable());self::assertTrue($result); }/** * @test */public 함수 suspensing_an_active_subscription_with_cannot_suspens_expired_policy_is_possible(): void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy() , 새로운 DateTimeImmutable());self::assertTrue($result); }/** * @test */public function suspensing_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 함수 suspensing_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); } }
이와 같은 클래스를 올바르게 단위 테스트하는 방법은 무엇입니까?
클래스 애플리케이션 서비스 {공용 함수 __construct(비공개 읽기 전용 OrderRepository $orderRepository,비공개 읽기 전용 FormRepository $formRepository) {}공용 함수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|스타일:플랫|레이블:좋음]
지나치게 복잡한 코드를 분리하여 클래스를 분리해야 합니다.
최종 클래스 ApplicationService {공용 함수 __construct(비공개 읽기 전용 OrderRepositoryInterface $orderRepository,비공개 읽기 전용 FormRepositoryInterface $formRepository,비공개 읽기 전용 FormApiInterface $formApi,비공개 읽기 전용 ChangeFormStatusService $changeFormStatusService) {}공용 함수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 {공용 함수 변경 상태(주문 $order, 양식 $form, 문자열 $formStatus): void{$status = FormStatus::createFromString($formStatus);$form->changeStatus($status);if ($form->isAccepted( )) {$order->changeStatus(OrderStatus::paid()); } } }
최종 클래스 ChangingFormStatusTest는 TestCase를 확장합니다. {/** * @test */public 함수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 함수changing_a_form_status_to_refused_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를 확장합니다. {공용 함수 testSetName(): void{$customer = new Customer('Jack');$customer->setName('John');self::assertSame('John', $customer->getName()); } }
최종 클래스 EventSubscriber {공용 정적 함수 getSubscribedEvents(): array{return ['event' => 'onEvent']; }공개 함수 onEvent(): 무효{ } }
최종 클래스 EventSubscriberTest는 TestCase를 확장합니다. {공용 함수 testGetSubscribedEvents(): void{$result = EventSubscriber::getSubscribedEvents();self::assertSame(['event' => 'onEvent'], $result); } }
주의 복잡한 논리 없이 코드를 테스트하는 것은 의미가 없지만 취약한 테스트로 이어집니다.
[!경고|스타일:플랫|레이블:BAD]
최종 클래스 UserRepository {공용 함수 __construct(비공개 읽기 전용 연결 $connection) {}공용 함수 getUserNameByEmail(string $email): ?array{return $this->connection->createQueryBuilder() ->from('사용자', 'u') ->where('u.email = :email') ->setParameter('이메일', $email) ->실행() ->가져오기(); } }
최종 클래스 TestUserRepository는 TestCase를 확장합니다. {공용 함수 testGetUserNameByEmail(): void{$email = '[email protected]';$connection = $this->createMock(Connection::class);$queryBuilder = $this->createMock(QueryBuilder::class); $result = $this->createMock(ResultStatement::class);$userRepository = 신규 UserRepository($connection);$connection->expects($this->once()) ->method('createQueryBuilder') ->willReturn($queryBuilder);$queryBuilder->기대($this->once()) ->방법('from') ->with('사용자', 'u') ->willReturn($queryBuilder);$queryBuilder->기대($this->once()) ->방법('어디') ->with('u.email = :email') ->willReturn($queryBuilder);$queryBuilder->기대($this->once()) ->방법('setParameter') ->('이메일', $email)과 함께 ->willReturn($queryBuilder);$queryBuilder->기대($this->once()) -> 메소드 ( '실행') -> WillReturn ($ result); $ result-> expects ($ this-> 일시간 ()) -> 메소드 ( 'Fetch') -> WillReturn ([ 'email'=> $ email]); $ result = $ userrepository-> getUserNameByEmail ($ email); self :: AsserTsame ([ 'email'=> $ email], $ result); } }
주의 이러한 방식으로 리포지토리를 테스트하면 취약한 테스트가 발생하고 리팩터링이 어려워집니다. 저장소를 테스트하려면 통합 테스트를 작성하세요.
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 GoodTest는 테스트 케이스를 확장합니다 {private subscriptionFactory $ sut; public function setup () : void {$ this-> sut = new SubscriptionFactory (); }/** * @test */public function restes_a_subscription_for_a_given_date_range () : void {$ result = $ this-> sut-> create (new dateTimeImmutable (), new DateTimeImmutable ( 'now +1 년'); self :: AsserTinStanceof (구독 :: 클래스, $ result); }/** * @test */public function rows_an_exception_on_invalid_date_range () : void {$ this-> eccessexception (createSubscriptionException :: class); $ result = $ this-> sut-> create (new dateTimeImmutable ( 'now -1 년'), new dateTimeImutable ()); } }
메모
setUp 메소드를 사용하는 가장 좋은 경우는 상태 비저장 객체를 테스트하는 것입니다.
setUp
내부에서 이루어진 모든 구성은 테스트를 함께 결합하며 모든 테스트에 영향을 미칩니다.
테스트 간 상태 공유를 피하고 테스트 방법에 따라 초기 상태를 구성하는 것이 좋습니다.
적절한 테스트 방법으로 구성한 것에 비해 가독성이 떨어집니다.
[!팁|스타일:플랫|레이블:더 좋음]
최종 클래스 BetterStest는 테스트 케이스를 확장합니다 {/** * @test */public function_an_active_subscription_with_cannot_suspend_new_spossible () : void {$ sut = $ this-> createAnactivesubscription (); $ result = $ sut-> splession (new cannotsuspendnewsubsubsubspolicy :) : 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 CannotSuspendNewSubscriptionPolicy(), new DateTimeImmutable());self: : AssertFalse ($ result); } private function createEnewSubscription () : subscription {return new subscription (new dateTimeImMutable ()); } private function createAnactivesUbscription () : 구독 {$ subscription = new Subscription (new dateTimeImMutable ()); $ subscription-> activeate (); $ 구독 반환; } }
메모
이 접근 방식은 가독성을 향상시키고 구분을 명확하게 합니다(코드를 작성하는 것보다 읽는 것이 더 많습니다).
개인 도우미는 명시적인 의도를 제공하지만 각 테스트 메서드에서 사용하는 것이 지루할 수 있습니다.
여러 테스트 클래스 간에 유사한 테스트 개체를 공유하려면 다음을 사용하세요.
개체 어머니
빌더
[!경고|스타일:플랫|레이블:BAD]
최종 클래스 고객 {private customertype $ type; private discountcalculationpolicyinterface $ discountcalculationpolicy; public function __construct () {$ this-> type = customertype :: normal (); $ this-> 할인 countcalculationpolicy = new NormalDiscountPolicy (); } public function makevip () : void {$ this-> type = customertype :: vip (); $ this-> 할인 calculationPolicy = new vipdiscountPolicy (); } public function getCustomerType () : customerType {return $ this-> type; } public function getPerCentagedIscount () : int {return $ this-> 할인 calculationPolicy-> getPerCentagedIscount (); } }
최종 클래스 invalidTest는 테스트 케이스를 확장합니다 {public function testmakevip () : void {$ sut = new Customer (); $ sut-> makevip (); self :: AsserTsame (customertype :: vip (), $ sut-> getCustomerType ()); } }
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 고객 {private customertype $ type; private discountcalculationpolicyinterface $ discountcalculationpolicy; public function __construct () {$ this-> type = customertype :: normal (); $ this-> 할인 countcalculationpolicy = new NormalDiscountPolicy (); } public function makevip () : void {$ this-> type = customertype :: vip (); $ this-> 할인 calculationPolicy = new vipdiscountPolicy (); } public function getPerCentagedIscount () : int {return $ this-> 할인 calculationPolicy-> getPerCentagedIscount (); } }
최종 클래스 ValidTest는 테스트 케이스를 확장합니다 {/** * @test */public function a_vip_customer_has_a_25_percentage_discount () : void {$ sut = new Customer (); $ sut-> makeVip (); self :: AsserTsame (25, $ sut-> getperCentagedIscount (25, $ sut-> getperCentagedIscount); } }
주의 테스트에서 상태를 확인하기 위해 추가 프로덕션 코드(예: getter getCustomerType())를 추가하는 것은 나쁜 습관입니다. 다른 도메인의 중요한 값(이 경우 getPercentageDiscount())으로 확인해야 합니다. 물론 때로는 동작을 검증할 수 있는 다른 방법을 찾는 것이 어려울 수도 있고, 테스트에서 정확성을 검증하기 위해 추가적인 프로덕션 코드를 추가해야 할 수도 있지만, 우리는 그렇게 하지 않도록 노력해야 합니다.
최종 클래스 할인 계산기 {공개 함수 계산 (int $ isvipfromyears) : int { Assert :: Greaterthaneq ($ isvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } }
[!경고|스타일:플랫|레이블:BAD]
최종 클래스 invalidTest는 테스트 케이스를 확장합니다 {/** * @dataprovider 할인 DATAPROVIDER */public function testCalculate (int $ vipdaysfrom, int $ expect) : void {$ sut = new DiscountCalculator (); self :: AsserTsame ($ explice, $ sut-> calculate ($ vipdaysfrom). ); } public function discountdataprovider () : array {return [ [0, 0 * 10 + 3], // 도메인이 누출되어 [1, 1 * 10 + 3], [5, 5 * 10 + 3], [8, 80] ]; } }
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 ValidTest는 테스트 케이스를 확장합니다 {/** * @dataprovider 할인 DATAPROVIDER */public function testCalculate (int $ vipdaysfrom, int $ expect) : void {$ sut = new DiscountCalculator (); self :: AsserTsame ($ explice, $ sut-> calculate ($ vipdaysfrom). ); } public function discountdataprovider () : array {return [ [0, 3], [1, 13], [5, 53], [8, 80] ]; } }
메모
테스트에서 생산 로직을 복제하지 마세요. 하드코딩된 값으로 결과를 확인하세요.
[!경고|스타일:플랫|레이블:BAD]
클래스 할인 계산기 {public function accempationinternaldiscount (int $ isvipfromyears) : int { Assert :: Greaterthaneq ($ isvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } public function calculateadDitionAldScountFromeXternalSystem () : int {// 외부 시스템에서 데이터를 가져와 할인 턴 5를 계산합니다. } }
클래스 Orderservice {public function __construct (개인 기독교 할인 계산기 $ 할인 계산기) {} public function getTotalPriceWithDiscount (int $ totalprice, int $ vipfromdays) : int {$ internaldiscount = $ this-> 할인 된 vipfromdiscount ($ vipfromdays); $ this-> 할인 계산기-> CalculateAdDitionAldICountFromeXternalSystem (); $ 할인 량 = $ internalDiscount + $ externAldIscount; return $ totalprice-(int) CEIL (($ TotalPrice * $ discountsum) / 100); } }
최종 클래스 invalidTest는 테스트 케이스를 확장합니다 {/** * @dataprovider OrderDatapRovider */public function testgetToTalPriceWithDiscount (int $ totalPrice, int $ vipdaysfrom, int $ explice) : void {$ discountcalculator = $ this-> createPartialMock (createPartInculator :: class, [ ''calculateadDitionAldIsCountFromexternalSystem ']); $ 할당 CountCalculator-> 메소드 ('calculateadDitionAldScountFromeXternalSystem ')-> WillEturn (5); $ sut = new Orderservice ($ discountcalculator); self :: Assertsame ($ sut-> gettotalprice) , $ vipdaysfrom)); } public function orderdataprovider () : array {return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
[!TIP|스타일:플랫|레이블:좋음]
인터페이스 externAldIscountCalculatorInterface {public function acculate () : int; }
최종 클래스 InternAldIscountCalculator {공개 함수 계산 (int $ isvipfromyears) : int { Assert :: Greaterthaneq ($ isvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } }
최종 클래스 Orderservice {public function __construct (개인 재시험적 인 InternAldScountCalculator $ 할인 계산기, 개인 readonly externAldIscountCalculatorInterface $ externAldIscountCalculator) {} public function getTotalPriceWithDiscount (int $ totalprice, int $ vipfromdays) : int {$ interaldiscount = $ thishount->); $ externAldIscount = $ this-> externAldIscountCalculator--> calculate (); $ 할인 + $ internalDiscount + $ externAldIscount; return $ totalprice- (int) ceil (($ totalprice * $ discountsum) / 100); } }
최종 클래스 ValidTest는 테스트 케이스를 확장합니다 {/** * @dataprovider OrderDatapRovider */public function testGetToTalPriceWithDiscount (int $ totalPrice, int $ vipdaysfrom, int $ excent) : void {$ externAldIscountCalculator = new class () externAldAldIscountCalculatorInterface {public function () : int {return 5; } }; $ sut = new Orderservice (New InternalDiscountCalculator (), $ externAldScountCalculator); self :: AsserTsame ($ explice, $ sut-> getTotalPriceWithDiscount ($ 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 $ ranverncost */public function __construct (private array $ airet, private int $ ranverncost) {} public function getTotal () : int {return $ this-> getItemStotal () + $ this-> TransportCost; } private function getItemStotal () : int {return array_reduce (array_map (fn (OrderItem $ item) => $ item-> total, $ 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 $ excentTotal) : void {self :: Assertsame ($ excentTotal, $ ordert-> getTotal (); }/** * @test * @dataProvider orderItemsDataProvider */public function get_items_total_returns_a_total_cost_of_all_items(Order $order, int $expectedTotal): void{self::assertSame($expectedTotal, $this->invokePrivateMethodGetItemsTotal($order)); } public function ordersdataprovider () : array {return [ [New Order ([New OrderItem (20), New Orderitem (20), New Orderitem (20)], 15), 75], [새로운 순서 ([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 OrderItem (20), New Orderitem (30), New Orderitem (40)], 0), 90], [New Order ([New Orderitem (99), New Orderitem (99), New Orderitem (99)], 9), 297] ]; } 개인 함수 invokePrivatemetHodgetItemStotal (Order & $ Order) : int {$ reclection = new ReflectionClass (get_class ($ order)); $ method = $ reflection-> getMethod ( 'getItemStotal'); $ method-> setAccessible (true); $ method-> invokeargs ($ order, []); } }
[!TIP|스타일:플랫|레이블:좋음]
최종 클래스 ValidTest는 테스트 케이스를 확장합니다 {/** * @test * @dataprovider OrdersDatapRovider */public function get_total_returns_a_total_cost_of_a_whole_order (order $ order, int $ excentTotal) : void {self :: Assertsame ($ excentTotal, $ ordert-> getTotal (); } public function ordersdataprovider () : array {return [ [New Order ([New OrderItem (20), New Orderitem (20), New Orderitem (20)], 15), 75], [새로운 순서 ([New OrderItem (20), New Orderitem (30), New Orderitem (40)], 0), 90], [New Order ([New Orderitem (99), New Orderitem (99), New Orderitem (99)], 9), 306] ]; } }
주의] 테스트에서는 공개 API만 확인해야 합니다.
시간은 비결정적이기 때문에 일시적인 종속성입니다. 각 호출은 다른 결과를 반환합니다.
[!경고|스타일:플랫|레이블:BAD]
최종 클래스 시계 {public static datetime | null $ currentDateTime = null; public static 함수 getCurrentDateTime () : dateTime {if (null === self :: $ currentDateTime) {self :: $ currentDateTime = new DateTime (); } return self :: $ currentDateTime; } public static function set (dateTime $ dateTime) : void {self :: $ currentDateTime = $ dateTime; } public static function reset () : void {self :: $ currentDateTime = null; } }
최종 클래스 고객 {private datetime $ createat; public function __construct () {$ this-> createat = clock :: getCurrentDateTime (); } public function isvip () : bool {return $ this-> create-> diff (clock :: getCurrentDateTime ())-> y> = 1; } }
최종 클래스 invalidTest는 테스트 케이스를 확장합니다 {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_as_a_a_vip () : void { clock :: set (new DateTime ( '2019-01-01')); $ sut = new Customer (); clock :: reset (); // 공유 상태를 재설정하는 것에 대해 기억해야합니다. }/** * @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 (); clock :: reset (); // 공유 상태를 재설정하는 것에 대해 기억해야합니다. } }
[!TIP|스타일:플랫|레이블:좋음]
인터페이스 클럭 ernceface {public function getCurrentTime () : dateTimeImutable; }
최종 클래스 시계는 ClockInterface를 구현합니다 {개인 기능 __construct () { } public static function create () : self {return new self (); } public function getCurrentTime () : dateTimeImMutable {return new dateTimeImMutable (); } }
최종 클래스 고정식은 ClockInterface를 구현합니다 {private function __construct (private readOnly dateTimeImmutable $ fixdate) {} public static function create (dateTimeImmutable $ fixdDate) : self {return new self ($ fixedDate); } public function getCurrentTime () : dateTimeImMutable {return $ this-> 고정 데이터; } }
최종 클래스 고객 {public function __construct (private readOnly dateTimeImmutable $ createdAt) {} public function isvip (dateTimeImmutable $ currentDate) : bool {return $ this-> createat-> diff ($ currentDate)-> y> = 1; } }
최종 클래스 ValidTest는 테스트 케이스를 확장합니다 {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_as_a_a_vip () : void {$ sut = new Customer (FixedClock :: 만들기) dateTimeImutable ( '2019-01-01'))-> getCurrentTime ()); self :: assertTrue ($ sut-> isvip (fixedClock :: create (new dateTimeImmutable ( '2020-01-02'))-> getCurrentTime (GetCurrentTime) ))); }/** * @test */public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip () : void {$ sut = new Customer (fixedClock :: create (new dateTimeImutable ( '2019-01-01'))-> getCurrentTime ()); self :: assertfalse ($ sut-> isvip (fixedClock :: create (new dateTimeImmutable ( '2019-05-02'))-> getCurrentTime (GetCurrentTime). ))); } }
메모
시간과 난수는 도메인 코드에서 직접 생성되어서는 안 됩니다. 동작을 테스트하려면 결정적인 결과가 있어야 하므로 위의 예와 같이 이러한 값을 도메인 개체에 주입해야 합니다.
100% 적용 범위는 목표가 아니며 바람직하지도 않습니다. 100% 적용 범위가 있으면 테스트가 매우 취약할 수 있으며 이는 리팩토링이 매우 어렵다는 것을 의미합니다. 돌연변이 테스트는 테스트 품질에 대한 더 나은 피드백을 제공합니다. 더 읽어보세요
테스트 중심 개발 : 예시 / 켄트 벡 - 클래식
단위 테스트 원리, 실습 및 패턴 / Vladimir Khorikov - 내가 읽은 테스트에 관한 최고의 책
카밀 루친스키
트위터: https://twitter.com/Sarvendev
블로그: https://sarvendev.com/
링크드인: https://www.linkedin.com/in/kamilruczynski/