Testes puros em PHP e PHPUnit


Existem muitas ferramentas no ecossistema PHP que fornecem testes convenientes de PHP. Um dos mais famosos é o PHPUnit , que é quase um sinônimo de teste nessa linguagem. No entanto, não se escreve muito sobre bons métodos de teste. Existem muitas opções para por que e quando escrever testes, que tipo de testes e assim por diante. Mas, para ser sincero, não faz sentido escrever um teste se você não conseguir lê-lo mais tarde .

Os testes são um tipo especial de documentação. Como escrevi anteriormente sobre TDD em PHP , o teste sempre (ou pelo menos deveria) claramente fala sobre qual é a tarefa de um pedaço de código específico.

Se um teste não puder expressar essa ideia, o teste será ruim.

Eu preparei um conjunto de técnicas que ajudarão os desenvolvedores de PHP a escrever testes bons, legíveis e úteis.

Vamos começar com o básico


Há um conjunto de técnicas padrão que muitos seguem sem nenhuma pergunta. Vou mencionar muitos deles e tentar explicar por que eles são necessários.

1. Os testes não devem conter operações de entrada e saída


O principal motivo : as operações de E / S são lentas e não confiáveis.

Lento : mesmo se você tiver o melhor hardware do mundo, a E / S ainda será mais lenta do que a memória acessa. Os testes devem sempre funcionar rápido, caso contrário, as pessoas os executam muito raramente.

Não confiável : alguns arquivos, binários, soquetes, pastas e registros DNS podem não estar disponíveis em algumas máquinas nas quais você está testando. Quanto mais você confia nos testes de E / S, mais os seus testes estão vinculados à infraestrutura.

Quais operações estão relacionadas à E / S:

  • Lendo e gravando arquivos.
  • Chamadas de rede.
  • Chamadas para processos externos (usando exec , proc_open , etc.).

Há situações em que a presença de operações de entrada e saída permite que você escreva testes mais rapidamente. Mas tenha cuidado: verifique se essas operações funcionam da mesma maneira em suas máquinas para desenvolvimento, montagem e implantação; caso contrário, você poderá ter sérios problemas.

Isole os testes para que eles não precisem de operações de E / S: Forneci uma solução de arquitetura abaixo que impede que os testes executem operações de E / S compartilhando responsabilidade entre interfaces.

Um exemplo:

 public function getPeople(): array { $rawPeople = file_get_contents( 'people.json' ) ?? '[]'; return json_decode( $rawPeople, true ); } 

