Capa de post contendo um computador, xícaras de café e uma caixa escrita "no bad days"

TDD "do mundo real" com PestPHP


tdd pest

No post de introdução ao PestPHP vimos um guia rápido sobre o que é o PestPHP, pra que serve e como utilizá-lo. Dando sequência a série sobre o Pest vamos abordar um caso de uso “do mundo real” utilizando técnicas do TDD e o PestPHP.

Contexto

Para pensar em um caso de uso “do mundo real”, vamos assumir o seguinte cenário:

Temos uma sistema web onde podemos convidar usuários para participar deste sistema, estes convites são armazenados numa tabela no banco de dados chamada invites.

Cada usuário convidado pode aceitar ou não o convite e é considerado “aceite” quando um usuário convidado é cadastrado através do link do convite, neste caso uma propriedade chamada accepted no registro do convite terá o valor atualizado para 1.

A tabela de invites possui a seguinte estrutura:

| invites  |
|----------|
| id       | // identificador único do convite
| name     | // nome do usuário convidado
| email    | // email do convidado
| link     | // link para ativação
| user_id  | // id do usuário que fez o convite
| accepted | // boolean indicando se o usuário aceitou o convite ou não

Dado o contexto de convites, o desafio será criar um relatório de todos os usuários convidados para participar do sistema, exibir o número total de convites, quantos dos convidados já se cadastraram na plataforma e qual é esta porcentagem.

Este relatório deve ser um arquivo XLSX, a primeira linha do relatório deve conter um resumo com o número de convites, aceites e porcentagem, cada um em sua respectiva coluna. As demais linhas serão todos os usuários convidados ordenados pela coluna accepted onde os usuários que aceitaram o convite devem vir primeiro dos que ainda não se cadastraram.

Caso queira acompanhar o código utilizado neste post, visite o repositório da série , o branch utilizado foi o tdd-pest e todos os passos do post estão separados por commit, ou seja, cada ação um commit.

Mão na massa

Para gerar o XLSX vamos utilizar a lib spreadsheet , a dependência dela já foi adicionada ao projeto e para seguir o tutorial basta seguir os passos do readme do repositório da série, caso não esteja utilizando o repositório da série, recomendo dar uma olhada na documentação da lib spreadsheet para realizar sua instalação.

Começando com TDD

Neste momento vamos começar a escrever os testes unitários para o relatório.

Uma coisa que gosto de fazer antes de começar a escrever código (incluindo os testes) é, se possível, quebrar o problema em problemas menores e escolher por qual parte começar, vamos separar o problema do relatório em partes e escrever os testes e implementação por partes.

A primeira regra que vamos garantir com o teste será: “a primeira linha do relatório deve conter um resumo com o número de convites, a quantidade de convites aceitos e porcentagem, cada um em sua respectiva coluna”

Desta forma podemos chegar nas seguintes regras:

  • A coluna A1 (primeira coluna da primeira linha) deverá conter o número total de convites enviados;
  • A coluna A2 (segunda coluna da primeira linha) deverá conter o número total de aceites;
  • A coluna A3 (terceira coluna da primeira linha) deverá conter o percentual de convites aceitos.

Como vamos escrever primeiro os testes unitários, precisamos criar um arquivo de testes dentro da pasta tests/Unit e vamos chamá-lo de InvitesReportTest.php.

Aqui estamos assumindo que existe uma classe chamada InvitesReport que recebe uma collection com os dados dos convite, estamos assumindo também que há um método chamado generate() que retornará um objeto do tipo Worksheet.

Futuramente este comportamento pode ser transformado em uma interface para relatórios utilizando a lib spreadsheet mas por hora vamos seguir apenas com a classe.

Como o método generate() nos retornará um Worksheet, podemos acessar o valor de uma coluna em uma determinada linha , com isso conseguimos fazer uma asserção com o valor.

Vamos escrever nosso primeiro teste, vamos atender o primeiro tópico: "A coluna A1 deverá conter o número total de convites enviados"

use Illuminate\Database\Eloquent\Collection;

