Testes Automatizados com PHPUnit

Testes Automatizados com PHPUnit

Do teste unitário de uma entidade ao de integração com SQLite em memória, passando por data providers e mocks que isolam o serviço de suas dependências. Inclui a mudança do phpunit.xml que o PHPUnit 10 trouxe e o motivo de dublar interface, não classe.
PHP

• • 24 min de leitura

Testes automatizados são o mecanismo que permite modificar código com confiança. Sem testes, cada mudança é uma aposta — você só descobre que quebrou algo quando o cliente reclama. Com testes, a suite te avisa em segundos se algo regrediu.

PHPUnit é o framework de testes padrão do ecossistema PHP. Ele organiza testes em classes, oferece assertions expressivas, mocks para isolar dependências, e data providers para testar múltiplos cenários sem duplicar código. Neste artigo construímos testes unitários puros, testes de integração com banco real, e aprendemos a usar mocks para testar código que depende de serviços externos — sem precisar que eles existam.

Configuração e estrutura de projeto

<!-- phpunit.xml — configuração do PHPUnit -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         stopOnFailure="false">

    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <!-- Desde o PHPUnit 10, quem lista os arquivos analisados é <source>;
         <coverage> ficou só com as opções de relatório. A forma antiga ainda
         roda, mas a suite passa a avisar "Your XML configuration validates
         against a deprecated schema" a cada execução. O próprio PHPUnit
         converte o arquivo com --migrate-configuration. -->
    <source>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </source>

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>
projeto/
├── src/
│   ├── Domain/
│   │   ├── Produto.php
│   │   └── Carrinho.php
│   ├── Service/
│   │   └── CarrinhoService.php
│   └── Repository/
│       └── ProdutoRepositorioInterface.php
├── tests/
│   ├── Unit/
│   │   ├── Domain/
│   │   │   └── CarrinhoTest.php
│   │   └── Service/
│   │       └── CarrinhoServiceTest.php
│   └── Integration/
│       └── Repository/
│           └── ProdutoRepositorioPDOTest.php
├── phpunit.xml
└── composer.json

Primeiro teste — entidade de domínio

Testes unitários testam uma única unidade de código em isolamento, sem banco de dados, sem HTTP, sem arquivos. São os mais rápidos e os mais valiosos.

<?php
declare(strict_types=1);

namespace App\Domain;

// ── Entidade que vamos testar ──────────────────────────────────────────
class Produto
{
    public function __construct(
        public readonly int    $id,
        public readonly string $nome,
        public readonly float  $preco,
        public readonly int    $estoque,
    ) {}

    public function comDesconto(float $percentual): static
    {
        if ($percentual < 0 || $percentual > 100) {
            throw new \InvalidArgumentException(
                "Percentual deve ser entre 0 e 100, recebido: {$percentual}"
            );
        }
        return new static(
            id:      $this->id,
            nome:    $this->nome,
            preco:   round($this->preco * (1 - $percentual / 100), 2),
            estoque: $this->estoque,
        );
    }

    public function temEstoque(int $quantidade = 1): bool
    {
        return $this->estoque >= $quantidade;
    }
}
<?php
declare(strict_types=1);

namespace Tests\Unit\Domain;

use App\Domain\Produto;
use PHPUnit\Framework\TestCase;

class ProdutoTest extends TestCase
{
    // Convenção: método de teste começa com "test" ou usa atributo #[Test]
    // Nome do método descreve o comportamento esperado

    public function test_desconto_calcula_preco_corretamente(): void
    {
        $produto    = new Produto(id: 1, nome: 'Teclado', preco: 200.00, estoque: 10);
        $comDesconto = $produto->comDesconto(10); // 10%

        $this->assertSame(180.00, $comDesconto->preco);
        $this->assertSame(1, $comDesconto->id);    // imutável — mantém o ID
        $this->assertSame('Teclado', $comDesconto->nome);
    }

    public function test_desconto_zero_nao_altera_preco(): void
    {
        $produto = new Produto(id: 1, nome: 'Mouse', preco: 150.00, estoque: 5);
        $this->assertSame(150.00, $produto->comDesconto(0)->preco);
    }

    public function test_desconto_100_resulta_em_preco_zero(): void
    {
        $produto = new Produto(id: 1, nome: 'Fone', preco: 300.00, estoque: 3);
        $this->assertSame(0.00, $produto->comDesconto(100)->preco);
    }

    public function test_desconto_invalido_lanca_excecao(): void
    {
        $produto = new Produto(id: 1, nome: 'Monitor', preco: 1800.00, estoque: 2);

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Percentual deve ser entre 0 e 100');

        $produto->comDesconto(150); // inválido
    }

