Testes Automatizados com PHPUnit e Pest

Testes Automatizados com PHPUnit e Pest

PHPUnit e Pest lado a lado, mocks, testes de integração com banco e testes de feature no Laravel, com a régua de quando cada um vale a pena. Inclui a flag que não existe, o schema de produção que o SQLite recusa e a assertiva de JSON escrita no palpite.
PHP

• • 32 min de leitura

Testes automatizados são o seguro de vida do software. Sem eles, cada mudança no código é um salto no escuro — você não sabe o que quebrou até o usuário reclamar. Com eles, você refatora com confiança, entrega com velocidade e dorme tranquilo.

PHP tem duas ferramentas de teste de primeira linha: o PHPUnit, padrão da indústria há mais de 20 anos, e o Pest, uma camada mais moderna construída sobre o PHPUnit com sintaxe mais expressiva. Laravel integra ambos nativamente.

PHPUnit — fundamentos

composer require --dev phpunit/phpunit
./vendor/bin/phpunit --generate-configuration   # cria phpunit.xml

# Cuidado com a flag: `--init` é do Pest, não do PHPUnit — aqui ela
# responde `Unknown option "--init"` (conferido no PHPUnit 11.5).
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Domain\Pedido\Dinheiro;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

class DinheiroTest extends TestCase
{
    #[Test]
    public function cria_dinheiro_com_valor_valido(): void
    {
        $d = new Dinheiro(1000); // centavos
        $this->assertSame(1000, $d->centavos());
        $this->assertSame('R$ 10,00', $d->formatar());
    }

    #[Test]
    public function soma_dois_valores(): void
    {
        $a = new Dinheiro(1000);
        $b = new Dinheiro(2500);

        $resultado = $a->somar($b);

        $this->assertSame(3500, $resultado->centavos());
        // Imutabilidade: a e b não foram alterados
        $this->assertSame(1000, $a->centavos());
        $this->assertSame(2500, $b->centavos());
    }

    #[Test]
    public function lanca_excecao_para_valor_negativo(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Valor não pode ser negativo');

        new Dinheiro(-1);
    }

    #[Test]
    #[DataProvider('provedorDeDescontos')]
    public function aplica_desconto_corretamente(int $centavos, float $percentual, int $esperado): void
    {
        $dinheiro  = new Dinheiro($centavos);
        $resultado = $dinheiro->aplicarDesconto($percentual);

        $this->assertSame($esperado, $resultado->centavos());
    }

    public static function provedorDeDescontos(): array
    {
        return [
            'desconto zero'     => [1000, 0.0,  1000],
            'desconto 10%'      => [1000, 10.0, 900],
            'desconto 50%'      => [2000, 50.0, 1000],
            'desconto completo' => [1000, 100.0, 0],
        ];
    }
}

Mocks e stubs — isolando dependências

<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\CadastroService;
use App\Repositories\UsuarioRepositorioInterface;
use App\Services\EmailService;
use App\Models\Usuario;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

class CadastroServiceTest extends TestCase
{
    #[Test]
    public function cadastra_usuario_e_envia_email(): void
    {
        // Stub do repositório — retorna usuário salvo
        $repositorio = $this->createMock(UsuarioRepositorioInterface::class);
        $repositorio
            ->expects($this->once())         // deve ser chamado exatamente 1 vez
            ->method('salvar')
            ->willReturn(new Usuario(id: 1, nome: 'Alice', email: 'alice@teste.com'));

        // Mock do email — verifica que foi chamado
        $emailService = $this->createMock(EmailService::class);
        $emailService
            ->expects($this->once())
            ->method('enviarBoasVindas')
            ->with($this->callback(fn(Usuario $u) => $u->email === 'alice@teste.com'));

        $service = new CadastroService($repositorio, $emailService);
        $usuario = $service->cadastrar(new DadosDeCadastro(
            nome:  'Alice',
            email: 'alice@teste.com',
            senha: 'Senh@Segura123',
        ));

        $this->assertSame('Alice', $usuario->nome);
        $this->assertSame(1, $usuario->id);
    }

    #[Test]
    public function nao_envia_email_se_cadastro_falhar(): void
    {
        $repositorio = $this->createMock(UsuarioRepositorioInterface::class);
        $repositorio
            ->method('salvar')
            ->willThrowException(new \RuntimeException('Falha no banco'));

        $emailService = $this->createMock(EmailService::class);
        $emailService->expects($this->never())->method('enviarBoasVindas');

        $service = new CadastroService($repositorio, $emailService);

        $this->expectException(\RuntimeException::class);
        $service->cadastrar(new DadosDeCadastro('Alice', 'alice@teste.com', 'Senh@Segura123'));
    }
}

Testes de integração com banco de dados

<?php
declare(strict_types=1);

namespace Tests\Integration;

use App\Repositories\UsuarioRepositorioPDO;
use PHPUnit\Framework\TestCase;

class UsuarioRepositorioPDOTest extends TestCase
{
    private \PDO $pdo;
    private UsuarioRepositorioPDO $repositorio;