$invites = new Collection();
$inviteService = new InvitesReport($invites);

test('first column should display the number 10 as total invites')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(1, 1)
        ->getValue())
    ->toBe(10);

Basicamente nosso teste está esperando que ao executar o método generate() da classe InvitesReport, seja retornada uma instância da classe Worksheet e que na coluna A1 o valor seja 10.

Agora basta rodar o teste e verificar se deu tudo certo:

vendor/bin/pest tests/Unit/InvitesReportTest.php

Ouput do teste unitário indicando que a classe `InvitesReport` não foi encontrada

Como era de se esperar tivemos um erro.

A classe InvitesReport não existe e seguindo o ciclo Red->Green->Refactor, está na hora de fazer o teste passar, vamos criar a classe InvitesReport.

// app/Services/InvitesReport.php
namespace App\Services;

use Illuminate\Database\Eloquent\Collection;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;

class InvitesReport
{
    private $invites;

    public function __construct(Collection $invites)
    {
        $this->invites = $invites;
    }

    public function generate() : Worksheet
    {

    }
}

Após criada a classe, devemos adicionar seu use na classe do nosso teste:

// arquivo tests/Unit/InvitesReportTest.php
use Illuminate\Database\Eloquent\Collection;
use App\Services\InvitesReport;

$invites = new Collection();
$inviteService = new InvitesReport($invites);
...

Agora que criamos a classe InvitesReport devemos nos livrar do erro indicado pelo teste unitário, o correto então, seguindo o ciclo Red->Green->Refactor, seria que o teste passasse, vamos rodar novamente o Pest e verificar o output:

vendor/bin/pest tests/Unit/InvitesReportTest.php

output do teste unitário indicando que o valor retornado deveria ser uma instância da classe `Worksheet` e nada foi retornado

Desta vez o erro gerado é um pouco diferente: O retorno do método generate() deveria ser uma instância de Worksheet mas na verdade nada foi retornado.

Neste momento, para seguir com baby steps conforme é indicado pelas técnicas de TDD, seria interessante adicionar o retorno mais simples possível, ou seja, return new Worksheet() e verificar os próximos erros ou sucesso do teste. Para o artigo não ficar gigantesco, vou pular alguns passos menores mas, principalmente no começo, recomendo fortemente seguir os baby steps.

Para fazer nosso teste passar, vamos criar a implementação mais simples possível e rodar novamente os testes para obtermos um feedback:

// app/Services/InvitesReport.php
...
public function generate() : Worksheet
{
    $sheet = new Worksheet();
    $sheet->setCellValueByColumnAndRow(1, 1, 10);

    return $sheet;
}

E ao rodar novamente o Pest, veremos que agora tudo passou!

output do teste unitário com o primeiro teste passando

Agora que atingimos o estágio “green” do ciclo Red->Green->Refactor, claramente precisamos refatorar nosso código.

Os dados retornados estão sendo inseridos “na mão” e não refletem os dados reais da collection passada para a classe de relatório.

Além de refatorar a classe de relatório, precisamos também adicionar itens na collection usada no teste já que a collection atual está vazia.

No nosso caso vamos utilizar uma collection criada e populada no arquivo de testes, dessa forma não precisamos de uma conexão com o banco de dados e evitamos fazer I/O nos testes, com isso estamos garantindo que dada uma entrada X (o mais próximo possível do cenário real), uma saída Y deve ser sempre a mesma.

// tests/Unit/InvitesReportTest.php

$invites = new Collection();
$invites->add([
    'name' => 'José',
    'email' => '[email protected]',
    'link' => 'https://[email protected]&u=1',
    'user_id' => 1,
    'accepted' => 0,
]);
$invites->add([
    'name' => 'José 2',
    'email' => '[email protected]',
    'link' => 'https://[email protected]&u=1',
    'user_id' => 1,
    'accepted' => 0,
]);
// adiciona convites na collection até chegar em 10

Novamente, para o post não ficar gigantesco foram omitidas algumas inserções na collection mas caso queira conferir os dados inseridos neste commit está o arquivo completo com todos os dados que foram utilizados.