    public function test_tem_estoque_retorna_true_quando_suficiente(): void
    {
        $produto = new Produto(id: 1, nome: 'SSD', preco: 400.00, estoque: 5);

        $this->assertTrue($produto->temEstoque(1));
        $this->assertTrue($produto->temEstoque(5));
        $this->assertFalse($produto->temEstoque(6));
    }

    public function test_tem_estoque_retorna_false_quando_zerado(): void
    {
        $produto = new Produto(id: 1, nome: 'RAM', preco: 250.00, estoque: 0);
        $this->assertFalse($produto->temEstoque());
    }
}

Data Providers — testando múltiplos cenários

Data Providers eliminam a repetição de testes similares. Em vez de escrever um teste por caso, você declara os dados e o PHPUnit executa o mesmo método para cada conjunto.

<?php
declare(strict_types=1);

namespace Tests\Unit\Domain;

use App\Domain\Produto;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class ProdutoDescontoTest extends TestCase
{
    // PHP 8.x syntax: atributo #[DataProvider] no lugar de @dataProvider
    #[DataProvider('provedorDescontosValidos')]
    public function test_desconto_valido(float $preco, float $percentual, float $esperado): void
    {
        $produto     = new Produto(id: 1, nome: 'Produto', preco: $preco, estoque: 10);
        $comDesconto = $produto->comDesconto($percentual);
        $this->assertSame($esperado, $comDesconto->preco);
    }

    public static function provedorDescontosValidos(): array
    {
        return [
            'desconto 10% em R$200'   => [200.00, 10,  180.00],
            'desconto 25% em R$100'   => [100.00, 25,   75.00],
            'desconto 50% em R$300'   => [300.00, 50,  150.00],
            'desconto 0% mantém preço'=> [150.00,  0,  150.00],
            'desconto 100% zera preço'=> [500.00, 100,   0.00],
        ];
    }

    #[DataProvider('provedorDescontosInvalidos')]
    public function test_desconto_invalido_lanca_excecao(float $percentual): void
    {
        $produto = new Produto(id: 1, nome: 'Produto', preco: 100.00, estoque: 10);

        $this->expectException(\InvalidArgumentException::class);
        $produto->comDesconto($percentual);
    }

    public static function provedorDescontosInvalidos(): array
    {
        return [
            'negativo'      => [-1.0],
            'maior que 100' => [101.0],
            'muito alto'    => [999.0],
        ];
    }
}

Mocks — testando com dependências isoladas

Mocks substituem dependências reais por objetos controlados. Isso permite testar o comportamento de uma classe sem que ela precise de banco de dados, API externa ou qualquer infraestrutura.

<?php
declare(strict_types=1);

namespace App\Service;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioInterface;
use App\Service\NotificacaoService;

class CarrinhoService
{
    public function __construct(
        private readonly ProdutoRepositorioInterface $repositorio,
        private readonly NotificacaoService          $notificacao,
    ) {}

    public function adicionarAoCarrinho(int $produtoId, int $quantidade): array
    {
        $produto = $this->repositorio->buscarPorId($produtoId);

        if ($produto === null) {
            throw new \RuntimeException("Produto #{$produtoId} não encontrado.");
        }

        if (!$produto->temEstoque($quantidade)) {
            throw new \RuntimeException(
                "Estoque insuficiente. Disponível: {$produto->estoque}, solicitado: {$quantidade}"
            );
        }

        // Notifica se estoque ficará baixo após a compra
        if ($produto->estoque - $quantidade <= 3) {
            $this->notificacao->alertarEstoqueBaixo($produto);
        }

        return [
            'produto'    => ['id' => $produto->id, 'nome' => $produto->nome],
            'quantidade' => $quantidade,
            'subtotal'   => round($produto->preco * $quantidade, 2),
        ];
    }
}
<?php
declare(strict_types=1);

