Testes de integração são essenciais para garantir que os diferentes módulos de uma aplicação PHP funcionem corretamente juntos. Diferentemente dos testes unitários, que testam unidades isoladas, os testes de integração verificam a interação entre componentes, como a comunicação com o banco de dados, APIs externas ou serviços internos. Nesta aula, vamos explorar como configurar e escrever testes de integração eficazes, cobrindo desde a preparação do ambiente até a integração contínua.

Vamos utilizar o PHPUnit, o framework de testes mais popular para PHP, e o Composer para gerenciar as dependências. Ao final, você terá uma base sólida para implementar testes de integração em seus projetos, garantindo maior confiabilidade e facilitando a manutenção do código.

Banco de teste

Para testar a integração com o banco de dados, é fundamental que os testes não interfiram nos dados de produção. A melhor prática é utilizar um banco de dados separado, específico para testes, que possa ser recriado a cada execução. Isso garante que os testes sejam determinísticos e não dependam de dados existentes.

No PHPUnit, podemos configurar a conexão com o banco de teste no arquivo de configuração phpunit.xml. Além disso, é comum usar bibliotecas como Doctrine DBAL ou PDO para gerenciar a conexão. Uma abordagem eficaz é criar um trait ou classe base que configure o banco antes de cada teste, executando migrações e populando dados iniciais.

Aqui está um exemplo de como configurar um banco de teste com PHPUnit e PDO:

// phpunit.xml
<phpunit bootstrap="vendor/autoload.php">
    <testsuites>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="DB_HOST" value="localhost"/>
        <env name="DB_NAME" value="test_db"/>
        <env name="DB_USER" value="root"/>
        <env name="DB_PASS" value=""/>
    </php>
</phpunit>
// tests/Integration/BaseTestCase.php
namespace Tests\Integration;

use PHPUnit\Framework\TestCase;

abstract class BaseTestCase extends TestCase
{
    protected \PDO $pdo;

    protected function setUp(): void
    {
        parent::setUp();
        $this->pdo = new \PDO(
            'mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'),
            getenv('DB_USER'),
            getenv('DB_PASS')
        );
        $this->pdo->exec('CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))');
        $this->pdo->exec('TRUNCATE TABLE users');
    }
}

Fixtures

Fixtures são dados de teste pré-definidos que são carregados no banco antes da execução dos testes. Eles garantem que os testes tenham um estado conhecido, facilitando a verificação dos resultados. Existem várias formas de criar fixtures, desde arrays em código até arquivos SQL ou bibliotecas como Faker para gerar dados realistas.

No PHPUnit, podemos criar fixtures no método setUp() ou usar traits específicas. Uma prática comum é ter um arquivo SQL com os dados iniciais e executá-lo antes de cada teste. Outra abordagem é usar o Fixture do Doctrine, mas vamos mostrar um exemplo simples com PDO.

// tests/Integration/UserRepositoryTest.php
namespace Tests\Integration;

class UserRepositoryTest extends BaseTestCase
{
    public function testFindByNameReturnsUser(): void
    {
        // Fixture: inserir um usuário diretamente
        $this->pdo->exec("INSERT INTO users (name) VALUES ('Alice')");

        // Suponha que temos um repositório
        $repo = new \App\Repository\UserRepository($this->pdo);
        $user = $repo->findByName('Alice');

        $this->assertNotNull($user);
        $this->assertEquals('Alice', $user['name']);
    }
}

Você também pode usar uma biblioteca como Faker para criar dados mais variados, mas é importante que os fixtures sejam estáveis e não gerem dados aleatórios que possam quebrar os testes.

Isolamento

O isolamento dos testes de integração é crucial para que eles não interfiram uns nos outros. Isso significa que cada teste deve rodar em um ambiente limpo, sem depender de dados criados por testes anteriores. Para conseguir isso, é necessário garantir que o banco de dados seja restaurado ao estado inicial antes de cada teste.

Uma técnica comum é usar transações: iniciar uma transação no setUp() e fazer rollback no tearDown(). Dessa forma, as alterações feitas durante o teste são desfeitas automaticamente, garantindo o isolamento.

// Exemplo com transações
abstract class BaseTestCase extends TestCase
{
    protected \PDO $pdo;

    protected function setUp(): void
    {
        parent::setUp();
        $this->pdo = new \PDO(...);
        $this->pdo->beginTransaction();
    }

    protected function tearDown(): void
    {
        $this->pdo->rollBack();
        parent::tearDown();
    }
}

Outra alternativa é recriar o banco de dados a cada teste, mas isso pode ser mais lento. A escolha depende do tamanho do banco e da frequência dos testes. Além disso, é importante que os testes não dependam da ordem de execução, o que pode ser garantido com isolamento adequado.

CI

Integração contínua (CI) é a prática de executar testes automaticamente sempre que há uma alteração no código. Isso ajuda a detectar erros rapidamente e garante que o código esteja sempre em um estado funcional. Para testes de integração, é essencial que o ambiente de CI tenha um banco de dados configurado, geralmente usando serviços como MySQL, PostgreSQL ou SQLite.

No GitHub Actions, por exemplo, podemos criar um workflow que instala as dependências, configura o banco e executa os testes. Veja um exemplo:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: test_db
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: actions/checkout@v3
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - run: composer install
      - run: php vendor/bin/phpunit --testsuite Integration
        env:
          DB_HOST: 127.0.0.1
          DB_NAME: test_db
          DB_USER: root
          DB_PASS: root

