PHP

Artigo 47 — Testes Automatizados com PHPUnit e Pest Já leu

6 min de leitura

Artigo 47 — Testes Automatizados com PHPUnit e Pest
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

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

  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.

  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.

  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.

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

  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.


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/

Comentários

Mais em PHP

Strings em Profundidade
Strings em Profundidade

Strings s&atilde;o o tipo de dado mais presente em qualquer aplica&ccedil;&at...

Domain-Driven Design
Domain-Driven Design

Domain-Driven Design (DDD) é uma abordagem para desenvolver software complexo...

Artigo 46 — Segurança Avançada em PHP
Artigo 46 — Segurança Avançada em PHP

O artigo 37 cobriu os vetores mais comuns — XSS, CSRF e SQL Injection. Segura...