Como escrever testes unitários eficazes em PHP

1. Fundamentos dos testes unitários em PHP

Testes unitários são a base da qualidade de software no ecossistema PHP. Eles verificam o comportamento de unidades individuais de código — geralmente métodos de classes — de forma isolada e repetível. No contexto da série Temas — Lista Final (1200 temas), dominar essa prática é essencial para qualquer desenvolvedor que deseja entregar código confiável e sustentável.

A principal diferença entre os tipos de teste é clara:
- Testes unitários: verificam uma única unidade de código, sem dependências externas (banco de dados, APIs, sistema de arquivos)
- Testes de integração: validam a interação entre múltiplas unidades ou com sistemas externos
- Testes funcionais: simulam o fluxo completo de uma funcionalidade do início ao fim

O PHPUnit é o framework padrão da indústria PHP, mantido por Sebastian Bergmann e amplamente adotado por frameworks como Laravel, Symfony e WordPress.

2. Configuração inicial do ambiente de testes

A instalação via Composer é simples:

composer require --dev phpunit/phpunit

A estrutura de diretórios recomendada segue o padrão PSR-4:

projeto/
├── src/
│   └── App/
│       └── Calculator.php
├── tests/
│   ├── Unit/
│   │   └── CalculatorTest.php
│   └── Feature/
└── phpunit.xml.dist

O arquivo de configuração phpunit.xml.dist permite personalizar a execução:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         verbose="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory>src</directory>
        </include>
    </coverage>
</phpunit>

3. Escrevendo o primeiro teste unitário eficaz

A anatomia de uma classe de teste segue um padrão consistente. Suponha que temos uma calculadora simples em src/App/Calculator.php:

<?php

namespace App;

class Calculator
{
    public function add(float $a, float $b): float
    {
        return $a + $b;
    }

    public function divide(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new \InvalidArgumentException('Division by zero');
        }
        return $a / $b;
    }
}

O teste correspondente em tests/Unit/CalculatorTest.php:

<?php

namespace Tests\Unit;

use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private Calculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new Calculator();
    }

    public function testAddReturnsCorrectSum(): void
    {
        $result = $this->calculator->add(2, 3);
        $this->assertEquals(5, $result);
    }

    public function testAddWithNegativeNumbers(): void
    {
        $result = $this->calculator->add(-1, 5);
        $this->assertEquals(4, $result);
    }

    public function testDivideByZeroThrowsException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero');
        $this->calculator->divide(10, 0);
    }
}

A nomenclatura clara dos métodos (usando test prefix ou o atributo #[Test] do PHPUnit 10+) e o uso de assertions específicas (assertEquals, assertTrue, assertCount, assertInstanceOf) tornam os testes legíveis e auto-documentados.

4. Isolamento e mocks com PHPUnit

Isolar dependências é crucial para testes unitários verdadeiros. Considere uma classe que depende de um serviço externo:

<?php

namespace App;

class OrderProcessor
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}

    public function process(Order $order): bool
    {
        if ($order->getTotal() <= 0) {
            throw new \InvalidArgumentException('Invalid order total');
        }
        return $this->gateway->charge($order->getTotal());
    }
}

O teste usa mocks para isolar o PaymentGateway:

<?php

namespace Tests\Unit;

use App\Order;
use App\OrderProcessor;
use App\PaymentGateway;
use PHPUnit\Framework\TestCase;

class OrderProcessorTest extends TestCase
{
    public function testProcessChargesCorrectAmount(): void
    {
        $order = $this->createMock(Order::class);
        $order->method('getTotal')->willReturn(100.0);

        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->expects($this->once())
                ->method('charge')
                ->with(100.0)
                ->willReturn(true);

        $processor = new OrderProcessor($gateway);
        $result = $processor->process($order);

        $this->assertTrue($result);
    }

    public function testProcessThrowsExceptionForInvalidOrder(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $order = $this->createMock(Order::class);
        $order->method('getTotal')->willReturn(0);

        $gateway = $this->createMock(PaymentGateway::class);

        $processor = new OrderProcessor($gateway);
        $processor->process($order);
    }
}

O uso de expects, willReturn e with permite verificar interações específicas, garantindo que o método charge seja chamado exatamente uma vez com o valor correto.