Quando você inicia o teste usando esse método, um arquivo local será criado e, de tempos em tempos, instantâneos serão criados:

 public function testGetPeopleReturnsPeopleList(): void { $people = $this->peopleService ->getPeople(); // assert it contains people } 

Para fazer isso, precisamos configurar os pré-requisitos para executar os testes. À primeira vista, tudo parece razoável, mas na verdade é terrível.

Ignorar um teste devido ao fato de que os pré-requisitos não são atendidos não garante a qualidade do nosso software. Isso apenas oculta bugs!

Corrigimos a situação : isolamos as operações de E / S transferindo a responsabilidade para a interface.

 // extract the fetching // logic to a specialized // interface interface PeopleProvider { public function getPeople(): array; } // create a concrete implementation class JsonFilePeopleProvider implements PeopleProvider { private const PEOPLE_JSON = 'people.json'; public function getPeople(): array { $rawPeople = file_get_contents( self::PEOPLE_JSON ) ?? '[]'; return json_decode( $rawPeople, true ); } } class PeopleService { // inject via __construct() private PeopleProvider $peopleProvider; public function getPeople(): array { return $this->peopleProvider ->getPeople(); } } 

Agora eu sei que o JsonFilePeopleProvider usará a E / S em qualquer caso.

Em vez de file_get_contents() você pode usar uma camada de abstração como o sistema de arquivos Flysystem , para o qual é fácil criar stubs.

E então por que precisamos do PeopleService ? Boa pergunta Para isso, são necessários testes: desafiar a arquitetura e remover código inútil.

2. Os testes devem ser conscientes e significativos.


O principal motivo : os testes são uma forma de documentação. Mantenha-os claros, concisos e legíveis.

Clareza e brevidade : sem bagunça, sem milhares de linhas de stubs, sem seqüências de declarações.

Legibilidade : Os testes devem contar uma história. A estrutura “dado, quando, então” é excelente para isso.

Características de um teste bom e legível:

  • Contém apenas as chamadas necessárias para o método assert (de preferência um).
  • Ele explica claramente o que deve acontecer sob determinadas condições.
  • Ele testa apenas um ramo da execução do método.
  • Ele não faz um esboço para o universo inteiro por causa de qualquer afirmação.

É importante observar que, se sua implementação contiver expressões condicionais, operadores de transição ou loops, todos eles deverão ser explicitamente cobertos por testes. Por exemplo, para que as respostas iniciais sempre contenham um teste.

Repito: não é uma questão de cobertura, mas de documentação.

Aqui está um exemplo de um teste confuso:

 public function testCanFly(): void { $noWings = new Person(0); $this->assertEquals( false, $noWings->canFly() ); $singleWing = new Person(1); $this->assertTrue( !$singleWing->canFly() ); $twoWings = new Person(2); $this->assertTrue( $twoWings->canFly() ); } 

Vamos adaptar o formato "dado quando, então" e ver o que acontece:

 public function testCanFly(): void { // Given $person = $this->givenAPersonHasNoWings(); // Then $this->assertEquals( false, $person->canFly() ); // Further cases... } private function givenAPersonHasNoWings(): Person { return new Person(0); } 

Como a seção "Dados", "quando" e "então" podem ser transferidos para métodos privados. Isso tornará seu teste mais legível.

assertEquals bagunça inútil. A pessoa que está lendo isso deve rastrear a declaração para entender o que isso significa.

O uso de instruções específicas tornará seu teste muito mais legível. assertTrue() deve receber uma variável booleana, não uma expressão como canFly() !== true .

No exemplo anterior, substituímos assertEquals entre false e $person->canFly() por um assertFalse simples:

 // ... $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); // Further cases... 

Agora está tudo muito claro! Se uma pessoa não tem asas, ela não deve poder voar! Leia como um poema

Agora, a seção "Outros casos", que aparece duas vezes em nosso texto, é uma indicação clara de que o teste faz muitas declarações. O método testCanFly() é completamente inútil.

Vamos melhorar o teste novamente:

 public function testCanFlyIsFalsyWhenPersonHasNoWings(): void { $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); } public function testCanFlyIsTruthyWhenPersonHasTwoWings(): void { $person = $this->givenAPersonHasTwoWings(); $this->assertTrue( $person->canFly() ); } // ... 

Podemos até renomear o método de teste para que ele corresponda ao cenário real, por exemplo, em testPersonCantFlyWithoutWings , mas tudo me convém de testPersonCantFlyWithoutWings .

3. O teste não deve depender de outros testes


O principal motivo : os testes devem ser executados e executados com êxito em qualquer ordem.

Não vejo razões suficientes para criar interconexões entre testes. Recentemente me pediram para fazer um teste de função de login, vou dar aqui como um bom exemplo.

O teste deve:

  • Gere um token JWT para efetuar login.
  • Execute a função de login.
  • Aprovar a mudança de status.

Foi assim:

 public function testGenerateJWTToken(): void { // ... $token $this->token = $token; } // @depends testGenerateJWTToken public function testExecuteAnAmazingFeature(): void { // Execute using $this->token } // @depends testExecuteAnAmazingFeature public function testStateIsBlah(): void { // Poll for state changes on // Logged-in interface } 

Isso é ruim por vários motivos:

  • O PHPUnit não pode garantir essa ordem de execução.
  • Os testes devem poder executar independentemente.
  • Testes paralelos podem falhar aleatoriamente.

A maneira mais fácil de contornar isso é usar o esquema dado, quando e depois. Portanto, os testes serão mais ponderados, eles contarão uma história, demonstrando claramente suas dependências, explicando a função que está sendo testada.

 public function testAmazingFeatureChangesState(): void { // Given $token = $this->givenImAuthenticated(); // When $this->whenIExecuteMyAmazingFeature( $token ); $newState = $this->pollStateFromInterface( $token ); // Then $this->assertEquals( 'my-state', $newState ); } 

Também precisamos adicionar testes para autenticação, etc. Essa estrutura é tão boa que o Behat é usado por padrão .

4. Sempre implemente dependências


A principal razão : um tom muito ruim - para criar um esboço para o estado global. A incapacidade de criar stubs para dependências não permite testar a função.

Dica útil: esqueça as classes estáticas e instâncias estáticas . Se sua classe depende de algo, faça-o para que possa ser implementado.

Aqui está um exemplo triste:

 class FeatureToggle { public function isActive( Id $feature ): bool { $cookieName = $feature->getCookieName(); // Early return if cookie // override is present if (Cookies::exists( $cookieName )) { return Cookies::get( $cookieName ); } // Evaluate feature toggle... } } 

Como posso testar esta resposta inicial?

Isso mesmo. De jeito nenhum.

Para testá-lo, precisamos entender o comportamento da classe Cookies e garantir que possamos reproduzir todo o ambiente associado a ela, resultando em certas respostas.

Não faça isso.

A situação pode ser corrigida se você implementar uma instância de Cookies como uma dependência. O teste terá a seguinte aparência:

 // Test class... private Cookies $cookieMock; private FeatureToggle $service; // Preparing our service and dependencies public function setUp(): void { $this->cookieMock = $this->prophesize( Cookies::class ); $this->service = new FeatureToggle( $this->cookieMock->reveal() ); } public function testIsActiveIsOverriddenByCookies(): void { // Given $feature = $this->givenFeatureXExists(); // When $this->whenCookieOverridesFeatureWithTrue( $feature ); // Then $this->assertTrue( $this->service->isActive($feature) ); // additionally we can assert // no other methods were called } private function givenFeatureXExists(): Id { // ... return $feature; } private function whenCookieOverridesFeatureWithTrue( Id $feature ): void { $cookieName = $feature->getCookieName(); $this->cookieMock->exists($cookieName) ->shouldBeCalledOnce() ->willReturn(true); $this->cookieMock->get($cookieName) ->shouldBeCalledOnce() ->willReturn(true); } 

O mesmo vale para singletones. Portanto, se você deseja tornar um objeto único, configure corretamente seu injetor de dependência, em vez de usar o padrão (anti) singleton. Caso contrário, você escreverá métodos que são úteis apenas para casos como reset() ou setInstance() . Na minha opinião, isso é loucura.

É completamente normal alterar a arquitetura para facilitar o teste! E criar métodos para facilitar o teste não é normal.

5. Nunca teste métodos protegidos / privados


O principal motivo : eles afetam a maneira como testamos as funções determinando a assinatura do comportamento: sob essa condição, quando digito A, espero obter B. Métodos particulares / protegidos não fazem parte das assinaturas da função .

Eu nem quero mostrar uma maneira de "testar" métodos privados, mas darei uma dica: você só pode fazer isso usando a API de reflexão .

Sempre se castigue de alguma forma quando você pensa em usar a reflexão para testar métodos privados! Desenvolvedor ruim, ruim!

Por definição, métodos privados são chamados apenas internamente. Ou seja, eles não estão disponíveis ao público. Isso significa que apenas métodos públicos da mesma classe podem chamar esses métodos.

Se você testou todos os seus métodos públicos, também testou todos os métodos privados / protegidos . Se não for esse o caso, remova livremente métodos privados / protegidos; ninguém os usa de qualquer maneira.

Dicas avançadas


Espero que você não esteja entediado ainda. Ainda assim, eu tive que falar sobre o básico. Agora vou compartilhar minha opinião sobre como escrever testes e decisões limpas que afetam meu processo de desenvolvimento.

A coisa mais importante que não esqueço ao escrever testes:

  • Estudo.
  • Feedback rápido.
  • Documentação
  • Refatoração
  • Design durante o teste.

1. Testes no começo, não no fim


Valores : estudo, feedback rápido, documentação, refatoração, design durante o teste.

Esta é a base de tudo. O aspecto mais importante, que inclui todos os valores listados. Quando você escreve testes com antecedência, isso ajuda a entender primeiro como o esquema "dado, quando, então" deve ser estruturado. Ao fazer isso, você primeiro documenta e, mais importante, lembra e define seus requisitos como os aspectos mais importantes.

É estranho ouvir sobre a gravação de testes antes da implementação? E imagine como é estranho implementar algo e, ao testar para descobrir, todas as suas expressões "dadas quando, então" não fazem sentido.

Além disso, essa abordagem verificará suas expectativas a cada dois segundos. Você recebe feedback o mais rápido possível. Não importa o tamanho ou a aparência do recurso.

Testes verdes são uma área ideal para refatoração. A idéia principal: sem testes - sem refatoração. A refatoração sem testes é simplesmente perigosa.

Finalmente, definindo a estrutura “dada quando, então”, ficará óbvio para você quais interfaces seus métodos devem ter e como devem se comportar. Manter o teste limpo também forçará você a tomar constantemente diferentes decisões de arquitetura. Isso forçará você a criar fábricas, interfaces, interromper a herança etc. E sim, os testes se tornarão mais fáceis!

Se seus testes são documentos ativos que explicam como o aplicativo funciona, é imperativo que eles deixem claro.

2. Melhor sem testes do que com testes ruins


Valores : estudo, documentação, refatoração.

Muitos desenvolvedores pensam nos testes da seguinte maneira: escreverei um recurso, dirigirei a estrutura de teste até que os testes abranjam um certo número de novas linhas e os coloque em operação.

Parece-me que você precisa prestar mais atenção à situação em que um novo desenvolvedor começa a trabalhar com esse recurso. O que os testes dirão a essa pessoa?

Os testes geralmente são confusos se os nomes não forem detalhados o suficiente. O que é mais claro: testCanFly ou testCanFlyReturnsFalseWhenPersonHasNoWings ?

Se seus testes são apenas um código confuso que faz com que a estrutura cubra mais linhas, com exemplos que não fazem sentido, é hora de parar e pensar em escrever esses testes.

Mesmo bobagens como atribuir $a e $b variáveis ​​ou atribuir nomes que não estão relacionados a um uso específico.

Lembre - se : seus testes são documentos ativos, tentando explicar como seu aplicativo deve se comportar. assertFalse($a->canFly()) não documenta muito. E assertFalse($personWithNoWings->canFly()) já é bastante.

3. Execute testes de forma intrusiva


Valores : estudo, feedback rápido, refatoração.

Antes de começar a trabalhar nos recursos, execute os testes. Se eles falharem antes que você comece a trabalhar, você saberá disso antes de escrever o código e não precisará gastar minutos preciosos na depuração de testes quebrados com os quais nem se importava.

Depois de salvar o arquivo, execute os testes. Quanto mais cedo você descobrir que algo está quebrado, mais rápido você o corrige e segue em frente. Se a interrupção do fluxo de trabalho para resolver um problema parecer improdutiva para você, imagine que mais tarde você precisará voltar várias etapas se não souber sobre o problema.

Após conversar por cinco minutos com os colegas ou verificar as notificações do Github, execute os testes. Se eles coraram, então você sabe de onde parou. Se os testes estiverem verdes, você poderá continuar trabalhando.
Após qualquer refatoração, mesmo nomes de variáveis, execute os testes.

Sério, execute os malditos testes. Sempre que você pressionar o botão Salvar.
O PHPUnit Watcher pode fazer isso por você e até enviar notificações!

4. Grandes testes - grande responsabilidade


Valores : estudo, refatoração, design durante o teste.

Idealmente, cada classe deve ter um teste. Este teste deve abranger todos os métodos públicos desta classe, bem como todas as expressões condicionais ou operadores de transição ...

Você pode pegar algo assim:

  • Uma classe = um caso de teste.
  • Um método = um ou mais testes.
  • Uma ramificação alternativa (se / switch / try-catch / exception) = um teste.

Portanto, para esse código simples, você precisará de quatro testes:

 // class Person public function eatSlice(Pizza $pizza): void { // test exception if ([] === $pizza->slices()) { throw new LogicException('...'); } // test exception if (true === $this->isFull()) { throw new LogicException('...'); } // test default path (slices = 1) $slices = 1; // test alternative path (slices = 2) if (true === $this->isVeryHungry()) { $slices = 2; } $pizza->removeSlices($slices); } 

Quanto mais métodos públicos você tiver, mais testes serão necessários.

Ninguém gosta de ler documentação longa. Como seus testes também são documentos, o tamanho pequeno e a significância apenas aumentarão sua qualidade e utilidade.

Também é um sinal importante de que sua classe está acumulando responsabilidades e é hora de refatorá-la transferindo várias funções para outras classes ou redesenhando o sistema.

5. Apoie um conjunto de testes para resolver problemas de regressão


Valores : estudo, documentação, feedback rápido.

Considere a função:

 function findById(string $id): object { return fromDb((int) $id); } 

Você acha que alguém está transmitindo "10", mas na verdade "10 bananas" está sendo transmitida. Ou seja, dois valores vêm, mas um é supérfluo. Você tem um bug.

O que você fará primeiro? Escreva um teste que marque esse comportamento errôneo !!!

 public function testFindByIdAcceptsOnlyNumericIds(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Only numeric IDs are allowed.' ); findById("10 bananas"); } 

Obviamente, os testes não transmitem nada. Mas agora você sabe o que precisa ser feito para que eles transmitam. Corrija o erro, torne os testes verdes, implante o aplicativo e seja feliz.

Mantenha este teste com você. Sempre que possível, em um conjunto de testes projetados para resolver problemas com regressão.

Isso é tudo! Feedback rápido, correções de erros, documentação, código resistente à regressão e felicidade.

Palavra final


Muito do exposto é apenas minha opinião pessoal, desenvolvida durante minha carreira. Isso não significa que o conselho seja verdadeiro ou falso, é apenas uma opinião.

Source: https://habr.com/ru/post/pt485124/


All Articles