    protected function setUp(): void
    {
        // SQLite em memória — rápido e isolado
        $this->pdo = new \PDO('sqlite::memory:', options: [
            \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_EXCEPTION,
            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
        ]);
        $this->pdo->exec('PRAGMA foreign_keys = ON');

        // Roda o schema dos TESTES — e repare que não é o dump de produção.
        //
        // Despejar o `schema.sql` do MySQL aqui é a tentação óbvia e não
        // funciona: o SQLite morre no primeiro `SET` do cabeçalho do dump
        // (`near "SET": syntax error`), e mesmo passando dele ele não
        // conhece `ENGINE=InnoDB`, `AUTO_INCREMENT`, `UNSIGNED` nem
        // `ON UPDATE CURRENT_TIMESTAMP`. Duas saídas honestas:
        //
        //   a) rodar as MIGRATIONS do projeto, que são escritas na
        //      linguagem do framework e sabem falar com os dois bancos;
        //   b) usar no teste o mesmo SGBD da produção (um MySQL no
        //      docker-compose do CI), que é a única forma de o teste de
        //      integração testar mesmo a integração.
        //
        // SQLite em memória é imbatível em velocidade, mas o que ele
        // garante é que o SQL do repositório roda no SQLite.
        $this->pdo->exec(file_get_contents(__DIR__ . '/../schema-sqlite.sql'));

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

    public function test_salvar_e_buscar_por_id(): void
    {
        $id      = $this->repositorio->salvar(new DadosDeUsuario('Alice', 'alice@teste.com'));
        $usuario = $this->repositorio->buscarPorId($id);

        $this->assertNotNull($usuario);
        $this->assertSame('Alice', $usuario->nome);
        $this->assertSame('alice@teste.com', $usuario->email);
    }

    public function test_buscar_por_email_retorna_null_para_inexistente(): void
    {
        $usuario = $this->repositorio->buscarPorEmail('naoexiste@teste.com');
        $this->assertNull($usuario);
    }
}

Pest — sintaxe moderna

composer require --dev pestphp/pest pestphp/pest-plugin-laravel
./vendor/bin/pest --init
<?php
// tests/Unit/DinheiroTest.php

use App\Domain\Pedido\Dinheiro;

describe('Dinheiro', function () {

    it('cria com valor válido', function () {
        $d = new Dinheiro(1000);
        expect($d->centavos())->toBe(1000);
        expect($d->formatar())->toBe('R$ 10,00');
    });

    it('soma dois valores corretamente', function () {
        $resultado = (new Dinheiro(1000))->somar(new Dinheiro(2500));
        expect($resultado->centavos())->toBe(3500);
    });

    it('lança exceção para valor negativo', function () {
        expect(fn() => new Dinheiro(-1))
            ->toThrow(\InvalidArgumentException::class, 'Valor não pode ser negativo');
    });

    it('aplica desconto corretamente')->with([
        [1000, 10.0, 900],
        [2000, 50.0, 1000],
        [1000, 0.0,  1000],
    ])->expect(function (int $centavos, float $pct, int $esperado) {
        expect((new Dinheiro($centavos))->aplicarDesconto($pct)->centavos())->toBe($esperado);
    });

});

Testes de feature no Laravel

<?php
// tests/Feature/ProdutoApiTest.php

use App\Models\Produto;
use App\Models\User;

describe('API de Produtos', function () {

    it('lista produtos paginados', function () {
        Produto::factory()->count(20)->create();

        $response = $this->getJson('/api/produtos');

        $response
            ->assertOk()
            ->assertJsonStructure([
                'data' => [['id', 'nome', 'preco']],
                // As chaves de `meta` são as do Laravel, em inglês:
                // current_page, from, last_page, path, per_page, to, total.
                // Elas vêm do `toArray()` do paginador e SÓ mudam se o
                // endpoint devolver uma ResourceCollection própria que
                // reescreva o bloco. Assertiva escrita no palpite passa a
                // ser um teste que falha sem que nada esteja errado.
                'meta' => ['total', 'per_page', 'current_page'],
            ])
            ->assertJsonCount(15, 'data'); // paginação de 15
    });

    it('cria produto com autenticação', function () {
        $admin = User::factory()->admin()->create();

        $response = $this->actingAs($admin, 'sanctum')
            ->postJson('/api/produtos', [
                'nome'         => 'Teclado Mecânico',
                'preco'        => 299.90,
                'estoque'      => 10,
                'categoria_id' => 1,
            ]);

        $response->assertCreated()->assertJsonPath('data.nome', 'Teclado Mecânico');
        $this->assertDatabaseHas('produtos', ['nome' => 'Teclado Mecânico']);
    });

    it('recusa criação sem autenticação', function () {
        $this->postJson('/api/produtos', ['nome' => 'Teste'])
            ->assertUnauthorized();
    });

    it('retorna 404 para produto inexistente', function () {
        $this->getJson('/api/produtos/99999')
            ->assertNotFound()
            ->assertJsonPath('mensagem', 'Recurso não encontrado.');
    });

});

Cobertura de código

# Requer a extensão Xdebug ou PCOV
php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html=coverage/

# Com Pest
./vendor/bin/pest --coverage --min=80  # falha se cobertura < 80%

A pergunta que decide se uma suíte vale o que custa não é "qual a cobertura?", e sim "o que este teste me deixa fazer que eu não faria sem ele?". Um teste que repete a implementação linha a linha não autoriza nada: quebra a cada refatoração e obriga a corrigir dois arquivos para mudar um comportamento. Um teste que fixa o contrato — a soma de dois valores em centavos não perde centavo, o desconto no piso exato não sobe de faixa, o e-mail não sai quando a gravação falha — autoriza mexer no corpo inteiro da classe sem pedir licença. Os 80% de cobertura são consequência de escrever os segundos; perseguidos diretamente, produzem os primeiros.

Sobre a escolha entre PHPUnit e Pest, vale desarmar a disputa: é a mesma máquina embaixo, e o Pest é uma camada de sintaxe sobre o PHPUnit. Migrar um projeto inteiro só pela estética raramente se paga; escrever os testes novos na sintaxe que o time lê mais rápido, sim. A decisão que muda resultado de verdade é outra, e é a dos limites — o que roda em memória e em milissegundos, o que precisa de banco de verdade, e o que só faz sentido atravessando a aplicação inteira pelo HTTP. Suíte que confunde os três níveis fica lenta o bastante para ninguém rodar, e uma suíte que ninguém roda é documentação desatualizada com passos extras.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva testes unitários para a classe Dinheiro cobrindo: criação válida, soma, subtração, multiplicação, desconto, igualdade, formatação, e todos os casos de erro.

Ver resposta

✓ Resposta: Repare no test_repartir_nunca_perde_centavo: em vez de um exemplo escolhido a dedo, ele afirma uma propriedade — a soma das partes volta ao original — e a verifica em 30 combinações. É o formato de teste que pega bug de arredondamento, justamente o que passa despercebido quando você escolhe os números. O outro hábito que se paga: toda operação tem uma asserção sobre o operando original, porque é ela que falha no dia em que alguém trocar return new self por +=.

<?php

// ---------------------------------------- tests/Unit/DinheiroTest.php  (PHPUnit)
declare(strict_types=1);

namespace Tests\Unit;

use App\Suporte\Dinheiro;
use App\Suporte\Moeda;
use DivisionByZeroError;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

// CoversClass restringe a cobertura ao que este arquivo se propõe a
// testar: sem isso, o relatório credita ao DinheiroTest as linhas que ele
// só encostou de passagem.
#[CoversClass(Dinheiro::class)]
final class DinheiroTest extends TestCase
{
    // ---- criação ----