Ao rodar os testes novamente, tudo deve estar passando como antes, isso porque os dados inseridos no worksheet foram inseridos "na mão". Vamos refatorar o método generate() da classe InvitesReport para considerar os dados providos pela collection.

// app/Services/InvitesReport.php
public function generate() : Worksheet
{
    $total = $this->invites->count();
    $sheet = new Worksheet();
    $sheet->setCellValueByColumnAndRow(1, 1, $total);

    return $sheet;
}

Desta vez ao invés de inserir os dados "na mão", criamos uma varíavel chamada $total e atribuímos a ela o total de convites existentes na collection.

Com tudo refatorado vamos rodar novamente o Pest e verificar o output:

output do teste unitário após a refatoração da classe InvitesReport e testes passando

Até aqui conseguimos garantir que dada uma entrada de uma collection com 10 convites o nosso relatório em XLSX contém, na coluna correta, o valor esperado de saída.

Vamos adicionar o teste para o próximo tópico: "A coluna A2 deverá conter o número total de convites aceitos"

test('second column should display the number 3 as total accepted invites')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(1, 2)
        ->getValue())
    ->toBe(3);

Agora que adicionamos um novo caso de teste, bora executar o Pest novamente e verificar o feedback que o teste nos traz:

output do segundo teste unitário com o primeiro teste passando e o segundo não

O teste que acabamos de criar, como já era esperado, não passou, mas o teste de antes que já estava escrito e implementado continua passando. Aqui conseguimos observar um dos vários benefícios de se utilizar técnicas de TDD: estamos adicionando funcionalidades e garantindo que o restante da aplicação continua funcionando como esperado.

Outro benefício de utilizar técnicas de TDD é aprender com o feedback que o teste nos traz, o teste que está falhando indica exatamente o que precisamos fazer. O erro apresentado foi: "a segunda coluna deveria conter o número 3 representando o total de convites aceitos, mas foi encontrado o valor null". Este comportamento já era esperado uma vez que não há uma implementação para esta regra no nosso service. Vamos implementá-la:

public function generate() : Worksheet
{
    $total = $this->invites->count();
    $accepted = $this->invites->where('accepted', 1)->count();
    $sheet = new Worksheet();
    $sheet->setCellValueByColumnAndRow(1, 1, $total);
    $sheet->setCellValueByColumnAndRow(2, 1, $accepted);

    return $sheet;
}

Foi criada a variável $accepted, o valor atribuído a ela foi um filtro feito na collection para retornar apenas convites aceitos e à partir dos convites aceitos foi feita uma contagem dos itens. Além disso, foi criada a inserção no worksheet na coluna A2.

Vamos executar o Pest mais uma vez e verificar se os testes passaram:

output do segundo teste unitário com todos os testes passando

Já conseguimos cobrir com testes duas informações importantes para o relatorio: a quantia total de convites e o número total de convites aceitos.

Agora vamos para o último tópico deste primeiro conjunto de regras: "A coluna A3 deverá conter o percentual de convites aceitos".

Começando pelo teste, teremos o seguinte:

test('third column should display the string 30% as total accepted invites percentage')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(1, 3)
        ->getValue())
    ->toBe('30%');

E ao executar o Pest, recebemos o seguinte feedback:

output do terceiro teste unitário com o terceiro teste falhando e os demais passando

Mais uma vez o output do teste nos diz exatamente qual o problema de acordo com o cenário esperado: "a terceira coluna deveria conter a string 30% representando o percentual de convites aceitos, mas foi encontrado o valor null".

Partindo para a implementação, vamos criar o cálculo de porcentagem dos convites aceitos e inserí-lo na coluna esperada:

...
$accepted = $this->invites->where('accepted', 1)->count();
$acceptedPercentage = ($accepted * 100) / $total;
$sheet->setCellValueByColumnAndRow(3, 1, "{$acceptedPercentage}%");
...

Criamos a variável $acceptedPercentage e atribuímos a ela o resultado do cálculo de porcentagem considerando o total de convites enviados e o total de convites aceitos. Vamos executar o Pest e ver se isso foi suficiente para nossos testes passarem:

