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
- PHPUnit — Documentação oficial: https://phpunit.de/documentation.html
- PHPUnit — Test Doubles (Mocks): https://docs.phpunit.de/en/11.5/test-doubles.html
- PHP Manual — PDO SQLite: https://www.php.net/manual/pt_BR/ref.pdo-sqlite.php
- Martin Fowler — Mocks Aren't Stubs: https://martinfowler.com/articles/mocksArentStubs.html
- PHPUnit — fixtures e o ciclo
setUp/tearDown: https://docs.phpunit.de/en/11.5/fixtures.html - PHPUnit — atributos, incluindo
#[DataProvider]e#[Depends]: https://docs.phpunit.de/en/11.5/attributes.html
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>.