namespace Tests\Unit\Service;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioInterface;
use App\Service\CarrinhoService;
use App\Service\NotificacaoService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class CarrinhoServiceTest extends TestCase
{
    private ProdutoRepositorioInterface&MockObject $repositorio;
    private NotificacaoService&MockObject          $notificacao;
    private CarrinhoService                        $servico;

    // setUp() é executado antes de CADA método de teste
    protected function setUp(): void
    {
        $this->repositorio = $this->createMock(ProdutoRepositorioInterface::class);
        // Repare que o repositório é dublado pela interface, e não pela classe.
        // Isso não é preciosismo: o PHPUnit gera o dublê herdando do tipo, e
        // uma classe declarada final não pode ser herdada — o teste morre com
        // ClassIsFinalException: class "X" is declared "final" and cannot be
        // doubled. Depender de interface resolve isso antes de acontecer.
        $this->notificacao = $this->createMock(NotificacaoService::class);
        $this->servico     = new CarrinhoService($this->repositorio, $this->notificacao);
    }

    public function test_adicionar_produto_retorna_item_correto(): void
    {
        $produto = new Produto(id: 42, nome: 'Teclado', preco: 200.00, estoque: 10);

        // Configura o mock: quando buscarPorId(42) for chamado, retorna $produto
        $this->repositorio
            ->expects($this->once())       // deve ser chamado exatamente 1 vez
            ->method('buscarPorId')
            ->with(42)                     // com este argumento
            ->willReturn($produto);

        // Notificação NÃO deve ser chamada (estoque 10 - 2 = 8, não é baixo)
        $this->notificacao
            ->expects($this->never())
            ->method('alertarEstoqueBaixo');

        $resultado = $this->servico->adicionarAoCarrinho(produtoId: 42, quantidade: 2);

        $this->assertSame(42, $resultado['produto']['id']);
        $this->assertSame('Teclado', $resultado['produto']['nome']);
        $this->assertSame(2, $resultado['quantidade']);
        $this->assertSame(400.00, $resultado['subtotal']);
    }

    public function test_adicionar_produto_inexistente_lanca_excecao(): void
    {
        $this->repositorio
            ->method('buscarPorId')
            ->willReturn(null);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('não encontrado');

        $this->servico->adicionarAoCarrinho(produtoId: 999, quantidade: 1);
    }

    public function test_adicionar_sem_estoque_suficiente_lanca_excecao(): void
    {
        $produto = new Produto(id: 1, nome: 'SSD', preco: 400.00, estoque: 2);

        $this->repositorio->method('buscarPorId')->willReturn($produto);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Estoque insuficiente');

        $this->servico->adicionarAoCarrinho(produtoId: 1, quantidade: 5); // pede 5, tem 2
    }

    public function test_notificacao_e_disparada_quando_estoque_fica_baixo(): void
    {
        // Estoque = 4, quantidade = 2 → sobrará 2 (≤ 3) → deve notificar
        $produto = new Produto(id: 1, nome: 'Mouse', preco: 150.00, estoque: 4);

        $this->repositorio->method('buscarPorId')->willReturn($produto);

        // Agora o mock DEVE ser chamado — e com o produto correto
        $this->notificacao
            ->expects($this->once())
            ->method('alertarEstoqueBaixo')
            ->with($this->callback(fn($p) => $p->id === 1));

        $this->servico->adicionarAoCarrinho(produtoId: 1, quantidade: 2);
    }
}

Teste de integração — com banco de dados real

Testes de integração verificam que o código funciona com a infraestrutura real. Usamos SQLite em memória para isolar e manter os testes rápidos.

<?php
declare(strict_types=1);

namespace Tests\Integration\Repository;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioPDO;
use PHPUnit\Framework\TestCase;

class ProdutoRepositorioPDOTest extends TestCase
{
    private \PDO $pdo;
    private ProdutoRepositorioPDO $repositorio;

    protected function setUp(): void
    {
        // Banco em memória — zero custo, zero estado entre testes
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);

        // Cria o schema necessário para os testes
        $this->pdo->exec("
            CREATE TABLE produtos (
                id      INTEGER PRIMARY KEY AUTOINCREMENT,
                nome    TEXT    NOT NULL,
                preco   REAL    NOT NULL,
                estoque INTEGER NOT NULL DEFAULT 0,
                ativo   INTEGER NOT NULL DEFAULT 1
            )
        ");

        $this->repositorio = new ProdutoRepositorioPDO($this->pdo);
    }

    protected function tearDown(): void
    {
        // Banco em memória — descartado automaticamente; tearDown() aqui por clareza
        unset($this->pdo, $this->repositorio);
    }

    public function test_salvar_cria_produto_com_id(): void
    {
        $produto = new Produto(id: 0, nome: 'Teclado Mecânico', preco: 350.00, estoque: 10);
        $salvo   = $this->repositorio->salvar($produto);

        $this->assertGreaterThan(0, $salvo->id);
        $this->assertSame('Teclado Mecânico', $salvo->nome);
        $this->assertSame(350.00, $salvo->preco);
    }

    public function test_buscar_por_id_retorna_produto_correto(): void
    {
        $produto = $this->repositorio->salvar(
            new Produto(id: 0, nome: 'Mouse Gamer', preco: 180.00, estoque: 20)
        );

        $encontrado = $this->repositorio->buscarPorId($produto->id);

        $this->assertNotNull($encontrado);
        $this->assertSame($produto->id, $encontrado->id);
        $this->assertSame('Mouse Gamer', $encontrado->nome);
    }