    #[TestDox('aceita os formatos de entrada do mundo real')]
    #[DataProvider('entradas')]
    public function test_criacao(int|float|string $entrada, int $esperado): void
    {
        $this->assertSame($esperado, Dinheiro::deReais($entrada)->centavos);
    }

    /** @return iterable<string, array{int|float|string, int}> */
    public static function entradas(): iterable
    {
        yield 'inteiro'          => [20, 2000];
        yield 'float'            => [19.9, 1990];
        yield 'texto pt-BR'      => ['19,90', 1990];
        yield 'texto com milhar' => ['1.234,56', 123456];
        yield 'ponto decimal'    => ['19.90', 1990];
        yield 'zero'             => [0, 0];
        // O caso que só aparece com arredondamento correto: (int)(0.29*100)
        // dá 28 em ponto flutuante binário.
        yield 'o clássico 0,29'  => ['0,29', 29];
    }

    public function test_valor_negativo_e_recusado_na_construcao(): void
    {
        $this->expectException(InvalidArgumentException::class);

        new Dinheiro(-1);
    }

    // ---- operações ----

    public function test_somar(): void
    {
        $a = Dinheiro::deReais('10,50');
        $b = Dinheiro::deReais('5,25');

        $this->assertSame(1575, $a->somar($b)->centavos);

        // A asserção que prova a imutabilidade — a que falharia se alguém
        // trocasse o `return new self` por um `+=`.
        $this->assertSame(1050, $a->centavos);
        $this->assertSame(505 + 20, $b->centavos);
    }

    public function test_subtrair(): void
    {
        $resultado = Dinheiro::deReais(10)->subtrair(Dinheiro::deReais('2,50'));

        $this->assertSame(750, $resultado->centavos);
    }

    #[DataProvider('multiplicacoes')]
    public function test_multiplicar(int $centavos, float $fator, int $esperado): void
    {
        $this->assertSame($esperado, (new Dinheiro($centavos))->multiplicar($fator)->centavos);
    }

    /** @return iterable<string, array{int, float, int}> */
    public static function multiplicacoes(): iterable
    {
        yield 'dobro'              => [1000, 2.0,   2000];
        yield 'metade'             => [1001, 0.5,    501];   // 500,5 → 501
        yield 'zero'               => [1000, 0.0,      0];
        yield 'fração que arredonda' => [333, 3.0,    999];
    }

    public function test_dividir(): void
    {
        $this->assertSame(250, Dinheiro::deReais(10)->dividir(4)->centavos);
    }

    public function test_dividir_por_zero(): void
    {
        $this->expectException(DivisionByZeroError::class);

        Dinheiro::deReais(10)->dividir(0);
    }

    #[DataProvider('descontos')]
    public function test_aplicar_desconto(int $centavos, float $percentual, int $esperado): void
    {
        $this->assertSame(
            $esperado,
            (new Dinheiro($centavos))->aplicarDesconto($percentual)->centavos,
        );
    }

    /** @return iterable<string, array{int, float, int}> */
    public static function descontos(): iterable
    {
        yield 'sem desconto'  => [10000,   0.0, 10000];
        yield 'dez por cento' => [10000,  10.0,  9000];
        yield 'tudo'          => [10000, 100.0,     0];
        yield 'meio centavo'  => [  333,  10.0,   300];   // 33,3 → 33 de desconto
    }

    #[DataProvider('percentuaisInvalidos')]
    public function test_desconto_fora_da_faixa(float $percentual): void
    {
        $this->expectException(InvalidArgumentException::class);

        Dinheiro::deReais(100)->aplicarDesconto($percentual);
    }

    /** @return iterable<string, array{float}> */
    public static function percentuaisInvalidos(): iterable
    {
        yield 'negativo'    => [-1.0];
        yield 'acima de 100' => [100.01];
        yield 'absurdo'     => [1000.0];
    }

    // ---- igualdade e formatação ----

    public function test_equals_compara_valor_e_moeda(): void
    {
        $this->assertTrue(Dinheiro::deReais(10)->equals(new Dinheiro(1000)));
        $this->assertFalse(Dinheiro::deReais(10)->equals(new Dinheiro(1001)));
        $this->assertFalse(
            (new Dinheiro(1000, Moeda::BRL))->equals(new Dinheiro(1000, Moeda::USD))
        );
    }

    public function test_operacao_entre_moedas_diferentes(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessageMatches('/moedas diferentes/');

        (new Dinheiro(100, Moeda::BRL))->somar(new Dinheiro(100, Moeda::USD));
    }

    public function test_formatacao(): void
    {
        $this->assertSame('R$ 1.234,56', (new Dinheiro(123456))->formatado());
        $this->assertSame('R$ 0,00', (new Dinheiro(0))->formatado());
        $this->assertSame('US$ 19,90', (new Dinheiro(1990, Moeda::USD))->formatado());
    }

    // ---- a propriedade que resume tudo ----

