Por que uma testsuite por plugin
Plugins estendem o host por hooks — eles não fazem stub ou mock da integração. Um plugin que registra um driver sending-server na real contribui por Hook::add('register_sending_server_driver', ...); o host na real itera essa contribuição por Hook::collect() em produção. Os testes mais úteis fazem o mesmo — bootam um container Laravel real, registram o service provider do plugin, batem numa rota HTTP real, disparam um evento de lifecycle real. Stubs provariam apenas que os stubs estão conectados.
Rodar testes de plugin no próprio processo Pest / PHPUnit do host é o que torna isso possível. O tests/TestCase.php do host entrega CreatesApplication, que boota Laravel exatamente do jeito que produção faz. Todo teste de plugin herda esse boot — incluindo o próprio autoloadWithoutDbQuery() autoload do plugin, o boot() do seu service provider, e qualquer hook add_translation_file que o plugin contribui em register().
A razão pela qual as testsuites são isoladas por plugin em vez de misturadas numa única suite é execução seletiva. Rodar só a suite de um plugin durante desenvolvimento local (php artisan test --testsuite="Plugin: acelle/ai") é o fast feedback loop. A matriz CI do host também pode fan out: um job por plugin, paralelo.
Registrando no phpunit.xml do host
O phpunit.xml raiz do host reserva um bloco <testsuite> para cada plugin sob gestão. Três plugins que entregam no codebase agora mesmo são registrados por esse padrão (acelle/ai, acelle/console, athena/evs entre outros):
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<!-- Per-plugin testsuites -->
<testsuite name="Plugin: acelle/ai">
<directory>storage/app/plugins/acelle/ai/tests</directory>
</testsuite>
<testsuite name="Plugin: athena/evs">
<directory>storage/app/plugins/athena/evs/tests</directory>
</testsuite>
<testsuite name="Plugin: acelle/paddle">
<directory>storage/app/plugins/acelle/paddle/tests</directory>
</testsuite>
<testsuite name="Plugin: acelle/console">
<directory>storage/app/plugins/acelle/console/tests</directory>
</testsuite>
</testsuites>
Adicionar a testsuite de um plugin novo é um bloco, uma linha por diretório. O php artisan test default do host pega toda testsuite registrada por default; --testsuite="Plugin: yourvendor/yourplugin" isola o run para sua suite apenas.
O autor do plugin edita o phpunit.xml do host. Não há autodiscovery — adicionar testes ao diretório tests/ do seu plugin não tem efeito até que a testsuite seja registrada no host. Esse é o único touchpoint fora do folder do seu plugin que um plugin novo requer; tudo mais é self-contained.
Onde os testes do plugin vivem
A convenção do acelle/ai espelha a própria estrutura tests/ do host — folders Unit / Feature mais um PluginTestCase na raiz:
storage/app/plugins/acelle/ai/tests/
├── PluginTestCase.php ← shared base class for the plugin
├── Feature/
│ ├── AIHandler/ ← grouped by surface
│ └── PluginLifecycle/ ← lifecycle integration tests
├── Unit/ ← isolated unit tests, no Laravel boot
├── Fixtures/ ← test fixtures + factories
├── Snapshots/ ← Pest snapshot artefacts
└── Support/ ← test-only helpers
Testes Pest vivem como arquivos .php em qualquer lugar sob Feature/ ou Unit/ e usam uses() / test() / it() normalmente. Classes de teste que preferem extends em vez de uses() funcionam também — o PluginTestCase do plugin lida com ambos.
A classe base PluginTestCase
O Tests\TestCase do host boota Laravel por CreatesApplication. Plugins que precisam de setup adicional antes de todo teste — tipicamente seedando sua própria row como active para que o middleware não 404 as rotas do plugin — entregam um PluginTestCase que estende o do host. A implementação acelle/ai completa vive em storage/app/plugins/acelle/ai/tests/PluginTestCase.php:
namespace Acelle\Ai\Tests;
use App\Model\Plugin;
use Tests\TestCase as BaseTestCase;
class PluginTestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->seedAcellAiPluginActive();
// Bust the per-request memoisation in PluginGate so each test
// observes the actual DB state set above (or set inline by the
// test). Without this the second test onward sees a stale
// cached value from the first test's boot.
if (class_exists(\Acelle\Ai\Support\PluginGate::class)) {
\Acelle\Ai\Support\PluginGate::resetCache();
}
}
protected function seedAcellAiPluginActive(): void
{
$plugin = Plugin::query()->where('name', 'acelle/ai')->first();
if (! $plugin) {
$plugin = new Plugin();
$plugin->name = 'acelle/ai';
$plugin->title = 'AcelleMail AI';
$plugin->version = '1.0.0';
}
$plugin->status = 'active';
$plugin->save();
}
}
Testes de plugin alcançam essa base por uses() (estilo Pest) ou extends (estilo PHPUnit):
// Pest style — file-level
uses(\Acelle\Ai\Tests\PluginTestCase::class, RefreshDatabase::class);
it('renders the chatbox bubble on app pages', function () {
$this->get('/dashboard')->assertSee('data-ai-chatbox');
});
// PHPUnit style — class-level
class MyTest extends \Acelle\Ai\Tests\PluginTestCase
{
public function test_something(): void { /* ... */ }
}
A armadilha do gate cache per-request
O middleware que gateia as rotas do plugin (console.active, ai.active, etc.) checa Plugin::getByName($name)->isActive() em toda request. Código real de produção memoiza esse lookup para que uma única request nunca bata no DB mais de uma vez. Em testes, esse memo é a armadilha — o primeiro teste sobe uma row de plugin ativa, o segundo teste limpa o DB com RefreshDatabase e re-seeda, mas o gate cache do primeiro teste ainda diz "active" (ou "inactive") na row errada.
O fix é expor um método estático resetCache() na classe gate e chamá-lo do PluginTestCase::setUp() do plugin depois que o seed roda. acelle/ai faz isso com \Acelle\Ai\Support\PluginGate::resetCache() como mostrado acima. Qualquer plugin que entrega um middleware similar deve seguir o mesmo padrão — sem isso, a suite é order-dependent (primeiro teste passa, segundo falha).
Padrão hooks-under-test
O jeito mais direto de verificar as contribuições de hook de um plugin é chamar o mesmo Hook::collect() que o host chama em produção e assertar no resultado. Sem mocking; sem stubbing; o registro real roda pelo boot real do plugin:
use App\Library\Facades\Hook;
it('contributes a sending-server driver entry', function () {
$entries = Hook::collect('register_sending_server_driver');
$myDriver = collect($entries)->firstWhere('type', 'postal');
expect($myDriver)->not->toBeNull();
expect($myDriver['driver'])->toBe(\AcmeCorp\Postal\Driver::class);
expect($myDriver['name'])->toBe('Postal MTA');
});
Hooks EVENT precisam de uma forma diferente — assertar side-effects em vez de resultados de collect:
it('awards welcome points when a customer is added', function () {
$customer = Customer::factory()->create();
Hook::fire('customer_added', [$customer]);
expect(LoyaltyAccount::where('customer_id', $customer->id)->first()->points)
->toBe(100);
});
Hooks BEHAVIOR (set / perform) testam chamando Hook::perform() e assertando que o valor retornado vem do override do seu plugin:
it('overrides the import job dispatcher', function () {
Hook::setIfEmpty('dispatch_list_import_job', fn ($l, $f) => new \App\Jobs\ImportJob($l, $f));
// Plugin's own boot already called Hook::set with FasterImportJob.
$job = Hook::perform('dispatch_list_import_job', [$mailList, $filePath]);
expect($job)->toBeInstanceOf(\AcmeCorp\FastImport\FasterImportJob::class);
});
Ciclo Activate → test → delete
O teste de integração completo para um plugin feature-shaped roda o lifecycle inteiro num teste: registra o plugin (ou usa a row já registrada do host), chama $plugin->activate() para disparar a migration, exercita a feature, depois chama $plugin->deleteAndCleanup() para verificar que o rollback funciona de forma limpa.
it('runs migration on activate and rolls back on delete', function () {
$plugin = Plugin::register('acmecorp/loyalty');
expect($plugin->status)->toBe('inactive');
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeFalse();
$plugin->activate();
expect($plugin->status)->toBe('active');
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeTrue();
$plugin->deleteAndCleanup();
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeFalse();
expect(Plugin::where('name', 'acmecorp/loyalty')->exists())->toBeFalse();
});
Esse teste pega uma classe de bugs que testes unitários não pegam: um listener de hook delete_plugin_* faltando em boot(), uma migration que cria uma table mas não consegue droppá-la (sem método down()), um typo no argumento path para artisan migrate. Rode uma vez por release e o lifecycle permanece verde.
Fixtures plugin-isoladas
Plugins que entregam factories ou seeders mantêm eles sob tests/Fixtures/ no folder do plugin, namespaced sob o prefixo PSR-4 do plugin. Eles alcançam os testes do host só por serem autoloaded — o composer.json do plugin já declara o namespace, e o plugin loader do host registra ele durante o boot.
// storage/app/plugins/acmecorp/loyalty/tests/Fixtures/LoyaltyAccountFactory.php
namespace Acmecorp\Loyalty\Tests\Fixtures;
use Acmecorp\Loyalty\Models\LoyaltyAccount;
use Illuminate\Database\Eloquent\Factories\Factory;
class LoyaltyAccountFactory extends Factory
{
protected $model = LoyaltyAccount::class;
public function definition(): array
{
return [
'customer_id' => \App\Model\Customer::factory(),
'points' => $this->faker->numberBetween(0, 10_000),
];
}
}
Use eles dos testes do mesmo jeito que você usaria as próprias factories do host — LoyaltyAccountFactory::new()->create(). RefreshDatabase limpa as rows entre testes, factories reconstroem conforme necessário.
Padrões CI — um job por plugin
O filtro --testsuite é o que permite uma matriz CI per-plugin. Um único workflow GitHub-Actions ou GitLab-CI pode fan out para tantos jobs paralelos quantos plugins você tiver:
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
suite:
- "Unit"
- "Feature"
- "Plugin: acelle/ai"
- "Plugin: athena/evs"
- "Plugin: acmecorp/loyalty"
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: php artisan test --testsuite="${{{ matrix.suite }}}"
Cada suite ganha seu próprio database SQLite ou MySQL (a conexão de teste é isolada per-job), roda em paralelo, e falha independentemente — um teste acelle/ai quebrado não bloqueia um release acmecorp/loyalty. O wall-clock time total permanece próximo da suite única mais longa em vez da soma.
Rodando testes de plugin localmente
Três comandos cobrem quase todo caso de uso developer-level:
# Run every plugin's testsuite plus host Unit + Feature
php artisan test
# Run just one plugin's tests
php artisan test --testsuite="Plugin: acmecorp/loyalty"
# Run a single test by file path
php artisan test storage/app/plugins/acmecorp/loyalty/tests/Feature/AwardsTest.php
# Run a single Pest test by description
php artisan test --filter "awards welcome points"
O binário estilo-Pest ./vendor/bin/pest funciona também se seu time prefere — ambas as invocações rotam pelo mesmo PHPUnit runner. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" é idêntico em efeito à versão artisan.
Cinco anti-padrões
1. Mockando Hook::collect() em vez de deixar o plugin na real registrar
Um collect mockado pode retornar o que você quiser, incluindo entradas que nunca existiriam se o plugin bootasse normalmente. Fix: use o singleton HookManager real; deixe o service provider do plugin registrar por boot(); assert no resultado real.
2. Esquecendo RefreshDatabase
Sem isso, rows vazam entre testes — o segundo teste vê as contas de loyalty do primeiro teste e assert em state stale. Fix: sempre inclua uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) no topo de todo arquivo de teste de plugin (ou no PluginTestCase do plugin por uma trait).
3. Pulando o reset do gate-cache
Falhas order-dependent, difíceis de reproduzir localmente, falham intermitentemente em CI. Fix: se seu plugin entrega um middleware de active-check, exponha um resetCache() estático na classe gate e chame-o de PluginTestCase::setUp().
4. Testando a aplicação host inteira de um teste de plugin
Os testes de um plugin devem focar na contribuição do plugin. Bater em /customers num teste de plugin de loyalty é testar o host, não o plugin — um upgrade do host pode flake o teste por razões não relacionadas. Fix: bata em rotas /plugins/acmecorp/loyalty/... que o plugin possui, ou assert diretamente nos models / hooks do plugin.
5. Compartilhando fixtures de teste entre plugins pelo folder tests/ do host
Tentador porque deduplica uma Customer factory que dois plugins ambos precisam — mas o próximo upgrade do host renomeia uma coluna e silenciosamente quebra os testes de ambos os plugins. Fix: cada plugin possui seu próprio folder Fixtures/; se dois plugins legitimamente compartilham uma factory, entregue um terceiro plugin "shared test helpers" ou um package Composer e dependa dele explicitamente.
Para onde ir em seguida
Testes é a última parada na track de Qualidade. As próximas duas páginas são os exemplos trabalhados mais pesados nos docs: Sending drivers entrega um backend MTA novinho como plugin (Postal MTA, end-to-end), e Payment gateways entrega um gateway regional (Paddle) com os contratos do package Cashier. Depois desses, o showcase acelle/ai percorre o plugin complexo canônico end-to-end como um exercício de leitura.