output do terceiro teste unitário com todos os testes passando

Agora sim! Temos todos os tópicos do primeiro conjunto de regras funcionando e coberto por testes.

Criando novos casos de teste

Até aqui fomos criando regra por regra, seus testes e sua implementação seguindo passos menores e o ciclo Red->Green->Refactor, aprendendo com cada feedback dado pelos testes. Para o próximo conjunto de regras vou dar uma acelerada para criar todas as demais regras e as implementações necessárias.

Agora vamos passar para ao segundo conjunto de regras do caso de uso proposto: ”as demais linhas serão todos os usuários convidados ordenados pela coluna accepted onde os usuários que aceitaram o convite devem vir primeiro dos que ainda não se cadastraram”.

Para facilitar, as colunas serão escritas nesta ordem:

| nome | email | link | user_id | accepted |
|------|-------|------|---------|----------|

Atualmente os itens na collection não estão ordenados pelos convites aceitos, neste arquivo estão todas as dez adições de convites na collection (no exemplo mostrei só 2 para encurtar) para caso queira verificar a ordem dos itens. De acordo com os itens inseridos na collection, sabemos que existem 3 convites aceitos, logo, eles deverão aparecer nas 3 próximas linhas após a primeira regra.

Para garantir que este comportamento está acontecendo, basicamente precisamos verificar as próximas três linhas do worksheet (linhas 2 a 4) e assegurar que são convites aceitos, também precisamos verificar a quarta linha seguinte (linha 5) e garantir que nesta linha existe um convite não aceito (já que só temos 3 convites aceitos).

Regras definidas, hora de escrever o teste:

// tests/Unit/InvitesReportTest.php
…
test('second line should be an accepted invite')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(5, 2)
        ->getValue())
    ->toBe(1);

test('third line should be an accepted invite')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(5, 3)
        ->getValue())
    ->toBe(1);

test('forth line should be an accepted invite')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(5, 4)
        ->getValue())
    ->toBe(1);

test('fifth line should be a not accepted invite')
    ->expect($inviteService->generate()
        ->getCellByColumnAndRow(5, 5)
        ->getValue())
    ->toBe(0);

E ao rodar o Pest teremos uma série de erros:

output do teste unitário indicando que as linhas não contém os valores esperados

Todos as novas asserções falharam… Isso se deve pelo fato de que ainda não escrevemos a lógica no service que irá preencher as linhas e colunas do relatório de acordo com os dados da collection, vamos adicionar uma implementação para estas regras na classe InvitesReport:

// app/Services/InvitesReport.php
…
$sheet->setCellValueByColumnAndRow(3, 1, "{$acceptedPercentage}%");

$row = 2;
$this->invites->each(function ($invite) use (&$sheet, &$row) {
    $sheet->setCellValueByColumnAndRow(1, $row, $invite['name']);
    $sheet->setCellValueByColumnAndRow(2, $row, $invite['email']);
    $sheet->setCellValueByColumnAndRow(3, $row, $invite['link']);
    $sheet->setCellValueByColumnAndRow(4, $row, $invite['user_id']);
    $sheet->setCellValueByColumnAndRow(5, $row, $invite['accepted']);
    ++$row;
});

return $sheet;

No trecho acima, na variável $row, indicamos qual a linha queremos começar a escrever, estamos também iterando a collection $this->invites e para cada registro adicionamos a propriedade desejada na linha e coluna desejada. Ao final de cada iteração a variável $row recebe um incremento para “pular” para a próxima linha do Worksheet.

Essa seria a implementação mais simples para popular o Worksheet que consigo pensar. Mas ao rodar o Pest novamente, ainda teremos erros sendo exibidos:

output dos testes unitários indicando que onde deveria conter o valor 1 foi encontrado o valor 0

A diferença é que os erros agora dizem que no conjunto de linhas e colunas que deveriam conter o valor 1 foi inserido o valor 0.

Por coincidência o último teste acabou passando já que estamos testando se é um registro de convite não aceito e todos os 7 primeiros convites na collection não foram aceitos.