    public function test_repartir_nunca_perde_centavo(): void
    {
        // Não é um caso, são 200: para todo valor e toda divisão, a soma
        // das partes tem de voltar ao original. Teste de PROPRIEDADE, e é
        // ele que pega o bug de arredondamento que exemplo escolhido a
        // dedo deixa passar.
        foreach ([1, 3, 7, 11, 13] as $partes) {
            foreach ([1, 33, 100, 999, 10_000, 123_457] as $centavos) {
                $pedacos = (new Dinheiro($centavos))->repartir($partes);

                $this->assertCount($partes, $pedacos);
                $this->assertSame(
                    $centavos,
                    array_sum(array_map(static fn (Dinheiro $d): int => $d->centavos, $pedacos)),
                    "Perdeu centavo ao repartir {$centavos} em {$partes}.",
                );
            }
        }
    }
}

// ---------------------------------------- tests/Unit/DinheiroTest.php  (a mesma coisa em Pest)
declare(strict_types=1);

use App\Suporte\Dinheiro;
use App\Suporte\Moeda;

// O Pest é o MESMO PHPUnit por baixo: as duas sintaxes convivem no mesmo
// projeto, e a suíte roda junta. Não é decisão de arquitetura, é gosto.

it('soma sem alterar os operandos', function (): void {
    $a = Dinheiro::deReais('10,50');

    expect($a->somar(Dinheiro::deReais('5,25'))->centavos)->toBe(1575)
        ->and($a->centavos)->toBe(1050);
});

it('recusa valor negativo', function (): void {
    new Dinheiro(-1);
})->throws(InvalidArgumentException::class);

// O dataset do Pest é o Data Provider com outro nome — e sem o método
// estático separado.
it('converte as entradas do mundo real', function (string $entrada, int $esperado): void {
    expect(Dinheiro::deReais($entrada)->centavos)->toBe($esperado);
})->with([
    ['19,90', 1990],
    ['1.234,56', 123456],
    ['0,29', 29],
]);

it('não deixa somar moedas diferentes', function (): void {
    (new Dinheiro(100, Moeda::BRL))->somar(new Dinheiro(100, Moeda::USD));
})->throws(InvalidArgumentException::class, 'Operação entre moedas diferentes');

// Expectativa customizada: dá nome de domínio à asserção e a reaproveita.
expect()->extend('toValerEmReais', function (string $esperado) {
    return $this->toBeInstanceOf(Dinheiro::class)
        ->and($this->value->formatado())->toBe($esperado);
});

it('formata em português', function (): void {
    expect(new Dinheiro(123456))->toValerEmReais('R$ 1.234,56');
});

Exercício 2

Escreva testes de integração para um repositório PDO usando SQLite em memória. Cubra: salvar, buscar por ID, buscar por email, listar, deletar, e verificar que FK é respeitada.

Ver resposta

✓ Resposta: A pegadinha que faz o teste de foreign key passar sem testar coisa alguma: no SQLite as FKs vêm desligadas por padrão, e o PRAGMA foreign_keys = ON vale por conexão — sem ele, o DELETE passa e o teste fica verde afirmando o contrário do que deveria. Lembre também do que o SQLite em memória não cobre: tipos, funções de data e concorrência divergem do MySQL, então o que depende disso precisa de uma suíte rodando no banco de verdade.

<?php

// ---------------------------------------- tests/Integration/BancoDeTeste.php
declare(strict_types=1);

namespace Tests\Integration;

use PDO;

/**
 * SQLite em memória: o banco nasce e morre com a conexão. Cada teste
 * começa do zero, sem migration, sem TRUNCATE e sem interferência entre
 * testes — e roda em milissegundos.
 *
 * O limite é conhecido: SQLite não é MySQL. Tipos, funções de data e
 * comportamento de concorrência divergem. Por isso o teste de contrato do
 * item seguinte roda a MESMA suíte nos dois bancos.
 */
trait BancoDeTeste
{
    protected PDO $pdo;

    protected function criarBanco(): void
    {
        $this->pdo = new PDO('sqlite::memory:', options: [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);

        // A pegadinha que faz o teste de FK passar sem testar nada: no
        // SQLite as foreign keys vêm DESLIGADAS por padrão, por conexão.
        $this->pdo->exec('PRAGMA foreign_keys = ON');

        $this->pdo->exec(<<<'SQL'
            CREATE TABLE usuarios (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                nome       TEXT NOT NULL,
                email      TEXT NOT NULL UNIQUE,
                senha_hash TEXT NOT NULL,
                criado_em  TEXT NOT NULL DEFAULT (datetime('now'))
            )
        SQL);

        $this->pdo->exec(<<<'SQL'
            CREATE TABLE pedidos (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                usuario_id INTEGER NOT NULL,
                total      INTEGER NOT NULL,
                criado_em  TEXT NOT NULL DEFAULT (datetime('now')),
                FOREIGN KEY (usuario_id) REFERENCES usuarios (id) ON DELETE RESTRICT
            )
        SQL);
    }
}

// ---------------------------------------- tests/Integration/UsuarioRepositorioTest.php
declare(strict_types=1);

namespace Tests\Integration;

use App\Repositorios\UsuarioRepositorio;
use PDOException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use RuntimeException;

#[CoversClass(UsuarioRepositorio::class)]
final class UsuarioRepositorioTest extends TestCase
{
    use BancoDeTeste;

    private UsuarioRepositorio $repositorio;

