PHPUnit. "Como faço para testar meu maldito controlador" ou testar se há dúvidas

Oi Habr.

imagem

Sim, este é outro post sobre o tópico de teste. Parece que aqui já é possível discutir? Todos que precisam - escrevem testes, que não precisam - eles não escrevem, todos estão felizes! O fato é que a maioria dos posts sobre testes de unidade tem ... como ofender ninguém ... exemplos idiotas! Sério, não! Hoje vou tentar consertar. Eu peço gato.

Assim, a pesquisa rápida no tópico de testes encontra apenas muitos artigos, que em sua maioria são divididos em duas categorias:

1) A felicidade de um redator. Primeiro, vemos uma longa introdução, depois a história dos testes de unidade na Rússia antiga, depois dez hacks de vida com testes e, no final, um exemplo. Com testes de código como este:

<?php class Calculator { public function plus($a, $b) { return $a + $b; } } 

E eu não estou brincando agora. Eu realmente vi artigos com uma "calculadora" como um guia de estudo. Sim, sim, entendo que, para começar, é necessário simplificar tudo, abstrações, e para trás ... Mas é aqui que tudo acaba! E então termine a coruja, como eles dizem

2) Exemplos excessivamente sofisticados. Vamos escrever um teste e colocá-lo no CI do Gitlab. Em seguida, corrigiremos automaticamente se o teste for aprovado e aplicaremos a infecção pelo PHP aos testes, mas conectaremos tudo ao Hudson. E assim por diante nesse estilo. Parece ser útil, mas parece que não é exatamente o que você está procurando. Mas você só quer aumentar um pouco a estabilidade do seu projeto. E todas essas continuidades - bem, então, não de uma só vez.

Como resultado, as pessoas duvidam: "Mas eu preciso disso". Por sua vez, quero tentar explicar mais claramente os testes. E faça uma reserva imediatamente - sou desenvolvedor, não sou testador. Tenho certeza de que eu mesmo não sei muito, e minha primeira palavra na minha vida não foi a palavra "mok". Eu nunca trabalhei em TDD! Mas tenho certeza de que mesmo meu nível atual de habilidades me permitiu cobrir vários projetos com testes, e esses mesmos testes já detectaram uma dúzia de bugs. E se isso me ajudasse, poderia ajudar outra pessoa. Alguns erros detectados seriam difíceis de detectar manualmente.

Para começar, um pequeno programa educacional no formato de perguntas e respostas:

P: Preciso usar algum tipo de estrutura? E se eu tiver o Yii? E se Kohana? E se% one_more_framework_name%?
R: Não, o PHPUnit é uma estrutura de teste independente, você pode até parafusá-la no código legado em uma estrutura criada por você.

P: E agora estou rapidamente navegando pelo site com minhas mãos, e isso é normal. Por que eu preciso disso?
R: A execução de várias dezenas de testes dura vários segundos. O teste automático é sempre mais rápido que o manual e, com testes de alta qualidade, também é mais confiável, pois abrange todos os cenários.

P: Eu tenho um código legado com funções de 2000 linhas. Posso testar isso?
A: Sim e não. Em teoria, sim, qualquer código pode ser coberto com um teste. Na prática, o código deve ser escrito com uma base para testes futuros. Uma função de linha 2000 terá muitas dependências, ramificações, casos de borda. Pode acabar cobrindo tudo no final, mas provavelmente levará um tempo inaceitavelmente longo. Quanto melhor o código, mais fácil é testá-lo. Quanto melhor a responsabilidade única for respeitada, mais fáceis serão os testes. Para testar projetos antigos com mais freqüência, primeiro é necessário refatorá-los com frieza.

imagem

P: Eu tenho métodos muito simples (funções), o que há para testar? Tudo é confiável lá, não há espaço para erro!
R: Deve-se entender que você não testa a implementação correta da função (se você não possui TDD), simplesmente "corrige" seu estado atual de trabalho. No futuro, quando precisar alterá-lo, você poderá determinar rapidamente se quebrou o comportamento usando o teste. Exemplo: existe uma função que valida o email. Ela faz isso regularmente.

 function isValid($email) { $regex = "very_complex_regex_here"; if (is_array($email)) { $result = true; foreach ($email as $item) { if (preg_match($regex, $item) === 0) { $result = false; } } } else { $result = preg_match($regex, $emai) ==! 0; } return $result; } 