5. Dados de teste e fixtures inteligentes

Data providers eliminam duplicação e testam múltiplos cenários:

<?php

namespace Tests\Unit;

use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorDataProviderTest extends TestCase
{
    /** @dataProvider additionProvider */
    public function testAddWithMultipleScenarios(float $a, float $b, float $expected): void
    {
        $calculator = new Calculator();
        $result = $calculator->add($a, $b);
        $this->assertEquals($expected, $result);
    }

    public static function additionProvider(): array
    {
        return [
            'positive numbers'    => [2, 3, 5],
            'negative numbers'    => [-1, -2, -3],
            'mixed signs'         => [10, -5, 5],
            'decimal numbers'     => [0.1, 0.2, 0.3],
            'zero'                => [0, 0, 0],
        ];
    }
}

Fixtures reutilizáveis no setUp() e o uso de traits evitam repetição. É fundamental evitar dependências entre testes — cada teste deve ser independente e executável em qualquer ordem.

6. Cobertura de código e métricas

Para gerar relatórios de cobertura, instale o Xdebug ou PCOV:

composer require --dev pcov/clobber

Execute com cobertura:

vendor/bin/phpunit --coverage-html coverage

As métricas importantes incluem:
- Cobertura de linhas: percentual de linhas executadas
- Cobertura de branches: percentual de caminhos condicionais testados
- Cobertura de métodos: percentual de métodos chamados durante os testes

Metas realistas giram em torno de 70-80% de cobertura de linhas. Valores próximos de 100% podem indicar testes frágeis ou foco excessivo em código trivial (getters/setters).

7. Boas práticas e padrões em testes PHP

A regra de ouro: teste comportamento, não implementação. Um teste que verifica detalhes internos (como chamadas privadas ou ordem específica de execução) quebra quando o código é refatorado, mesmo que o comportamento permaneça correto.

Evite testes frágeis:
- Não teste métodos privados diretamente — teste-os através de métodos públicos
- Não dependa de ordem de execução entre testes
- Use mocks para dependências lentas ou instáveis

Testes bem escritos permitem refatoração segura. Se você precisa alterar a implementação interna de uma classe, mas os testes continuam passando, você tem confiança de que não quebrou nada.

8. Integração contínua e automação

No GitHub Actions, um workflow básico para testes PHP:

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.2'
        coverage: pcov

    - name: Install dependencies
      run: composer install --prefer-dist --no-progress

    - name: Run tests
      run: vendor/bin/phpunit --coverage-clover coverage.xml

    - name: Check coverage
      run: |
        COVERAGE=$(php -r "echo (float)simplexml_load_file('coverage.xml')['coverage'];")
        if (( $(echo "$COVERAGE < 70" | bc -l) )); then
          echo "Coverage $COVERAGE% is below 70% threshold"
          exit 1
        fi

O cache de dependências e a paralelização de testes aceleram a execução em projetos maiores. Bloquear merges quando a cobertura cai abaixo do limite estabelecido mantém a qualidade do código ao longo do tempo.


Escrever testes unitários eficazes em PHP não é apenas uma prática técnica — é um investimento na saúde do projeto a longo prazo. Com PHPUnit, mocks inteligentes, data providers e integração contínua, você constrói uma base sólida que permite evolução segura e confiável do código.

Referências

  • PHPUnit Documentation — Documentação oficial completa do PHPUnit, incluindo guias de instalação, configuração e todos os tipos de assertions disponíveis
  • PHP: The Right Way - Testing — Seção sobre testes no guia de boas práticas PHP, com recomendações de ferramentas e padrões
  • Mocking with PHPUnit — Guia oficial sobre criação de stubs, mocks e verificação de interações com PHPUnit
  • Code Coverage Analysis — Documentação oficial sobre como configurar e interpretar relatórios de cobertura de código no PHPUnit
  • GitHub Actions for PHP — Action oficial para configurar PHP no GitHub Actions, com suporte a extensões e ferramentas de cobertura
  • Test-Driven Development with PHPUnit — Tutorial prático sobre TDD aplicado com PHPUnit, abordando ciclo red-green-refactor
  • PHPUnit Best Practices — Artigo da PHP Architect com dicas avançadas para escrever testes mais limpos e eficientes