    protected function setUp(): void
    {
        $this->criarBanco();

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

    private function criarUsuario(string $email = 'ana@teste.com'): int
    {
        return $this->repositorio->criar('Ana', $email, password_hash('senha', PASSWORD_DEFAULT))->id;
    }

    public function test_salvar_e_buscar_por_id(): void
    {
        $id = $this->criarUsuario();

        $usuario = $this->repositorio->buscarPorId($id);

        $this->assertNotNull($usuario);
        $this->assertSame('Ana', $usuario->nome);
        $this->assertSame('ana@teste.com', $usuario->email);
    }

    public function test_buscar_por_email(): void
    {
        $this->criarUsuario('bruno@teste.com');

        $this->assertNotNull($this->repositorio->buscarPorEmail('bruno@teste.com'));
        $this->assertNull($this->repositorio->buscarPorEmail('ninguem@teste.com'));
    }

    public function test_id_inexistente_devolve_null(): void
    {
        // Devolver null, não lançar: "não achei" é resposta legítima de
        // uma busca. Quem decide se isso é erro é o caso de uso.
        $this->assertNull($this->repositorio->buscarPorId(9999));
    }

    public function test_email_duplicado_e_recusado(): void
    {
        $this->criarUsuario('ana@teste.com');

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessageMatches('/já cadastrado/');

        $this->criarUsuario('ana@teste.com');
    }

    public function test_listar_devolve_em_ordem(): void
    {
        foreach (['carla', 'ana', 'bruno'] as $nome) {
            $this->repositorio->criar(ucfirst($nome), "{$nome}@teste.com", 'hash');
        }

        $nomes = array_map(
            static fn (object $u): string => $u->nome,
            $this->repositorio->listar(),
        );

        $this->assertSame(['Ana', 'Bruno', 'Carla'], $nomes);
    }

    public function test_deletar(): void
    {
        $id = $this->criarUsuario();

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

        // Deletar de novo devolve false, não explode: a operação é
        // idempotente do ponto de vista de quem chama.
        $this->assertFalse($this->repositorio->deletar($id));
    }

    public function test_a_foreign_key_e_respeitada(): void
    {
        $id = $this->criarUsuario();

        $this->pdo->prepare('INSERT INTO pedidos (usuario_id, total) VALUES (?, ?)')
            ->execute([$id, 1000]);

        // ON DELETE RESTRICT: o banco recusa apagar o usuário que tem
        // pedido. É a regra que impede o histórico de vendas de sumir
        // junto com um cadastro.
        $this->expectException(PDOException::class);

        $this->pdo->prepare('DELETE FROM usuarios WHERE id = ?')->execute([$id]);
    }

    public function test_usuario_sem_pedido_pode_ser_apagado(): void
    {
        $id = $this->criarUsuario();

        $this->assertTrue($this->repositorio->deletar($id));
    }

    public function test_transacao_desfaz_tudo(): void
    {
        $this->pdo->beginTransaction();

        $this->criarUsuario('temporaria@teste.com');
        $this->assertNotNull($this->repositorio->buscarPorEmail('temporaria@teste.com'));

        $this->pdo->rollBack();

        $this->assertNull($this->repositorio->buscarPorEmail('temporaria@teste.com'));
    }
}

// ============ o mesmo teste rodando nos DOIS bancos ============
//
// SQLite é rápido, mas não é o banco de produção. O teste de contrato roda
// a mesma suíte contra os dois e pega justamente o que diverge: tipo de
// coluna, função de data, comportamento de concorrência.
//
// phpunit.xml:
//
//   <testsuites>
//     <testsuite name="unidade">     <directory>tests/Unit</directory></testsuite>
//     <testsuite name="integracao">  <directory>tests/Integration</directory></testsuite>
//     <testsuite name="feature">     <directory>tests/Feature</directory></testsuite>
//   </testsuites>
//
//   vendor/bin/phpunit --testsuite=unidade      ← segundos, roda a cada save
//   vendor/bin/phpunit                          ← tudo, antes do push

Exercício 3

Escreva testes de feature para a API de produtos: GET lista (com paginação), GET por ID, POST (autenticado e não autenticado), PUT, DELETE. Use factories para os dados.

Ver resposta

✓ Resposta: Duas asserções que a maioria dos testes de API esquece. A primeira é conferir o banco, não só a resposta: um endpoint pode devolver 201 com o Resource montado em memória e ter falhado ao gravar. A segunda é o caminho negativo com verificação de efeito — não basta afirmar 401 no POST sem token; é preciso afirmar que nada foi criado. E o teste da lista vazia parece bobo até o front-end quebrar porque a API resolveu devolver um objeto em vez de data: [].

<?php

// ---------------------------------------- tests/Feature/Api/ProdutoApiTest.php
declare(strict_types=1);

namespace Tests\Feature\Api;

use App\Models\Categoria;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

final class ProdutoApiTest extends TestCase
{
    // RefreshDatabase roda cada teste dentro de uma transação e faz
    // rollback no fim — mais rápido que recriar o schema, e garante
    // isolamento entre testes.
    use RefreshDatabase;

    // ---- GET /api/produtos ----

    public function test_lista_paginada(): void
    {
        Produto::factory()->count(25)->create();

        $this->getJson('/api/produtos')
            ->assertOk()
            ->assertJsonCount(20, 'data')          // o padrão de per_page
            ->assertJsonPath('meta.total', 25)
            ->assertJsonPath('meta.current_page', 1)
            ->assertJsonStructure([
                'data'  => [['id', 'nome', 'slug', 'preco', 'disponivel']],
                'links' => ['first', 'last', 'prev', 'next'],
                'meta'  => ['current_page', 'from', 'last_page', 'per_page', 'to', 'total'],
            ]);
    }

    public function test_segunda_pagina(): void
    {
        Produto::factory()->count(25)->create();

        $this->getJson('/api/produtos?page=2')
            ->assertOk()
            ->assertJsonCount(5, 'data')
            ->assertJsonPath('meta.current_page', 2);
    }

    public function test_filtro_por_busca(): void
    {
        Produto::factory()->create(['nome' => 'Teclado Mecânico']);
        Produto::factory()->create(['nome' => 'Mouse Vertical']);

        $this->getJson('/api/produtos?q=teclado')
            ->assertOk()
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.nome', 'Teclado Mecânico');
    }

    public function test_lista_vazia_tem_a_mesma_estrutura(): void
    {
        // O caso que quebra front-end: quando não há dados, a resposta
        // precisa continuar sendo { data: [], meta: {...} } — e não um
        // objeto vazio ou um 404.
        $this->getJson('/api/produtos')
            ->assertOk()
            ->assertJsonPath('data', [])
            ->assertJsonPath('meta.total', 0);
    }

    // ---- GET /api/produtos/{id} ----

    public function test_detalhe(): void
    {
        $produto = Produto::factory()->for(Categoria::factory())->create();

        $this->getJson("/api/produtos/{$produto->slug}")
            ->assertOk()
            ->assertJsonPath('data.id', $produto->id)
            ->assertJsonPath('data.nome', $produto->nome);
    }

