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
- PHPUnit Documentation: https://phpunit.de/documentation.html
- Pest PHP: https://pestphp.com/
- Laravel Testing: https://laravel.com/docs/testing
- Infection — Mutation Testing: https://infection.github.io/
- Pest — expectativas encadeadas, a API que mais difere do PHPUnit: https://pestphp.com/docs/expectations
- Mockery — dublês de teste com sintaxe mais legivel que a nativa do PHPUnit: https://docs.mockery.io/
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.