Apesar de já estar inserindo os valores da collection nas linhas do Worksheet, a collection ainda não está ordenada e por isso os testes não estão passando. Vamos ordená-la e rodar os testes novamente.

// app/Services/InvitesReport.php
…
$this->invites
    ->sortByDesc('accepted')
    ->each(function ($invite) use (&$sheet, &$row) {
...

A própria collection já possui um método que nos permite fazer ordenações e no código acima utilizamos o sortByDesc passando por qual propriedade desejamos ordená-la, neste caso foi a propriedade accepted.

Agora vamos rodar novamente o Pest e ver se nossa regra está realmente correta:

vendor/bin/pest tests/Unit/InvitesReportTest.php

output dos testes unitários todos passando

Sucesso, todas as regras contempladas e testes passando bonitinho!

Agora com a regra do service pronta e testada, para ter um XLSX “físico”, bastaria consumir o retorno do método generate() (o Worksheet populado) e utilizar um Writer da lib spreadsheet e gerar um arquivo excel.

Toques finais

Todas as vezes que foi necessário consumir o Worksheet gerado pelo método generate() foi feita uma nova chamada no service. Este comportamento não é o mais adequado e poderá consumir muitos recursos (dependendo do caso de uso) atoa.

Vamos alterar a forma como estamos utilizando o Worksheet nos testes:

// arquivo tests/Unit/InvitesReportTest.php
$inviteService = new InvitesReport($invites);
$worksheet = $inviteService->generate();

test('first column should display the number 10 as total invites')
    ->expect($worksheet->getCellByColumnAndRow(1, 1)
        ->getValue())
    ->toBe(10);
...

Ao invés de realizar uma chamada para $inviteService->generate() a cada teste, criamos uma variável chamada $worksheet (já que o método generate() retorna uma instância de worksheet) e alteramos os testes para utilizar esta variável. Vale lembrar que, ao alterar o comportamento no arquivo de testes para chamar apenas uma vez o método generate(), todos os testes devem (e estão) estar passando normalmente. Este fato é importante para mostrar que também podemos aplicar melhorias em nossos arquivos de testes e ainda assim confiar nas asserções, refatorar testes é tão importante quanto refatorar implementações!

Há também uma forma diferente de rodar os testes unitários, podemos utilizar um comando artisan chamado test:

php artisan test

E como neste caso estamos rodando apenas os testes da classe InvitesReportTest podemos utilizar a diretiva --filter:

php artisan test --filter InvitesReportTest

Conclusão

Foi utilizado o Worksheet para que futuramente seja possível implementar qualquer outra maneira de saída, seja forçando o download em uma requisição HTTP, gerando um excel via CLI/Artisan e por aí vai. A escolha da utilização da classe Worksheet também ajuda a criar a estrutura do relatório em excel sem precisar fazer operações de I/O.

Para este post busquei trazer um escopo próximo do que podemos encontrar “no mundo real”, acredito que muitos já devem ter passado pela experiência de ter que gerar um relatório e espero que daqui pra frente possam fazê-lo com TDD!

Apesar de ter um escopo próximo do “mundo real”, ainda há espaço para muitas melhorias no código. Podemos notar que o arquivo de teste ficou muito grande e poluído, isso porque tivemos que popular a collection Invites com 10 dados e tudo isso está no arquivo de testes. Este comportamento pode ser melhorado utilizando Data Providers que no Pest chamam-se Datasets , também poderíamos utilizar as Laravel Factories para criar uma collection já com o objeto correto, ou seja, instâncias da model Invites e algumas melhorias como injetar o Worksheet para a classe de service para evitar o uso do new Worksheet() dentro do método e por aí vai… A ideia foi trazer a maneira mais simplificada que pude pensar de como utilizar o TDD com Pest e Laravel.

Ainda falaremos mais sobre estes pontos nos próximos posts da série sobre o PestPHP, vamos refatorar nossos testes e seguir melhorando nossas regras e cobertura, então fique de olho!

-- Up the Laravel's \o/

← Post Anterior
Próximo Post →