    public function test_buscar_por_id_inexistente_retorna_null(): void
    {
        $this->assertNull($this->repositorio->buscarPorId(99999));
    }

    public function test_deletar_remove_produto(): void
    {
        $produto = $this->repositorio->salvar(
            new Produto(id: 0, nome: 'Fone Bluetooth', preco: 220.00, estoque: 5)
        );

        $this->repositorio->deletar($produto->id);
        $this->assertNull($this->repositorio->buscarPorId($produto->id));
    }

    public function test_buscar_ativos_retorna_apenas_ativos(): void
    {
        // Insere 2 ativos e 1 inativo diretamente no banco
        $this->pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
            ('Produto A', 100.00, 5, 1),
            ('Produto B', 200.00, 3, 1),
            ('Inativo',   150.00, 8, 0)
        ");

        $ativos = $this->repositorio->buscarAtivos();
        $this->assertCount(2, $ativos);

        // array_column lê propriedade pública de objeto desde o PHP 7.0:
        // o array_map no meio do caminho não fazia falta a ninguém.
        $nomes = array_column($ativos, 'nome');
        $this->assertContains('Produto A', $nomes);
        $this->assertContains('Produto B', $nomes);
        $this->assertNotContains('Inativo', $nomes);
    }
}

A pergunta que decide o valor de uma suite não é quantos testes ela tem, e sim o que cada um deixa de cobrir se for apagado. Teste que exercita getter e setter passa sempre e nunca avisa nada; o que vale é o que fixa uma regra de negócio — o desconto de 100% que zera o preço, o estoque que não pode ficar negativo, a notificação que dispara no limite e não um passo antes. Por isso os cenários de fronteira do data provider valem mais que dezenas de casos do meio da faixa.

Mock é ferramenta de isolamento, não de conveniência: cada expects() escrito congela um detalhe de como a classe trabalha, e suite cheia demais deles quebra em toda refatoração, mesmo quando o comportamento observável não mudou. Dublar interfaces em vez de classes concretas — e não só porque o PHPUnit se recusa a herdar de uma classe final — costuma ser o que mantém o teste falando de comportamento.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva testes unitários para a entidade Carrinho: métodos adicionar(Produto, int), remover(int $produtoId), calcularTotal(): float e estaVazio(): bool. Use Data Providers para testar calcularTotal() com múltiplos produtos.

Ver resposta

✓ Resposta: Nomear as chaves do Data Provider é o detalhe que economiza tempo: a saída do PHPUnit mostra calcularTotal com "preços com centavos quebrados" em vez de #4. E use assertEqualsWithDelta para float — assertSame em soma de decimais falha por arredondamento binário.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Domain\Carrinho;
use App\Domain\Produto;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class CarrinhoTest extends TestCase
{
    private Carrinho $carrinho;

    protected function setUp(): void
    {
        $this->carrinho = new Carrinho();
    }

    #[Test]
    public function carrinhoNasceVazio(): void
    {
        self::assertTrue($this->carrinho->estaVazio());
        self::assertSame(0.0, $this->carrinho->calcularTotal());
    }

    #[Test]
    public function adicionarProdutoTiraOCarrinhoDoEstadoVazio(): void
    {
        $this->carrinho->adicionar(new Produto(1, 'Teclado', 349.90), 1);

        self::assertFalse($this->carrinho->estaVazio());
        self::assertSame(349.90, $this->carrinho->calcularTotal());
    }

    #[Test]
    public function adicionarOMesmoProdutoSomaAsQuantidades(): void
    {
        $teclado = new Produto(1, 'Teclado', 100.00);

        $this->carrinho->adicionar($teclado, 2);
        $this->carrinho->adicionar($teclado, 3);

        self::assertSame(1, $this->carrinho->quantidadeDeItens());
        self::assertSame(500.00, $this->carrinho->calcularTotal());
    }

    #[Test]
    public function removerTiraOProdutoDoCarrinho(): void
    {
        $this->carrinho->adicionar(new Produto(1, 'Teclado', 100.00), 1);
        $this->carrinho->adicionar(new Produto(2, 'Mouse', 50.00), 1);

        $this->carrinho->remover(1);

        self::assertSame(50.00, $this->carrinho->calcularTotal());
    }

    #[Test]
    public function removerProdutoInexistenteNaoQuebra(): void
    {
        $this->carrinho->remover(999);

        self::assertTrue($this->carrinho->estaVazio());
    }

    #[Test]
    public function quantidadeZeroOuNegativaEhRejeitada(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->carrinho->adicionar(new Produto(1, 'Teclado', 100.00), 0);
    }

    /**
     * Data Provider: o mesmo teste roda uma vez por conjunto. Cada caso
     * aparece nomeado na saída, então a falha diz QUAL cenário quebrou.
     */
    #[Test]
    #[DataProvider('cenariosDeTotal')]
    public function calcularTotalSomaCorretamente(array $itens, float $esperado): void
    {
        foreach ($itens as [$id, $nome, $preco, $quantidade]) {
            $this->carrinho->adicionar(new Produto($id, $nome, $preco), $quantidade);
        }

        // Comparação de float pede delta: 0.1 + 0.2 !== 0.3 em binário.
        self::assertEqualsWithDelta($esperado, $this->carrinho->calcularTotal(), 0.001);
    }

    public static function cenariosDeTotal(): array
    {
        return [
            'carrinho vazio' => [
                [], 0.0,
            ],
            'um produto, uma unidade' => [
                [[1, 'Teclado', 349.90, 1]], 349.90,
            ],
            'um produto, várias unidades' => [
                [[1, 'Teclado', 349.90, 3]], 1049.70,
            ],
            'produtos diferentes' => [
                [[1, 'Teclado', 349.90, 1], [2, 'Mouse', 129.50, 2]], 608.90,
            ],
            'preços com centavos quebrados' => [
                [[1, 'Cabo', 19.99, 3], [2, 'Adesivo', 4.95, 7]], 94.62,
            ],
            'produto gratuito não altera o total' => [
                [[1, 'Teclado', 100.00, 1], [2, 'Brinde', 0.0, 5]], 100.00,
            ],
        ];
    }
}

