Upgrade to Pro — share decks privately, control downloads, hide ads and more …

(PHPers Wrocław #5) How to write valuable unit ...

(PHPers Wrocław #5) How to write valuable unit test?

Jak zwiększyć wartość testów jednostkowych?

Michał Kopacz, Łukasz Wróbel

Czy wiecie, jak zdefiniować czym są "dobre" testy jednostkowe? Główną obiektywną miarą ich jakości jest poziom pokrycia kodu testami. Tylko czy to wystarczy? Wprowadzając w RST zasadę 75% pokrycia kodu testami wiedzieliśmy, że samo kryterium liczbowe to za mało. Można przecież napisać testy dające 100% pokrycia, ale nie zawierające żadnej asercji. Nie chcąc bazować wyłącznie na poziomie pokrycia kodu, postanowiliśmy zebrać nasze doświadczenia w pisaniu testów jednostkowych i zorganizowaliśmy dla naszych zespołów deweloperskich warsztaty. Jeżeli chcesz się dowiedzieć: jak nazywać testy i nadawać im czytelną strukturę, co to znaczy "testowalny kod", jak się uchronić przed kruchością testów, czy też jaka jest różnica między pisaniem testów przed i po implementacji, to zobacz prezentację, w której dzielimy się zdobytą wiedzą i przykładami omawianymi na warsztatach.

Avatar for RST Software Masters

RST Software Masters

November 21, 2016
Tweet

Other Decks in Programming

Transcript

  1. Data Provider /** * @dataProvider dataCaseProvider */ public function testGetStatus(array

    $data, $expectedIsOk, $expectedIsWarning) { $statusProviderMock = $this->getMockBuilder(QueueStatusProvider::class) ->setConstructorArgs([$this->repositoryMock]) ->setMethods(['getData']) ->getMock(); $statusProviderMock->expects($this->once()) ->method('getData') ->willReturn($data); $result = $statusProviderMock->getStatus(); $this->assertInstanceOf(StatusInterface::class, $result); $this->assertSame($expectedIsOk, $result->isOk()); $this->assertSame($expectedIsWarning, $result->isWarning()); $this->assertSame($data, $result->getDetails()); }
  2. Data Provider public function dataCaseProvider() { return [ [ [

    QueueStatusProvider::SECTION_WARNING => [ QueueStatusProvider::STATUS_CATEGORY_DELAYED => [1], ], QueueStatusProvider::SECTION_FAILURE => [ QueueStatusProvider::STATUS_CATEGORY_DELAYED => [1, 2, 3], QueueStatusProvider::STATUS_CATEGORY_FAILED => [], ], ], false, //expectedIsOk false //expectedIsWarning ], [ [ QueueStatusProvider::SECTION_WARNING => [ QueueStatusProvider::STATUS_CATEGORY_DELAYED => [1], ], QueueStatusProvider::SECTION_FAILURE => [ QueueStatusProvider::STATUS_CATEGORY_DELAYED => [], QueueStatusProvider::STATUS_CATEGORY_FAILED => [], ], ], false, //expectedIsOk true //expectedIsWarning ],
  3. Separate each case /** * @test */ public function returns_not_ok_status_when_any_delayed_or_failed_report_exists()

    /** * @test */ public function returns_not_ok_with_warning_status_when_only_delayed_reports_exist()
  4. Examples /** * @test */ public function talk_contain_message_after_it_has_been_added() { //Given

    $message = new Message("test message"); $talkId = 1; $talkStorage = $this->getMock(TalkStorage::class); $talkManager = new TalkManager($talkStorage); $talkStorage->method('getMessages') ->with($talkId) ->willReturn([$message]); //When $talkManager->add($talkId, $message); //Then $this->assertTrue($talkManager->hasMessage($talkId, $message)); } /** * @test */ public function saved_message_in_storage_when_it_is_adding_to_talk() { //Given $message = new Message("test message"); $talkId = 1; $talkStorage = $this->getMock(TalkStorage::class); $talkManager = new TalkManager($talkStorage); //Expect $talkStorage->expects($this->once()) ->method('save') ->with($talkId, $message); //When $talkManager->add($talkId, $message); }
  5. public function handle($id, $isForced = false) { $logger = $this->debugLogger;

    $item = $this->debtReportTable->getById($id); if (!$item) { throw new DebtReportNotFoundException('Report does not exist: ' . $id); } $content = $item->getReportContent(); $this->updateContentWithEntity($content, $item) ->renameKeys($content) ->organizeCurrency($content) ->addFilesToReport($content, $isForced); $this->apiClient->setRoute('api/rest/v1/debt'); $response = $this->apiClient->dispatch(Request::METHOD_POST, $content)->getResponse(); if (in_array($this->apiClient->getResponseStatusCode(), $this->AdaUnrecoverableFailureCodes)) { $responseRawBody = $this->apiClient->getResponseRawBody(); if ($logger) { $logger->critical(sprintf('Debt reporting failed at AdaSoftware request with status: [%s: %s]', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus())); $logger->debug(sprintf('ADA API internal server error [%s: %s]. Reason: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($responseRawBody, true))); } $this->sendMailWithLog($item, $responseRawBody); $item->setStatus(DebtReport::STATUS_FAILED); $this->updateReportStats($item); $this->debtReportTable->save($item); throw new InternalSwApiException($responseRawBody); } elseif (!$this->apiClient->hasValidData() || !isset($response['_embedded']['debts']) || !is_array($response['_embedded']['debts'])) { $responseRawBody = $this->apiClient->getResponseRawBody(); if ($logger) { $logger->critical('Debt reporting failed at AdaSoftware request.'); $logger->debug(sprintf('ADA API failure [%s: %s]. Response body: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($responseRawBody, true))); } $this->sendMailWithLog($item, $responseRawBody); $item->setStatus(DebtReport::STATUS_FAILED); $this->debtReportTable->save($item); } else { if ($logger) { $logger->debug(sprintf('ADA API success [%s: %s]. Response body: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($this->apiClient->getResponseRawBody(), true))); } foreach ($response['_embedded']['debts'] as $debt) { $link = new DebtToDebtReport(); $link->setId($debt['id']); $item->addDebtToDebtReport($link); } $item->setStatus(DebtReport::STATUS_COMPLETED); $this->debtReportTable->save($item); $this->updateReportStats($item); return true; } return false; } Testable code?
  6. BUILD REQUEST CONTENT SEND REQUEST HANDLE RESPONSE WRONG STATUS CODE

    INVALID RESPONSE BODY PROCESS VALID RESPONSE
  7. Let’s test request content building $content = $item->getReportContent(); $this->updateContentWithEntity($content, $item)

    ->renameKeys($content) ->organizeCurrency($content) ->addFilesToReport($content, $isForced); $this->apiClient->setRoute('api/rest/v1/debt'); $response = $this->apiClient->dispatch(Request::METHOD_POST, $content)->getResponse();
  8. But it’s not the end of preparing test ... $response

    = $this->apiClient->dispatch(Request::METHOD_POST, $content)->getResponse(); } else { if ($logger) { $logger->debug(sprintf('ADA API success [%s: %s]. Response body: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($this->apiClient->getResponseRawBody(), true))); } foreach ($response['_embedded']['debts'] as $debt) { $link = new DebtToDebtReport(); $link->setId($debt['id']); $item->addDebtToDebtReport($link); } $item->setStatus(DebtReport::STATUS_COMPLETED); $this->debtReportTable->save($item); $this->updateReportStats($item); return true; } return false; } ...
  9. Double other collaborators protected function setUp() { $this->statsServiceMock = $this->getMockBuilder(\Inkasso\Statistics\DebtReportStatisticsService::class)

    ->disableOriginalConstructor()->getMock(); $this->apiClientMock = $this->getMockBuilder(\ApiClient\Client\AdaSoftware::class) ->disableOriginalConstructor()->getMock(); $this->debtReportTable = $this->getMockBuilder(\DebtReport\Entity\Table\DebtReportTable::class) ->disableOriginalConstructor()->getMock(); $this->storageAdapterMock = $this->getMockBuilder(\FileStorage\Storage\StorageAdapterInterface::class) ->disableOriginalConstructor()->getMock(); $this->consumer = new ReportHandler($this->statsServiceMock, $this->storageAdapterMock, $this->debtReportTable, $this->apiClientMock); }
  10. Decouple logic from infrastructure • DebtResponseToItemsMapper • Document Object Model

    (DOM) • Asynchrony (android.app.Service) foreach ($response['_embedded']['debts'] as $debt) { $link = new DebtToDebtReport(); $link->setId($debt['id']); $item->addDebtToDebtReport($link); } $item->setStatus(DebtReport::STATUS_COMPLETED); $this->debtReportTable->save($item); infra logic
  11. public function test_fetchAll_emptyLanguage() { $paramsMock = $this->getMock('Zend\Stdlib\Parameters'); $paramsMock->expects($this->at(0)) ->method('get') ->with($this->equalTo('labels'))

    ->will($this->returnValue('')); $paramsMock->expects($this->at(1)) ->method('get') ->with($this->equalTo('module')) ->will($this->returnValue('')); $paramsMock->expects($this->at(2)) ->method('get') ->with($this->equalTo('lang')) ->will($this->returnValue('')); } Call order Is the call order relevant? How is it connected to the result? Are empty strings important?
  12. $mockObjects = [ '11' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(4) ->andReturn('11') ->getMock(),

    '22' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(5) ->andReturn('22') ->getMock(), '33' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(4) ->andReturn('33') ->getMock(), ]; Call count Are these performance tests?
  13. Testing privacy public function testSetFilters() { $filters = ['foo' =>

    'bar']; /** @var Client|\PHPUnit_Framework_MockObject_MockObject $clientMock */ $clientMock = $this->getMockBuilder(Client::class) ->disableOriginalConstructor() ->setMethods(['addParameter']) ->getMock(); $clientMock ->expects($this->once()) ->method('addParameter') ->with( $this->equalTo(HalStorageAdapter::FILTER_PARAM), $this->equalTo(json_encode($filters)) ); $halStorageAdapter = new HalStorageAdapter('/foo/bar', $clientMock); ReflectionHelper::executeMethod($halStorageAdapter, 'setFilters', [[]]); ReflectionHelper::executeMethod($halStorageAdapter, 'setFilters', [$filters]); } Cannot we test this by calling public methods?
  14. Are you tempted to: • Call a private method via

    reflection? • Mock a private method when testing a public method? Public Private SRP Public Public +
  15. $mockObjects = [ '11' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(4) ->andReturn('11') ->getMock(),

    '22' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(5) ->andReturn('22') ->getMock(), '33' => Mockery::mock(ArticleObjectInterface::class) ->shouldReceive('getId') ->times(4) ->andReturn('33') ->getMock(), ]; Testing the implementation
  16. public function handle($id, $isForced = false) { $logger = $this->debugLogger;

    $item = $this->debtReportTable->getById($id); if (!$item) { throw new DebtReportNotFoundException('Report does not exist: ' . $id); } $content = $item->getReportContent(); $this->updateContentWithEntity($content, $item) ->renameKeys($content) ->organizeCurrency($content) ->addFilesToReport($content, $isForced); $this->apiClient->setRoute('api/rest/v1/debt'); $response = $this->apiClient->dispatch(Request::METHOD_POST, $content)->getResponse(); if (in_array($this->apiClient->getResponseStatusCode(), $this->AdaUnrecoverableFailureCodes)) { $responseRawBody = $this->apiClient->getResponseRawBody(); if ($logger) { $logger->critical(sprintf('Debt reporting failed at AdaSoftware request with status: [%s: %s]', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus())); $logger->debug(sprintf('ADA API internal server error [%s: %s]. Reason: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($responseRawBody, true))); } $this->sendMailWithLog($item, $responseRawBody); $item->setStatus(DebtReport::STATUS_FAILED); $this->updateReportStats($item); $this->debtReportTable->save($item); throw new InternalSwApiException($responseRawBody); } elseif (!$this->apiClient->hasValidData() || !isset($response['_embedded']['debts']) || !is_array($response['_embedded']['debts'])) { $responseRawBody = $this->apiClient->getResponseRawBody(); if ($logger) { $logger->critical('Debt reporting failed at AdaSoftware request.'); $logger->debug(sprintf('ADA API failure [%s: %s]. Response body: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($responseRawBody, true))); } $this->sendMailWithLog($item, $responseRawBody); $item->setStatus(DebtReport::STATUS_FAILED); $this->debtReportTable->save($item); } else { if ($logger) { $logger->debug(sprintf('ADA API success [%s: %s]. Response body: %s', $this->apiClient->getResponseStatusCode(), $this->apiClient->getResponseStatus(), var_export($this->apiClient->getResponseRawBody(), true))); } foreach ($response['_embedded']['debts'] as $debt) { $link = new DebtToDebtReport(); $link->setId($debt['id']); $item->addDebtToDebtReport($link); } $item->setStatus(DebtReport::STATUS_COMPLETED); $this->debtReportTable->save($item); $this->updateReportStats($item); return true; } return false; } Big chunk of code