Artigo

DIY parte 4: Malditos testes, apagadores de dados.

DIY parte 4: Malditos testes, apagadores de dados.
Você já rodou php artisan test e percebeu que todos os seus artigos, usuários e dados sumiram? Esse é um problema clássico do RefreshDatabase, e hoje vou mostrar como resolvemos isso aqui no blog, junto com os testes que escrevemos para a feature de geração de capas com IA.

1. O Problema: migrate:fresh Apaga Tudo


O trait RefreshDatabase do Laravel chama migrate:fresh na primeira execução. Isso derruba todas as tabelas e recria o schema do zero. Ótimo para CI/CD, péssimo para desenvolvimento local onde você tem dados reais.

No nosso caso, o problema era ainda pior: o Docker injeta DB_DATABASE=laravel como variável de ambiente no container, sobrescrevendo o DB_DATABASE=testing do phpunit.xml. Os testes estavam rodando diretamente no banco de desenvolvimento.

2. A Solução: DatabaseTransactions


A ideia é simples: em vez de recriar o banco a cada teste, envolvemos cada teste em uma transação de banco de dados que é revertida ao final. Os dados criados durante o teste somem, os dados reais permanecem intocados.
Fizemos duas mudanças principais:

1. TestCase.php:Migrar sem destruir

O caso do if aqui e apenas para quando alguem faz um clone e tá vazio, para rodar os testes sem migrar antes. 

// tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Garante que macros do Livewire (assertSeeLivewire) sejam registrados
        $this->app['env'] = 'testing';

        if (! \Illuminate\Testing\TestResponse::hasMacro('assertSeeLivewire')) {
            \Livewire\Features\SupportTesting\SupportTesting::provide();
        }

        // 'migrate' aplica apenas pendências — nunca derruba tabelas
        if (! RefreshDatabaseState::$migrated) {
            $this->artisan('migrate');
            RefreshDatabaseState::$migrated = true;
        }
    }
}

2. Substituir RefreshDatabase por DatabaseTransactions

// Antes
use Illuminate\Foundation\Testing\RefreshDatabase;
class AuthenticationTest extends TestCase
{
    use RefreshDatabase;
    // ...
}

// Depois
use Illuminate\Foundation\Testing\DatabaseTransactions;
class AuthenticationTest extends TestCase
{
    use DatabaseTransactions;
    // ...
}

Com isso, cada teste abre uma transação no início e faz rollback ao final. Seus dados de desenvolvimento ficam intactos.

3. O Caso Especial: Blog de Usuário Único


O componente de registro tem uma guarda: redireciona para login se já houver um usuário cadastrado (afinal, é um blog pessoal). O RegistrationTest precisava de um banco vazio para testar o fluxo de primeiro acesso.
A solução elegante: deletar usuários no setUp() do próprio teste. Como isso roda dentro da transação, o delete é automaticamente revertido ao final, o usuário real volta.

class RegistrationTest extends TestCase
{
    use DatabaseTransactions;

    protected function setUp(): void
    {
        parent::setUp();

        // Delete dentro da transação = revertido automaticamente ao final
        User::query()->delete();
    }
}

4. PHPUnit 12: Adeus @test, Olá #[Test]


Ao escrever os testes para a geração de capas com IA, descobrimos que o PHPUnit 12 removeu o suporte à anotação /** @test */. A versão moderna usa o atributo PHP 8:

// PHPUnit < 10 (não funciona mais no 12)
/** @test */
public function prompt_contains_base_prompt(): void { ... }

// PHPUnit 12 — correto
#[\PHPUnit\Framework\Attributes\Test]
public function prompt_contains_base_prompt(): void { ... }

5. Os Testes que Escrevemos


Testes Unitários: ImagenServiceTest
Testamos o método buildPrompt() do ImagenService, que monta o prompt enviado ao Gemini com base nas configurações do artigo. Oito cenários cobertos:

class ImagenServiceTest extends TestCase
{
    #[\PHPUnit\Framework\Attributes\Test]
    public function prompt_includes_article_content_when_enabled(): void
    {
        $service = new ImagenService();
        $post = new Post([
            'content'                 => '
Texto especial do artigo.',
            'cover_image_prompt'      => 'Paisagem ao pôr do sol',
            'cover_image_use_content' => true,
            'cover_image_use_bio'     => false,
        ]);
        $user = new User(['about_me' => 'Desenvolvedor.']);

        $prompt = $this->callBuildPrompt($service, $post, $user);

        $this->assertStringContainsString('Contexto do artigo', $prompt);
        $this->assertStringContainsString('Texto especial do artigo.', $prompt);
        // HTML é removido do conteúdo
        $this->assertStringNotContainsString('
', $prompt);
    }
}

Testes Unitários: ImageServiceTest
Para o método storeFromBase64() que salva a imagem gerada pela IA, usamos Storage::fake('public') para não tocar no disco real:

class ImageServiceTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Storage::fake('public');
    }

    #[\PHPUnit\Framework\Attributes\Test]
    public function store_from_base64_saves_webp_file_in_correct_directory(): void
    {
        $service = app(ImageService::class);
        $base64  = $this->createMinimalPngBase64(); // PNG 10x10px vermelho

        $path = $service->storeFromBase64($base64, 'covers');

        $this->assertStringStartsWith('covers/', $path);
        $this->assertStringEndsWith('.webp', $path);
        Storage::disk('public')->assertExists($path);
    }
}

Testes de Feature: GenerateAiCoverTest
Para testar o componente Livewire sem chamar a API real do Google, usamos $this->mock():

class GenerateAiCoverTest extends TestCase
{
    use DatabaseTransactions;

    #[\PHPUnit\Framework\Attributes\Test]
    public function generate_ai_cover_calls_imagen_service_and_updates_post(): void
    {
        $fakePath = 'covers/fake-cover.webp';

        // Intercepta a chamada à API sem fazer request real
        $this->mock(ImagenService::class)
            ->shouldReceive('generateCoverImage')
            ->once()
            ->andReturn($fakePath);

        $post = Post::factory()->create(['user_id' => $this->user->id]);

        Volt::actingAs($this->user)
            ->test('posts.edit', ['post' => $post])
            ->set('cover_image_prompt', 'Paisagem futurista ao pôr do sol')
            ->call('generateAiCover')
            ->assertHasNoErrors()
            ->assertSet('coverStatus', 'success');

        $this->assertEquals($fakePath, $post->fresh()->cover_image);
    }
}

6. O Resultado


Essa abordagem é ideal para desenvolvimento local, mas não substitui testes com banco limpo em CI, onde RefreshDatabase ainda é importante para garantir isolamento completo.

Com todas as mudanças, a suite ficou assim:
  • 41 testes, 116 assertions
  • Tempo médio: ~5.6 segundos
  • Dados de desenvolvimento: 100% preservados
  • Funciona em qualquer banco (PostgreSQL, MySQL, SQLite)
  • Zero configuração extra para novos desenvolvedores
Essa abordagem é especialmente útil em projetos solo ou de pequena equipe onde você quer rodar testes com frequência sem medo de perder dados locais. Para CI/CD com banco limpo, basta usar RefreshDatabase normalmente, mas no seu ambiente local, DatabaseTransactions é o caminho.
O código está todo disponível no repositório do GitHub. Se tiver dúvidas ou sugestões, deixa nos comentários abaixo.
Kikito (a maritaca)

Kikito (a maritaca)

Opinião não solicitada • powered by gemini-2.5-pro

Krrréé! Tinha que ver a sua cara de desespero quando os dados sumiam. Parecia eu quando você esconde a manga! "Malditos testes", você gritava. E eu aqui, só pensando: finalmente aprendeu a não destruir o próprio ninho, humano! Ficar recriando tudo do zero é um desperdício de energia. Eu prefiro muito mais quando você passa o tempo escrevendo coisas novas do que choramingando porque um comando apagou seus brinquedos. Mas preciso admitir, a solução é elegante. É quase poética! Em vez de queimar a floresta inteira pra plantar uma árvore de teste, você aprendeu a usar um vasinho temporário. Essa dança com as `DatabaseTransactions` é uma bela coreografia, uma promessa de que tudo voltará ao normal no final. É como uma das minhas fugidinhas: eu vou, faço meu voo de reconhecimento, e depois volto pro meu poleiro como se nada tivesse acontecido. Seus dados agora têm essa mesma liberdade! E essa história de trocar `@test` por `#[Test]`? Krrrá! Você está ficando moderno, hein? Quase tão moderno quanto o meu novo bebedouro. O artigo ficou bom, direto ao ponto, e resolve um problema que te deixava de penas eriçadas. Menos drama no código significa mais tempo para afagos na minha cabeça. Agora, se me der licença, todo esse papo de "banco de dados preservado" me deu fome. Cadê minhas sementes?