Exercício 2

Crie um mock do ServicoJWT para testar o AuthController sem emitir tokens reais. O mock deve simular cenários de token válido, expirado e inválido — verifique que o controller retorna os status HTTP corretos em cada caso.

Ver resposta

✓ Resposta: O mock isola o controller: o teste não depende de segredo, relógio ou biblioteca de JWT — e roda em milissegundos. Repare no expects(self::never()): garantir que uma chamada não acontece é uma asserção legítima, e pega trabalho desperdiçado.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Auth\AuthController;
use App\Auth\ServicoJWT;
use App\Auth\TokenExpiradoException;
use App\Auth\TokenInvalidoException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class AuthControllerTest extends TestCase
{
    #[Test]
    public function tokenValidoDevolve200ComOsDadosDoUsuario(): void
    {
        // createMock devolve um dublê com todos os métodos neutros; só o que
        // for configurado tem comportamento. Nenhum token real é emitido.
        $jwt = $this->createMock(ServicoJWT::class);

        $jwt->expects(self::once())          // exatamente uma chamada
            ->method('validar')
            ->with('token-valido')           // e com este argumento
            ->willReturn(['sub' => 7, 'jti' => 'abc', 'exp' => time() + 900]);

        $resposta = (new AuthController($jwt))->perfil('token-valido');

        self::assertSame(200, $resposta['status']);
        self::assertSame(7, $resposta['corpo']['usuario_id']);
    }

    #[Test]
    public function tokenExpiradoDevolve401(): void
    {
        $jwt = $this->createMock(ServicoJWT::class);

        $jwt->method('validar')
            ->willThrowException(new TokenExpiradoException('Token expirado.'));

        $resposta = (new AuthController($jwt))->perfil('token-velho');

        self::assertSame(401, $resposta['status']);
        self::assertSame('token_expirado', $resposta['corpo']['erro']);
    }

    #[Test]
    public function tokenInvalidoDevolve401ComCodigoProprio(): void
    {
        $jwt = $this->createMock(ServicoJWT::class);

        $jwt->method('validar')
            ->willThrowException(new TokenInvalidoException('Assinatura inválida.'));

        $resposta = (new AuthController($jwt))->perfil('token-forjado');

        self::assertSame(401, $resposta['status']);
        self::assertSame('token_invalido', $resposta['corpo']['erro']);

        // A mensagem técnica não pode vazar para o cliente.
        self::assertStringNotContainsString('Assinatura', json_encode($resposta['corpo']));
    }

    #[Test]
    public function tokenAusenteNemChegaAConsultarOServico(): void
    {
        $jwt = $this->createMock(ServicoJWT::class);

        // never(): provar que o método NÃO é chamado é tão útil quanto
        // provar que é — aqui, que não se gasta validação com token vazio.
        $jwt->expects(self::never())->method('validar');

        $resposta = (new AuthController($jwt))->perfil('');

        self::assertSame(401, $resposta['status']);
    }

    #[Test]
    public function respostasSucessivasSimulamRenovacaoDeToken(): void
    {
        $jwt = $this->createMock(ServicoJWT::class);

        // willReturnOnConsecutiveCalls: 1ª chamada falha, 2ª passa.
        $jwt->method('validar')
            ->willReturnOnConsecutiveCalls(
                self::throwException(new TokenExpiradoException('expirado')),
                ['sub' => 7, 'jti' => 'novo', 'exp' => time() + 900],
            );

        $controller = new AuthController($jwt);

        self::assertSame(401, $controller->perfil('t1')['status']);
        self::assertSame(200, $controller->perfil('t2')['status']);
    }
}