    public function test_inexistente_devolve_404_em_json(): void
    {
        $this->getJson('/api/produtos/nao-existe')
            ->assertNotFound()
            ->assertJsonStructure(['mensagem']);
    }

    // ---- POST /api/produtos ----

    public function test_criar_exige_autenticacao(): void
    {
        $this->postJson('/api/produtos', ['nome' => 'X'])->assertUnauthorized();

        $this->assertDatabaseCount('produtos', 0);
    }

    public function test_criar_autenticado(): void
    {
        $categoria = Categoria::factory()->create();

        Sanctum::actingAs(Usuario::factory()->create(), ['*']);

        $this->postJson('/api/produtos', [
            'nome'         => 'Teclado Mecânico',
            'categoria_id' => $categoria->id,
            'preco'        => 349.90,
            'estoque'      => 10,
        ])
            ->assertCreated()
            ->assertJsonPath('data.nome', 'Teclado Mecânico');

        // Asserção no BANCO, não só na resposta: a API pode devolver 201
        // com um Resource montado e ter falhado ao gravar.
        $this->assertDatabaseHas('produtos', [
            'nome'  => 'Teclado Mecânico',
            'preco' => 349.90,
        ]);
    }

    #[DataProvider('camposInvalidos')]
    public function test_validacao(array $carga, string $campoComErro): void
    {
        Sanctum::actingAs(Usuario::factory()->create(), ['*']);

        $this->postJson('/api/produtos', $carga)
            ->assertStatus(422)
            ->assertJsonValidationErrors($campoComErro);
    }

    /** @return iterable<string, array{array<string, mixed>, string}> */
    public static function camposInvalidos(): iterable
    {
        yield 'sem nome'            => [['preco' => 10, 'estoque' => 1], 'nome'];
        yield 'nome curto'          => [['nome' => 'ab'], 'nome'];
        yield 'preço negativo'      => [['nome' => 'Valido', 'preco' => -1], 'preco'];
        yield 'preço não numérico'  => [['nome' => 'Valido', 'preco' => 'caro'], 'preco'];
        yield 'categoria inexistente' => [['nome' => 'Valido', 'categoria_id' => 99999], 'categoria_id'];
        yield 'estoque fracionado'  => [['nome' => 'Valido', 'estoque' => 1.5], 'estoque'];
    }

    // ---- PUT /api/produtos/{id} ----

    public function test_atualizar(): void
    {
        $produto = Produto::factory()->create(['nome' => 'Antigo', 'preco' => 100]);

        Sanctum::actingAs(Usuario::factory()->create(), ['*']);

        $this->putJson("/api/produtos/{$produto->slug}", [
            'nome'         => 'Novo Nome',
            'categoria_id' => $produto->categoria_id,
            'preco'        => 150.0,
            'estoque'      => $produto->estoque,
        ])
            ->assertOk()
            ->assertJsonPath('data.nome', 'Novo Nome');

        $this->assertDatabaseHas('produtos', ['id' => $produto->id, 'nome' => 'Novo Nome']);
    }

    public function test_atualizar_exige_autenticacao(): void
    {
        $produto = Produto::factory()->create(['nome' => 'Original']);

        $this->putJson("/api/produtos/{$produto->slug}", ['nome' => 'Invadido'])
            ->assertUnauthorized();

        $this->assertDatabaseHas('produtos', ['nome' => 'Original']);
    }

    // ---- DELETE /api/produtos/{id} ----

    public function test_excluir(): void
    {
        $produto = Produto::factory()->create();

        $admin = Usuario::factory()->create();
        $admin->assignRole('admin');

        Sanctum::actingAs($admin, ['*']);

        $this->deleteJson("/api/produtos/{$produto->slug}")->assertNoContent();

        // Soft delete: sai das consultas, continua na tabela.
        $this->assertSoftDeleted('produtos', ['id' => $produto->id]);
    }

    public function test_usuario_comum_nao_exclui(): void
    {
        $produto = Produto::factory()->create();

        Sanctum::actingAs(Usuario::factory()->create(), ['*']);

        $this->deleteJson("/api/produtos/{$produto->slug}")->assertForbidden();

        $this->assertNotSoftDeleted('produtos', ['id' => $produto->id]);
    }

    public function test_a_habilidade_do_token_limita_mesmo_o_admin(): void
    {
        $admin = Usuario::factory()->create();
        $admin->assignRole('admin');

        // Token de leitura numa conta de administrador: a conta pode, o
        // token não. É a diferença entre autorização e escopo.
        Sanctum::actingAs($admin, ['produtos:ler']);

        $this->postJson('/api/produtos', ['nome' => 'X'])->assertForbidden();
    }
}

Exercício 4

Configure o CI para rodar os testes com cobertura mínima de 70% — o build deve falhar abaixo disso.

Ver resposta

✓ Resposta: PCOV em vez de Xdebug para medir cobertura na CI: cerca de cinco vezes mais rápido, e o Xdebug deixa a suíte lenta mesmo sem breakpoint. Mas o ponto que importa é outro: cobertura mede linha executada, não comportamento verificado — um teste que chama o método e não faz nenhuma asserção dá 100% naquele trecho. Daí a catraca (o piso sobe com o projeto e nunca desce) valer mais que um número alto fixado de uma vez, e daí existir o mutation testing do próximo item.

# ============ phpunit.xml ============
# <?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"
#          failOnWarning="true"
#          failOnRisky="true"
#          failOnDeprecation="true">
#
#     <testsuites>
#         <testsuite name="unidade">    <directory>tests/Unit</directory></testsuite>
#         <testsuite name="integracao"> <directory>tests/Integration</directory></testsuite>
#         <testsuite name="feature">    <directory>tests/Feature</directory></testsuite>
#     </testsuites>
#
#     <source>
#         <include><directory>app</directory></include>
#         <exclude>
#             <!-- Excluir o que é configuração, não lógica: contar
#                  Provider e Migration infla a cobertura sem testar nada. -->
#             <directory>app/Providers</directory>
#             <file>app/Console/Kernel.php</file>
#         </exclude>
#     </source>
#
#     <coverage>
#         <report>
#             <html outputDirectory="build/coverage"/>
#             <clover outputFile="build/logs/clover.xml"/>
#             <text outputFile="php://stdout" showOnlySummary="true"/>
#         </report>
#     </coverage>
#
#     <php>
#         <env name="APP_ENV" value="testing"/>
#         <env name="DB_CONNECTION" value="sqlite"/>
#         <env name="DB_DATABASE" value=":memory:"/>
#         <env name="CACHE_STORE" value="array"/>
#         <env name="QUEUE_CONNECTION" value="sync"/>
#         <env name="MAIL_MAILER" value="array"/>
#     </php>
# </phpunit>

# ============ .github/workflows/testes.yml ============
name: Testes

on: [push, pull_request]

jobs:
  testes:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_sqlite, intl
          # PCOV é ~5x mais rápido que Xdebug para MEDIR cobertura. Xdebug
          # só se você também precisar de depuração passo a passo — e ele
          # deixa a suíte inteira mais lenta, mesmo sem breakpoint.
          coverage: pcov