Outras ferramentas como GitLab CI, Travis CI ou Jenkins também podem ser usadas de forma similar. O importante é que o banco de teste seja criado e configurado antes de rodar os testes, e que as credenciais sejam passadas via variáveis de ambiente.

Boas práticas e observações finais

Algumas boas práticas para testes de integração incluem: manter os testes rápidos, evitando operações desnecessárias; usar nomes descritivos para os testes; e garantir que eles sejam independentes e reproduzíveis. Além disso, é recomendável rodar os testes de integração em um ambiente separado do de desenvolvimento, para evitar conflitos.

Lembre-se de que testes de integração não substituem testes unitários; eles complementam. Enquanto os unitários verificam a lógica interna, os de integração garantem que os componentes funcionam juntos. Uma boa estratégia é ter uma pirâmide de testes, com muitos testes unitários, alguns de integração e poucos testes de ponta a ponta.

Referências

Exercícios

  1. Escreva um teste de integração para uma classe UserRepository que tenha um método save(User $user) e findById($id). Use um banco SQLite em memória e crie a tabela no setUp().
  2. ✓ Resposta:
    use PHPUnit\Framework\TestCase;
    use App\Repository\UserRepository;
    use App\Model\User;
    
    class UserRepositoryTest extends TestCase
    {
        private \PDO $pdo;
        private UserRepository $repo;
    
        protected function setUp(): void
        {
            $this->pdo = new \PDO('sqlite::memory:');
            $this->pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
            $this->repo = new UserRepository($this->pdo);
        }
    
        public function testSaveAndFindById()
        {
            $user = new User('Alice');
            $this->repo->save($user);
            $found = $this->repo->findById($user->getId());
            $this->assertEquals('Alice', $found->getName());
        }
    }
  3. Configure um banco de teste MySQL para um projeto usando variáveis de ambiente no phpunit.xml. Escreva um teste que insira e consulte um registro.
  4. ✓ Resposta:
    // phpunit.xml
    <phpunit bootstrap="vendor/autoload.php">
        <testsuites>
            <directory>tests/Integration</directory>
        </testsuites>
        <php>
            <env name="DB_HOST" value="localhost"/>
            <env name="DB_NAME" value="test_db"/>
            <env name="DB_USER" value="root"/>
            <env name="DB_PASS" value="secret"/>
        </php>
    </phpunit>
    
    // Teste
    class DatabaseTest extends TestCase
    {
        public function testInsertAndQuery()
        {
            $pdo = new \PDO('mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'), getenv('DB_USER'), getenv('DB_PASS'));
            $pdo->exec('CREATE TABLE IF NOT EXISTS test (id INT AUTO_INCREMENT PRIMARY KEY, value VARCHAR(255))');
            $pdo->exec("INSERT INTO test (value) VALUES ('hello')");
            $stmt = $pdo->query('SELECT * FROM test');
            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
            $this->assertEquals('hello', $row['value']);
        }
    }
  5. Crie uma fixture usando a biblioteca Faker para gerar 10 usuários e teste um método que conta o total de usuários.
  6. ✓ Resposta:
    use Faker\Factory as Faker;
    
    public function testCountUsers()
    {
        $faker = Faker::create();
        for ($i = 0; $i < 10; $i++) {
            $name = $faker->name;
            $this->pdo->exec("INSERT INTO users (name) VALUES ('$name')");
        }
        $count = $this->repo->countAll();
        $this->assertEquals(10, $count);
    }
  7. Implemente o isolamento dos testes usando transações. Escreva um teste que altere um registro e verifique que a alteração é revertida após o teste.
  8. ✓ Resposta:
    class BaseTestCase extends TestCase
    {
        protected \PDO $pdo;
    
        protected function setUp(): void
        {
            parent::setUp();
            $this->pdo = new \PDO(...);
            $this->pdo->beginTransaction();
        }
    
        protected function tearDown(): void
        {
            $this->pdo->rollBack();
            parent::tearDown();
        }
    }
    
    class UserTest extends BaseTestCase
    {
        public function testUpdateUser()
        {
            $this->pdo->exec("INSERT INTO users (name) VALUES ('Original')");
            $this->pdo->exec("UPDATE users SET name = 'Changed' WHERE name = 'Original'");
            // Após o teste, a alteração é revertida.
        }
    }
  9. Configure um pipeline de CI no GitHub Actions para um projeto PHP que execute testes de integração com MySQL. Inclua as etapas de instalação, configuração do banco e execução dos testes.
  10. ✓ Resposta:
    name: CI
    on: [push, pull_request]
    jobs:
      test:
        runs-on: ubuntu-latest
        services:
          mysql:
            image: mysql:8.0
            env:
              MYSQL_DATABASE: test_db
              MYSQL_ROOT_PASSWORD: root
            ports:
              - 3306:3306
            options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
        steps:
          - uses: actions/checkout@v3
          - uses: shivammathur/setup-php@v2
            with:
              php-version: '8.2'
          - run: composer install
          - run: php vendor/bin/phpunit --testsuite Integration
            env:
              DB_HOST: 127.0.0.1
              DB_NAME: test_db
              DB_USER: root
              DB_PASS: root