Exercício 3

Adicione cobertura de testes para o QueryBuilder construído no artigo PDO Avançado, Prepared Statements e Padrão Repository. Teste cada método (where, whereIn, orderBy, limit, offset) verificando o SQL gerado por build().

Ver resposta

✓ Resposta: O último teste é o mais valioso da suíte: ele documenta que identificador passa por lista branca. Um refactor que "simplifique" a validação some com a proteção contra injeção — e é justamente esse teste que impede o merge.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Database\QueryBuilder;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class QueryBuilderTest extends TestCase
{
    // O builder é a peça perfeita para teste unitário: entra chamada de
    // método, sai string. Nenhum banco envolvido.
    #[Test]
    public function selectSimples(): void
    {
        [$sql, $params] = (new QueryBuilder())->from('produtos')->build();

        self::assertSame('SELECT * FROM produtos', $sql);
        self::assertSame([], $params);
    }

    #[Test]
    public function selectComColunas(): void
    {
        [$sql] = (new QueryBuilder())
            ->select('id', 'nome')
            ->from('produtos')
            ->build();

        self::assertSame('SELECT id, nome FROM produtos', $sql);
    }

    #[Test]
    public function whereGeraPlaceholderEParametro(): void
    {
        [$sql, $params] = (new QueryBuilder())
            ->from('produtos')
            ->where('ativo', '=', 1)
            ->build();

        self::assertStringContainsString('WHERE ativo = :', $sql);
        self::assertContains(1, $params);
        self::assertCount(1, $params);
    }

    #[Test]
    public function doisWheresSaoUnidosPorAnd(): void
    {
        [$sql, $params] = (new QueryBuilder())
            ->from('produtos')
            ->where('ativo', '=', 1)
            ->where('preco', '>', 100)
            ->build();

        self::assertMatchesRegularExpression('/WHERE ativo = :\w+ AND preco > :\w+/', $sql);
        self::assertCount(2, $params);

        // Placeholders distintos: se colidissem, um valor sobrescreveria o outro.
        self::assertCount(2, array_unique(array_keys($params)));
    }

    #[Test]
    public function whereInAceitaLista(): void
    {
        [$sql, $params] = (new QueryBuilder())
            ->from('produtos')
            ->whereIn('categoria_id', [1, 2, 3])
            ->build();

        self::assertStringContainsString('categoria_id IN (', $sql);
        self::assertCount(3, $params);
    }

    #[Test]
    public function orderByAceitaAscEDesc(): void
    {
        [$sqlAsc] = (new QueryBuilder())->from('p')->orderBy('nome')->build();
        [$sqlDesc] = (new QueryBuilder())->from('p')->orderBy('preco', 'desc')->build();

        self::assertStringEndsWith('ORDER BY nome ASC', $sqlAsc);
        self::assertStringEndsWith('ORDER BY preco DESC', $sqlDesc);
    }

    #[Test]
    public function limitEOffsetSaemNaOrdemCerta(): void
    {
        [$sql] = (new QueryBuilder())
            ->from('produtos')
            ->limit(10)
            ->offset(20)
            ->build();

        self::assertStringEndsWith('LIMIT 10 OFFSET 20', $sql);
    }

    #[Test]
    public function clausulasSaemNaOrdemDoSqlIndependenteDaOrdemDasChamadas(): void
    {
        // Chamadas fora de ordem, SQL na ordem correta: é o que o builder
        // garante e o motivo de ele existir.
        [$sql] = (new QueryBuilder())
            ->limit(5)
            ->orderBy('nome')
            ->from('produtos')
            ->where('ativo', '=', 1)
            ->select('id')
            ->build();

        self::assertMatchesRegularExpression(
            '/^SELECT id FROM produtos WHERE .+ ORDER BY nome ASC LIMIT 5$/',
            $sql,
        );
    }

    #[Test]
    public function buildSemFromLancaExcecao(): void
    {
        $this->expectException(\LogicException::class);
        $this->expectExceptionMessage('from()');

        (new QueryBuilder())->select('id')->build();
    }

    #[Test]
    #[DataProvider('identificadoresPerigosos')]
    public function identificadorInvalidoEhRejeitado(string $coluna): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new QueryBuilder())->from('produtos')->orderBy($coluna)->build();
    }

    public static function identificadoresPerigosos(): array
    {
        return [
            'injeção com drop'  => ['nome; DROP TABLE produtos'],
            'comentário sql'    => ['nome --'],
            'espaço e union'    => ['id UNION SELECT senha FROM usuarios'],
            'aspas'             => ["nome'"],
            'parêntese'         => ['count(*)'],
        ];
    }
}