      - name: Cache do Composer
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('**/composer.lock') }}

      - run: composer install --no-interaction --no-progress --prefer-dist

      - name: Testes com limite de cobertura
        # --min é o portão: abaixo de 70%, código de saída ≠ 0 e o job cai.
        # (No PHPUnit puro: --coverage-text e a checagem via
        #  php-coverage-checker, ou o --min do Pest, como aqui.)
        run: vendor/bin/pest --coverage --min=70 --coverage-clover=build/logs/clover.xml

      - name: Comentar a cobertura no PR
        if: github.event_name == 'pull_request'
        uses: lucassabreu/comment-coverage-clover@main
        with:
          file: build/logs/clover.xml

      - name: Guardar o relatório HTML
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: cobertura
          path: build/coverage

# ============ subindo o piso sem parar o time ============
#
# Fixar 70% de uma vez num projeto que está em 30% trava todo mundo. O que
# funciona é a catraca: o piso sobe junto com a cobertura e NUNCA desce.
#
#   # Makefile
#   PISO := 70
#
#   cobertura:
#   	vendor/bin/pest --coverage --min=$(PISO)
#
# Cada PR que sobe a cobertura sobe também o PISO no arquivo — a linha
# alterada aparece no diff e vira parte da revisão.

# ============ o que a porcentagem NÃO diz ============
#
# Cobertura mede LINHAS EXECUTADAS, não comportamento verificado. Este
# teste dá 100% de cobertura no método e não testa absolutamente nada:
#
#   public function test_calcula(): void
#   {
#       $this->calculadora->somar(2, 2);      // sem nenhum assert
#       $this->assertTrue(true);
#   }
#
# Por isso o item seguinte (mutation testing) existe: ele mede se os testes
# DETECTAM mudanças no código, que é a pergunta que a cobertura não faz.
# 70% com asserções boas vale mais que 95% de linhas encostadas.

Exercício 5

Desafio: implemente mutation testing com Infection PHP (infection/infection). Analise os mutantes sobreviventes — eles indicam testes que verificam execução mas não resultados. Corrija pelo menos 5.

Ver resposta

✓ Resposta: Os cinco mutantes contam sempre a mesma história: os testes verificavam execução, não resultado. O padrão mais frequente é a fronteira — testar 150 e 50, mas nunca exatamente 100, e por isso não distinguir > de >=. Depois vêm o caminho do valor padrão (?? 2500 que ninguém aciona), o argumento opcional nunca omitido, e o assertGreaterThan(0) que aceita 8 e 12 igualmente. Rode com --git-diff-filter no dia a dia: a análise completa roda a suíte inteira uma vez por mutante.

<?php

// ============ no terminal ============
// composer require --dev infection/infection
// vendor/bin/infection --threads=max --min-msi=60 --min-covered-msi=70
//
// Como funciona: o Infection ALTERA seu código (troca > por >=, true por
// false, remove uma linha) e roda a suíte. Se os testes continuam verdes
// com o código alterado, o mutante SOBREVIVEU — e isso significa que
// aquele trecho não está sendo verificado de verdade.
//
//   MSI (Mutation Score Indicator) = mutantes mortos / mutantes gerados
//
// É a única métrica que responde "meus testes detectariam um bug aqui?".
// Cobertura responde só "esta linha rodou?".

// ============ infection.json5 ============
// {
//     "source": { "directories": ["app"], "excludes": ["Providers"] },
//     "logs": {
//         "text": "build/infection.log",
//         "html": "build/infection.html",
//         // Só o que o PR mudou — a análise completa é lenta demais para
//         // rodar a cada push.
//         "summary": "build/infection-summary.log"
//     },
//     "mutators": {
//         "@default": true,
//         // Trocar '' por 'x' em mensagem de log gera mutante que nenhum
//         // teste razoável mata. Desligar reduz o ruído.
//         "IncrementInteger": false
//     },
//     "minMsi": 60,
//     "minCoveredMsi": 70
// }

// ---------- CINCO MUTANTES QUE SOBREVIVERAM, E A CORREÇÃO DE CADA UM ----

declare(strict_types=1);

namespace App\Precos;

final class CalculadoraDeFrete
{
    // MUTANTE 1 — GreaterThan: `>` virou `>=`, testes continuaram verdes.
    //
    // O teste original passava 150 e 50 e conferia o resultado. Nenhum
    // deles usava exatamente 100 — a FRONTEIRA. Toda condição tem três
    // casos: abaixo, exatamente, acima; e os testes tinham dois.
    public function freteGratis(int $totalEmCentavos): bool
    {
        return $totalEmCentavos > 10_000;
    }

    // MUTANTE 2 — Coalesce: o `?? 0` foi removido e nada quebrou, porque
    // nenhum teste passava região desconhecida.
    public function taxaPorRegiao(string $uf): int
    {
        $tabela = ['SP' => 1500, 'RJ' => 1800, 'MG' => 1700];

        return $tabela[$uf] ?? 2500;
    }

