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>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<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);
$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);
$nomes = array_column(array_map(fn($p) => ['nome' => $p->nome], $ativos), 'nome');
$this->assertContains('Produto A', $nomes);
$this->assertContains('Produto B', $nomes);
$this->assertNotContains('Inativo', $nomes);
}
}
Resumo do artigo
| Conceito | O que aprendemos |
|---|---|
| TestCase | Classe base do PHPUnit — cada método test_* é um teste independente |
| setUp() | Executado antes de cada teste — prepara estado comum |
| tearDown() | Executado após cada teste — limpa recursos |
| Assertions | assertSame, assertTrue, assertNull, assertCount, expectException |
| Data Provider | #[DataProvider] — testa múltiplos cenários sem duplicar código |
| Mock | createMock() — substitui dependências por objetos controlados |
expects() |
Verifica quantas vezes e com quais argumentos um método do mock foi chamado |
| Teste de integração | SQLite em memória — banco real, sem efeito colateral entre testes |
Exercício da semana
-
Escreva testes unitários para a entidade
Carrinho: métodosadicionar(Produto, int),remover(int $produtoId),calcularTotal(): floateestaVazio(): bool. Use Data Providers para testarcalcularTotal()com múltiplos produtos. -
Crie um mock do
ServicoJWTpara testar oAuthControllersem 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. -
Adicione cobertura de testes para o
QueryBuilderconstruído no Artigo 21. Teste cada método (where,whereIn,orderBy,limit,offset) verificando o SQL gerado porbuild(). -
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). Executerollback()e verifique que as tabelas foram removidas. -
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ódulosDomaineService.
Referências
- 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