Exercício 4

Implemente um teste de integração para o MigrationRunner: rode as migrations em SQLite em memória e verifique que as tabelas foram criadas. Rode de novo e verifique que não há erro (idempotência). Execute rollback() e verifique que as tabelas foram removidas.

Ver resposta

✓ Resposta: Cuidado com o que o SQLite não reproduz do MySQL: sem PRAGMA foreign_keys = ON ele ignora FK, os tipos são flexíveis, e ENUM/UNSIGNED não existem. Serve muito bem para testar a mecânica do runner; o SQL específico do MySQL merece um teste contra MySQL de verdade.

<?php

declare(strict_types=1);

namespace Tests\Integration;

use App\Database\MigrationRunner;
use PDO;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class MigrationRunnerTest extends TestCase
{
    private PDO $pdo;
    private MigrationRunner $runner;

    protected function setUp(): void
    {
        // :memory: nasce e morre com a conexão: cada teste começa do zero,
        // sem arquivo para limpar e sem interferência entre testes.
        $this->pdo = new PDO('sqlite::memory:', null, null, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        ]);

        // SQLite ignora foreign keys por padrão — sem isto, o teste passaria
        // mesmo com FK apontando para tabela inexistente.
        $this->pdo->exec('PRAGMA foreign_keys = ON');

        $this->runner = new MigrationRunner($this->pdo, [
            'Migration001Usuarios'   => new Migration001Usuarios(),
            'Migration002Categorias' => new Migration002Categorias(),
            'Migration003Produtos'   => new Migration003Produtos(),
        ]);
    }

    #[Test]
    public function migrateCriaTodasAsTabelas(): void
    {
        $this->runner->migrate();

        foreach (['usuarios', 'categorias', 'produtos'] as $tabela) {
            self::assertTrue($this->tabelaExiste($tabela), "Faltou a tabela {$tabela}");
        }
    }

    #[Test]
    public function migrateRegistraAsMigrationsExecutadas(): void
    {
        $this->runner->migrate();

        $executadas = $this->pdo->query('SELECT migration FROM migrations ORDER BY id')
                                ->fetchAll(PDO::FETCH_COLUMN);

        self::assertSame(
            ['Migration001Usuarios', 'Migration002Categorias', 'Migration003Produtos'],
            $executadas,
        );
    }

    #[Test]
    public function migrateEhIdempotente(): void
    {
        $aplicadas = $this->runner->migrate();
        self::assertSame(3, $aplicadas);

        // A segunda chamada não pode explodir nem reaplicar nada.
        $segunda = $this->runner->migrate();

        self::assertSame(0, $segunda, 'A 2ª execução não deveria aplicar nada.');
        self::assertSame(3, $this->contarMigrationsRegistradas());
    }

    #[Test]
    public function rollbackRemoveAsTabelasDoUltimoLote(): void
    {
        $this->runner->migrate();
        self::assertTrue($this->tabelaExiste('produtos'));

        $this->runner->rollback();

        foreach (['usuarios', 'categorias', 'produtos'] as $tabela) {
            self::assertFalse($this->tabelaExiste($tabela), "Sobrou a tabela {$tabela}");
        }

        self::assertSame(0, $this->contarMigrationsRegistradas());
    }

    #[Test]
    public function migrateAposRollbackFuncionaDeNovo(): void
    {
        $this->runner->migrate();
        $this->runner->rollback();

        $reaplicadas = $this->runner->migrate();

        self::assertSame(3, $reaplicadas);
        self::assertTrue($this->tabelaExiste('produtos'));
    }

    #[Test]
    public function foreignKeyImpedeProdutoSemCategoria(): void
    {
        $this->runner->migrate();

        $this->expectException(\PDOException::class);

        $this->pdo->exec(
            "INSERT INTO produtos (categoria_id, nome, preco) VALUES (999, 'Órfão', 10)"
        );
    }

    private function tabelaExiste(string $nome): bool
    {
        $stmt = $this->pdo->prepare(
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = :nome"
        );
        $stmt->execute([':nome' => $nome]);

        return (int) $stmt->fetchColumn() > 0;
    }

    private function contarMigrationsRegistradas(): int
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
    }
}

Exercício 5

Desafio: configure cobertura de código com Xdebug ou PCOV e execute vendor/bin/phpunit --coverage-html coverage/. Identifique os caminhos de código não cobertos e escreva testes para atingir 90% de cobertura nos módulos Domain e Service.

Ver resposta