    // MUTANTE 3 — Decrement: `- 1` virou `+ 1` sem matar nenhum teste,
    // porque só se verificava "o prazo é um número positivo".
    public function prazoEmDias(int $prazoBase, bool $expresso): int
    {
        return $expresso ? max(1, $prazoBase - 2) : $prazoBase;
    }

    // MUTANTE 4 — TrueValue: o `true` do parâmetro padrão virou `false`.
    // Nenhum teste chamava o método SEM o segundo argumento.
    public function calcular(int $pesoGramas, bool $incluirSeguro = true): int
    {
        $base = (int) ceil($pesoGramas / 1000) * 800;

        return $incluirSeguro ? $base + 500 : $base;
    }

    // MUTANTE 5 — MethodCallRemoval: a linha do arredondamento foi
    // REMOVIDA e os testes passaram, porque todos usavam valores que já
    // eram redondos.
    public function converterParaReais(int $centavos): float
    {
        return round($centavos / 100, 2);
    }
}

// ---------------------------------------- tests/Unit/CalculadoraDeFreteTest.php
declare(strict_types=1);

namespace Tests\Unit;

use App\Precos\CalculadoraDeFrete;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CalculadoraDeFreteTest extends TestCase
{
    private CalculadoraDeFrete $frete;

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

    /**
     * MATA O MUTANTE 1 — a fronteira exata.
     *
     * Com `>`, 10.000 é false. Com `>=` (o mutante), seria true. O caso
     * 'exatamente no limite' é o único que distingue os dois, e é
     * justamente o que faltava.
     */
    #[DataProvider('totais')]
    public function test_frete_gratis_na_fronteira(int $total, bool $esperado): void
    {
        $this->assertSame($esperado, $this->frete->freteGratis($total));
    }

    /** @return iterable<string, array{int, bool}> */
    public static function totais(): iterable
    {
        yield 'abaixo do limite'      => [ 9_999, false];
        yield 'exatamente no limite'  => [10_000, false];   // ← mata o mutante
        yield 'um centavo acima'      => [10_001, true];    // ← e este também
        yield 'bem acima'             => [50_000, true];
        yield 'zero'                  => [     0, false];
    }

    /** MATA O MUTANTE 2 — o caminho do valor padrão. */
    public function test_regiao_conhecida_e_desconhecida(): void
    {
        $this->assertSame(1500, $this->frete->taxaPorRegiao('SP'));

        // Sem esta linha, remover o `?? 2500` não quebra nada.
        $this->assertSame(2500, $this->frete->taxaPorRegiao('AC'));
        $this->assertSame(2500, $this->frete->taxaPorRegiao(''));
    }

    /** MATA O MUTANTE 3 — o valor exato, não "um número positivo". */
    public function test_prazo_expresso_desconta_dois_dias(): void
    {
        // 10 - 2 = 8. Com o mutante (+2), daria 12 — e um assertGreaterThan(0)
        // aceitaria os dois.
        $this->assertSame(8, $this->frete->prazoEmDias(10, expresso: true));
        $this->assertSame(10, $this->frete->prazoEmDias(10, expresso: false));

        // E o piso: com prazo base 1, o expresso não pode virar -1.
        $this->assertSame(1, $this->frete->prazoEmDias(1, expresso: true));
    }

    /** MATA O MUTANTE 4 — chamar SEM o argumento opcional. */
    public function test_o_seguro_vem_ligado_por_padrao(): void
    {
        // 1000g → 800 de base + 500 de seguro.
        $this->assertSame(1300, $this->frete->calcular(1000));

        // E os dois explícitos, para o padrão não ser a única prova.
        $this->assertSame(1300, $this->frete->calcular(1000, incluirSeguro: true));
        $this->assertSame(800, $this->frete->calcular(1000, incluirSeguro: false));
    }

    /** MATA O MUTANTE 5 — um valor que EXIGE o arredondamento. */
    public function test_conversao_arredonda(): void
    {
        // 3333 / 100 = 33.33 — este passa com ou sem o round().
        $this->assertSame(33.33, $this->frete->converterParaReais(3333));

        // Este NÃO: sem o round(), 1 / 100 * ... produz dízima em alguns
        // casos e o assertSame de float falha. É o caso que mata o mutante.
        $this->assertSame(0.01, $this->frete->converterParaReais(1));
        $this->assertSame(1.0, $this->frete->converterParaReais(100));
    }
}

// ============ lendo o relatório ============
//
//   vendor/bin/infection --threads=max
//
//   3421 mutations were generated:
//      2874 mutants were killed          ← testes detectaram
//       112 mutants were not covered      ← linha sem nenhum teste
//       389 covered mutants were not detected  ← O QUE IMPORTA
//        46 errors were encountered
//
//   Metrics:
//     Mutation Score Indicator (MSI): 84%
//     Mutation Code Coverage: 96%
//     Covered Code MSI: 88%
//
// "Covered mutants were not detected" é a lista de ouro: são trechos COM
// teste, cujos testes não verificam o resultado. Cada linha dessa lista é
// um bug que passaria despercebido.
//
// ============ como usar isso sem enlouquecer ============
//
// A análise completa é lenta (roda a suíte uma vez por mutante). No dia a
// dia, só o que mudou:
//
//   vendor/bin/infection --git-diff-filter=AM --git-diff-base=origin/main
//
// E na CI: MSI mínimo só no código NOVO. Exigir 80% de MSI no projeto
// inteiro de uma vez é o caminho mais curto para o time desinstalar a
// ferramenta na segunda semana.
Comentários

Mais em PHP

Observer, Decorator e Strategy
Observer, Decorator e Strategy

Três padrões que aparecem em praticamente qualquer sistema: o Observer, que…

Orientação a Objetos: Fundamentos
Orientação a Objetos: Fundamentos

Uma classe define o molde; cada objeto guarda o próprio estado. Construtor…

APIs REST com PHP
APIs REST com PHP

Um roteador que traduz {param} em regex, um envelope JSON que o cliente…