Todo o seu código espera que, se você passar um email válido para essa função, ele retornará verdadeiro. Uma matriz de e-mails válidos também é verdadeira. Uma matriz com pelo menos um endereço de email inválido é falsa. Bem e assim por diante, o código é claro. Mas chegou o dia e você decidiu substituir a monstruosa temporada regular por uma API externa. Mas como garantir que a função reescrita não mudou o princípio de operação? De repente, ele não lida bem com a matriz? Ou retornará não booleano? E os testes podem manter isso sob controle. Um teste bem escrito indicará imediatamente um comportamento de função diferente do esperado.

P: Quando começarei a perceber algum sentido nos testes?
R: Em primeiro lugar, assim que você cobrir uma parte significativa do código. Quanto mais próxima a cobertura estiver de 100%, mais confiável será o teste. Em segundo lugar, assim que você precisar fazer alterações globais ou alterações na parte complexa do código. Os testes podem detectar problemas facilmente perdidos manualmente (casos limítrofes). Em terceiro lugar, ao escrever os próprios testes! Muitas vezes, há uma situação ao escrever um teste, que revela falhas de código que não são visíveis à primeira vista.

P: Bem, eu tenho um site no laravel. O site não é uma função, o site é uma montanha de código de merda. Como testar aqui?
A: Isto é o que será discutido mais adiante. Em resumo: testamos separadamente os métodos dos controladores, separadamente o middleware, separadamente os serviços, etc.

Uma das idéias do teste de unidade é isolar a seção de código testada. Quanto menos código você testar com um teste, melhor. Vejamos um exemplo o mais próximo possível da vida real:

 <?php class Controller { public function __construct($userService, $emailService) { $this->userService = $userService; $this->emailService = $emailService; } public function login($request) { if (empty($request->login) || empty($request->password)) { return "Auth error"; } $password = $this->userService->getPasswordFor($request->login); if (empty($password)) { return "Auth error - no password"; } if ($password !== $request->password) { return "Incorrect password"; } $this->emailService->sendEmail($request->login); return "Success"; } } // .... /* somewhere in project core */ $controller = new Controller($userService, $emailService); $controller->login($request); 

Esse é um método muito típico de efetuar login no sistema em pequenos projetos. Tudo o que esperamos são as mensagens de erro corretas e o email enviado no caso de um login bem-sucedido. Como testar esse método? Primeiro, você precisa identificar dependências externas. No nosso caso, existem dois deles - $ userService e $ emailService. Eles são passados ​​pelo construtor de classe, o que facilita muito nossa tarefa. Mas, como mencionado anteriormente, quanto menos código testamos em uma passagem, melhor.

Emulação, substituição de objetos é chamada mokanem (do inglês. Mock objeto, literalmente: "paródia de objeto"). Ninguém se preocupa em escrever esses objetos manualmente, mas tudo já foi inventado diante de nós, de modo que uma biblioteca maravilhosa como Mockery vem em socorro. Vamos criar mokas para serviços.

 $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); 

Agora crie o objeto $ request. Para começar, testaremos a lógica de verificar os campos de login e senha. Queremos ter certeza de que, se não houver, nosso método manipulará corretamente esse caso e retornará a mensagem (!) Desejada.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); } 

Nada complicado, certo? Criamos stubs para os parâmetros de classe necessários, criamos uma instância da classe desejada e "extraímos" o método desejado, passando uma solicitação deliberadamente errada. Tenho uma resposta. Mas como verificar agora? Esta é a parte mais importante do teste - a chamada afirmação. O PHPUnit possui dezenas de asserções prontas. Basta usar um deles.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); // vv assertion here! vv $this->assertEquals("Auth error", $result); } 