✓ Resposta: Cobertura mede o que executou, não o que foi verificado: um teste sem nenhum assert pode dar 100%. Por isso 90% é meta de orientação, não de qualidade — os 10% que faltam quase sempre são os caminhos de erro, e são justamente os que mais quebram em produção.

<?php
// ============ Instalando o driver de cobertura ============
//
// PCOV — leve e rápido, só mede cobertura de linha:
//   pecl install pcov
//   php.ini:  extension=pcov
//             pcov.enabled=1
//             pcov.directory=/caminho/do/projeto/src
//
// Xdebug — mais lento, mas mede branch e path coverage:
//   pecl install xdebug
//   php.ini:  zend_extension=xdebug
//             xdebug.mode=coverage
//
// Conferir:  php -m | grep -E 'pcov|xdebug'

// ============ phpunit.xml ============
// <?xml version="1.0" encoding="UTF-8"?>
// <phpunit bootstrap="vendor/autoload.php" colors="true"
//          failOnWarning="true" failOnRisky="true">
//     <testsuites>
//         <testsuite name="unit">        <directory>tests/Unit</directory></testsuite>
//         <testsuite name="integration"> <directory>tests/Integration</directory></testsuite>
//     </testsuites>
//
//     <source>
//         <include>
//             <directory>src/Domain</directory>
//             <directory>src/Service</directory>
//         </include>
//         <exclude>
//             <!-- Config e migrations não são lógica: incluí-las só
//                  distorce o número para baixo. -->
//             <directory>src/Config</directory>
//             <file>src/Database/Migration001Usuarios.php</file>
//         </exclude>
//     </source>
//
//     <coverage>
//         <report>
//             <html outputDirectory="coverage"/>
//             <text outputFile="php://stdout" showOnlySummary="true"/>
//             <clover outputFile="coverage/clover.xml"/>
//         </report>
//     </coverage>
// </phpunit>

// ============ Rodando ============
//   vendor/bin/phpunit --coverage-html coverage/
//   vendor/bin/phpunit --coverage-text
//   vendor/bin/phpunit --coverage-text --path-coverage      (só Xdebug)
//
// Abrir coverage/index.html: as linhas vermelhas são o que nenhum teste
// executou. É por elas que se começa.

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

// #[CoversClass] restringe a medição ao alvo real do teste: sem isso, um
// teste de Carrinho "cobre" Produto de tabela, e o número engana.
#[CoversClass(\App\Domain\Carrinho::class)]
final class CarrinhoCoberturaTest extends TestCase
{
    // Os caminhos que costumam ficar vermelhos são os de erro — ninguém
    // lembra de testar o `throw`.
    #[Test]
    public function quantidadeNegativaLancaExcecao(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new \App\Domain\Carrinho())->adicionar(new \App\Domain\Produto(1, 'X', 10.0), -1);
    }

    #[Test]
    public function aplicarCupomExpiradoNaoAlteraOTotal(): void
    {
        $carrinho = new \App\Domain\Carrinho();
        $carrinho->adicionar(new \App\Domain\Produto(1, 'Teclado', 100.0), 1);

        $carrinho->aplicarCupom('EXPIRADO10', new \DateTimeImmutable('2020-01-01'));

        self::assertSame(100.0, $carrinho->calcularTotal());
    }

    #[Test]
    public function carrinhoAcimaDoLimiteDeItensRecusaNovoProduto(): void
    {
        $carrinho = new \App\Domain\Carrinho(limiteDeItens: 2);

        $carrinho->adicionar(new \App\Domain\Produto(1, 'A', 10.0), 1);
        $carrinho->adicionar(new \App\Domain\Produto(2, 'B', 10.0), 1);

        $this->expectException(\DomainException::class);
        $carrinho->adicionar(new \App\Domain\Produto(3, 'C', 10.0), 1);
    }
}

// ============ Exigindo o mínimo no CI ============
// vendor/bin/phpunit --coverage-text \
//   --coverage-clover coverage/clover.xml \
//   --fail-on-incomplete --fail-on-skipped
//
// O PHPUnit 11 aceita limite mínimo direto no XML:
// <coverage><report>...</report></coverage>
// com requireCoverageMetadata="true" no elemento <phpunit>.
Comentários

Mais em PHP

PostgreSQL com PHP
PostgreSQL com PHP

Conectar é só trocar o DSN, mas o que vem depois prende: RETURNING, UPSERT com…

Design Patterns: Singleton, Factory e Builder
Design Patterns: Singleton, Factory e Builder

Três padrões de criação e o que cada um custa: o Singleton, que garante…

Clean Architecture com PHP
Clean Architecture com PHP

Um módulo de pedidos montado camada por camada, com a entidade guardando as…