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.