Este teste garante o seguinte - se o argumento de login chegar ao objeto de método que não possui o campo de login ou senha, o método retornará a string "Erro de autenticação". Isso, em geral, é tudo. Tão simples - mas tão útil, porque agora podemos editar o método de login sem medo de quebrar algo. Nosso front-end pode ter certeza de que, se algo acontecer - ele receberá esse erro. E se alguém quebrar esse comportamento (por exemplo, decidir alterar o texto do erro), o teste imediatamente sinalizará isso! Adicionamos as verificações restantes para cobrir o maior número possível de cenários.

 function testEmptyPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '' $userService->shouldReceive('getPasswordFor')->andReturn(''); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Auth error - no password", $result); } function testUncorrectPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '4321' $userService->shouldReceive('getPasswordFor')->andReturn('4321'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Incorrect password", $result); } function testSuccessfullLogin() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '1234' $userService->shouldReceive('getPasswordFor')->andReturn('1234'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Success", $result); } 

Observe os métodos shouldReceive e andReturn? Eles nos permitem criar métodos em stubs que retornam apenas o que precisamos. Precisa testar o erro de senha errado? Nós escrevemos um stub $ userService que sempre retorna a senha errada. E é isso.

E quanto às dependências, você pergunta. Nós então os "afogamos", e se eles quebrarem? Mas é exatamente para isso que serve a cobertura máxima de código nos testes. Não verificaremos a operação desses serviços no contexto do logon - testaremos o logon na esperança da operação correta dos serviços. E então escrevemos os mesmos testes isolados para esses serviços. E depois testa suas dependências. E assim por diante Como resultado, cada teste individual garante apenas a operação correta de um pequeno pedaço de código, desde que todas as suas dependências funcionem corretamente. E como todas as dependências também são cobertas por testes, sua operação correta também é garantida. Como resultado, qualquer alteração no sistema que interrompa a lógica do trabalho, mesmo o menor pedaço de código, aparecerá imediatamente em um teste específico. Como especificamente executar o teste - não vou contar, a documentação no PHPUnit é muito boa. E no Laravel, por exemplo, basta executar vendor / bin / phpunit a partir da raiz do projeto para ver uma mensagem como esta

imagem - Todos os testes foram bem sucedidos. Ou algo assim

imagem Uma das sete afirmações falhou.

"Isso, é claro, é legal, mas em que não consigo colocar minhas mãos?", Você pergunta. E vamos imaginar o seguinte código para este

 <?php function getInfo($infoApi, $userName) { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { return null; } return $response->result; } // ... somewhere in system $api = new ExternalApi(); $info = getInfo($api, 'John'); if ($info === null) { die('Api is down'); } echo $info; 

Vemos um modelo simplificado de trabalho com uma API externa. A função usa alguma classe para trabalhar com a API e, em caso de erro, retorna nulo. Se, ao usar esta função, ficarmos nulos, devemos "aumentar o pânico" (enviar uma mensagem para a folga, enviar um e-mail ao desenvolvedor ou lançar um erro no kibana. Sim, várias opções). Tudo parece ser simples, certo? Mas imagine que depois de algum tempo outro desenvolvedor decidiu "consertar" essa função. Ele decidiu que retornar nulo é o século passado, e ele deveria lançar uma exceção.

 function getInfo($infoApi, $userName): string { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { throw new ApiException($response); } return $response->result; } 

E ele até reescreveu todas as seções do código em que essa função foi chamada! Todos menos um. Ele sentia falta dele. Distraído, cansado, errado - mas você nunca sabe. O fato é que um pedaço de código ainda está aguardando o antigo comportamento da função. E o PHP não é Java para nós - não receberemos um erro de compilação com o argumento de que a função jogável não está envolvida no try-catch. Como resultado, em um dos 100 cenários de uso do site, no caso de uma queda da API, não receberemos uma mensagem do sistema. Além disso, com o teste manual, provavelmente não capturaremos esta versão do evento. A API é externa, não depende de nós, funciona bem - e, provavelmente, não colocaremos nossas mãos em caso de falha da API e tratamento incorreto de exceções. Porém, se tivermos testes, eles entenderão muito bem esse caso, porque a classe ExternalApi é "abafada" em vários testes e emula o comportamento normal e a falha. E o próximo teste vai cair

 function testApiFail() { $api = Mockery::mock('api'); $api->shouldReceive('getInfo')->andReturn((object) [ 'status' => 'API Error' ]); $result = getInfo($api, 'name'); $this->assertNull($result); } 

Esta informação é realmente suficiente. Se você não tiver macarrão herdado, depois de 20 a 30 minutos, poderá escrever seu primeiro teste. E algumas semanas depois - para aprender algo novo, legal, retorne aos comentários neste post e escreva qual autor o govnokoder não conhece sobre% framework_name% e escreve testes ruins, mas você precisa fazer% this_way%. E ficarei muito feliz nesse caso. Isso significa que meu objetivo foi alcançado: alguém descobriu o teste por si mesmo e aumentou um pouco o nível geral de profissionalismo em nosso campo!

Críticas fundamentadas são bem-vindas.

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


All Articles