Artigo 47 — Testes Automatizados com PHPUnit e Pest
Módulo 9 · Semana 47 · Nível: Intermediário–Avançado
Introdução
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 --init # cria phpunit.xml
<?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 mesmo schema de produção
$this->pdo->exec(file_get_contents(__DIR__ . '/../../database/schema.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']],
'meta' => ['total', 'por_pagina', 'pagina'],
])
->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%
Resumo
| Tipo de teste | O que testa | Ferramenta |
|---|---|---|
| Unit | Lógica de uma classe isolada | PHPUnit / Pest |
| Integration | Repositório + banco de dados real | PHPUnit + SQLite |
| Feature | Fluxo HTTP completo (rota → controller → banco) | Laravel + Pest |
| Mock | Simula dependências externas | createMock() / Mockery |
| Data Provider | Mesma lógica com múltiplos inputs | #[DataProvider] / ->with() |
Exercício da semana
-
Escreva testes unitários para a classe
Dinheirocobrindo: criação válida, soma, subtração, multiplicação, desconto, igualdade, formatação, e todos os casos de erro. -
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.
-
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.
-
Configure o CI para rodar os testes com cobertura mínima de 70% — o build deve falhar abaixo disso.
-
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.
Referências
- 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/