Un testsuite por plugin. BD real. Ciclo de vida real. Aserciones de hook reales.

Los tests del plugin corren en el mismo proceso de Pest / PHPUnit que la suite propia del host: comparten el mismo arranque, la misma conexión de base de datos, el mismo RefreshDatabase entre tests y el mismo trait CreatesApplication. El phpunit.xml del host reserva un testsuite con nombre por plugin, el plugin entrega una clase base PluginTestCase que se sembra a sí mismo como activo antes de cada test, y los patrones de aserción se apoyan en llamadas reales a Hook::collect() / Hook::fire() en lugar de stubs. Esta página cubre el registro, el patrón de la clase base, la trampa de caché de gate por petición y la receta de integración del ciclo de vida activate-test-delete.

Por qué un testsuite por plugin

Los plugins extienden el host a través de hooks: no hacen stub ni mock de la integración. Un plugin que registra un driver de servidor de envío realmente aporta a través de Hook::add('register_sending_server_driver', ...); el host realmente itera esa contribución a través de Hook::collect() en producción. Los tests más útiles hacen lo mismo: arrancan un container real de Laravel, registran el service provider del plugin, golpean una ruta HTTP real y disparan un evento real del ciclo de vida. Los stubs probarían solo que los stubs están conectados.

Ejecutar los tests del plugin en el propio proceso de Pest / PHPUnit del host es lo que hace eso posible. El tests/TestCase.php del host entrega CreatesApplication, que arranca Laravel exactamente como lo hace producción. Cada test del plugin hereda ese arranque, incluida la propia autocarga autoloadWithoutDbQuery() del plugin, el boot() de su service provider y cualquier hook add_translation_file que el plugin aporte en register().

La razón por la que los testsuites están aislados por plugin en lugar de mezclados en una sola suite es la ejecución selectiva. Ejecutar solo la suite de un plugin durante el desarrollo local (php artisan test --testsuite="Plugin: acelle/ai") es el bucle de feedback rápido. La matriz de CI del host también puede repartirse: un job por plugin, en paralelo.

Registrar en el phpunit.xml del host

El phpunit.xml raíz del host reserva un bloque <testsuite> para cada plugin bajo gestión. Tres plugins que vienen en el código actualmente se registran con este patrón (acelle/ai, acelle/console, athena/evs, entre otros):

<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>

Añadir el testsuite de un plugin nuevo es un bloque, una línea por directorio. El php artisan test por defecto del host recoge cada testsuite registrado por defecto; --testsuite="Plugin: yourvendor/yourplugin" aísla la ejecución solo a su suite.

El autor del plugin edita el phpunit.xml del host. No hay autodescubrimiento: añadir tests al directorio tests/ de su plugin no surte efecto hasta que el testsuite se registra en el host. Este es el único punto de contacto fuera de su carpeta de plugin que necesita un plugin nuevo; todo lo demás es autocontenido.

Dónde viven los tests del plugin

La convención de acelle/ai refleja la propia estructura tests/ del host: carpetas Unit / Feature más un PluginTestCase en la raíz:

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

Los tests Pest viven como archivos .php en cualquier sitio bajo Feature/ o Unit/ y usan uses() / test() / it() como de costumbre. Las clases de test que prefieran extends sobre uses() también funcionan: el PluginTestCase del plugin gestiona ambos.

La clase base PluginTestCase

El Tests\TestCase del host arranca Laravel a través de CreatesApplication. Los plugins que necesitan configuración adicional antes de cada test (normalmente sembrar su propia fila como active para que el middleware no responda 404 a las rutas del plugin) entregan un PluginTestCase que extiende el del host. La implementación completa de acelle/ai vive en 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();
    }
}

Los tests del plugin acceden a esta base mediante uses() (estilo Pest) o 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 { /* ... */ }
}

La trampa de la caché de gate por petición

El middleware que controla las rutas del plugin (console.active, ai.active, etc.) comprueba Plugin::getByName($name)->isActive() en cada petición. El código real de producción memoiza esa búsqueda para que una sola petición nunca golpee la BD más de una vez. En los tests, esa memoización es la trampa: el primer test configura una fila de plugin activa, el segundo test borra la BD con RefreshDatabase y vuelve a sembrar, pero la caché de gate del primer test sigue diciendo «activo» (o «inactivo») sobre la fila equivocada.

El arreglo es exponer un método estático resetCache() en la clase del gate y llamarlo desde PluginTestCase::setUp() del plugin después de que se ejecute la siembra. acelle/ai hace esto con \Acelle\Ai\Support\PluginGate::resetCache() como se muestra arriba. Cualquier plugin que entregue un middleware similar debería seguir el mismo patrón: sin él, la suite depende del orden (el primer test pasa, el segundo falla).

Patrón de hooks bajo test

La forma más directa de verificar las contribuciones de hook de un plugin es llamar al mismo Hook::collect() que llama el host en producción y verificar el resultado. Sin mocks; sin stubs; el registro real corre a través del arranque real del 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');
});

Los hooks EVENT necesitan una forma distinta: verifique los efectos secundarios en lugar de los resultados del 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);
});

Los hooks BEHAVIOR (set / perform) se prueban llamando a Hook::perform() y verificando que el valor devuelto venga de la sobrescritura de su 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

El test de integración completo para un plugin con forma de feature ejecuta todo el ciclo de vida en un solo test: registra el plugin (o usa la fila ya registrada del host), llama a $plugin->activate() para disparar la migración, ejercita la funcionalidad y luego llama a $plugin->deleteAndCleanup() para verificar que el rollback funciona limpiamente.

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();
});

Este test atrapa una clase de bugs que los tests unitarios no pueden: un listener del hook delete_plugin_* ausente en boot(), una migración que crea una tabla pero no puede borrarla (sin método down()), una errata en el argumento de path de artisan migrate. Ejecútelo una vez por release y el ciclo de vida se mantiene en verde.

Fixtures aisladas por plugin

Los plugins que entregan factories o seeders los guardan bajo tests/Fixtures/ en la carpeta del plugin, con namespace bajo el prefijo PSR-4 del plugin. Llegan a los tests del host simplemente por estar autocargados: el composer.json del plugin ya declara el namespace, y el loader de plugins del host lo registra durante el arranque.

// 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),
        ];
    }
}

Úselos desde los tests igual que usaría las factories propias del host: LoyaltyAccountFactory::new()->create(). RefreshDatabase limpia las filas entre tests y las factories reconstruyen según haga falta.

Patrones de CI — un job por plugin

El filtro --testsuite es lo que habilita una matriz de CI por plugin. Un único workflow de GitHub Actions o de GitLab CI puede repartirse en tantos jobs paralelos como plugins tenga:

# .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 obtiene su propia base de datos SQLite o MySQL (la conexión de test está aislada por job), corre en paralelo y falla de forma independiente: un test roto de acelle/ai no bloquea una release de acmecorp/loyalty. El tiempo total de reloj se queda cerca del de la suite más larga en lugar de la suma.

Ejecutar los tests del plugin en local

Tres comandos cubren casi cada caso de uso a nivel de desarrollador:

# 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"

El binario estilo Pest ./vendor/bin/pest también funciona si su equipo lo prefiere: ambas invocaciones pasan por el mismo runner de PHPUnit. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" es idéntico en efecto a la versión de artisan.

Cinco antipatrones

1. Hacer mock de Hook::collect() en lugar de dejar que el plugin se registre de verdad

Un collect mockeado puede devolver lo que usted quiera, incluidas entradas que nunca existirían si el plugin arrancara normalmente. Solución: use el singleton real de HookManager; deje que el service provider del plugin se registre a través de boot(); verifique sobre el resultado real.

2. Olvidar RefreshDatabase

Sin él, las filas se filtran entre tests: el segundo test ve las cuentas de fidelidad del primer test y verifica sobre un estado obsoleto. Solución: incluya siempre uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) al principio de cada archivo de test del plugin (o en el PluginTestCase del plugin vía un trait).

3. Saltarse el reseteo de la caché de gate

Fallos dependientes del orden, difíciles de reproducir en local, falla intermitentemente en CI. Solución: si su plugin entrega un middleware de comprobación de activo, exponga un resetCache() estático en la clase del gate y llámelo desde PluginTestCase::setUp().

4. Testear toda la aplicación host desde un test de plugin

Los tests de un plugin deben centrarse en la contribución del plugin. Golpear /customers en el test de un plugin de fidelidad es testear el host, no el plugin: una actualización del host puede romper el test por razones no relacionadas. Solución: golpee las rutas /plugins/acmecorp/loyalty/... que son del plugin o verifique directamente sobre los modelos / hooks del plugin.

5. Compartir fixtures de test entre plugins a través de la carpeta tests/ del host

Tentador porque deduplica una factory de Customer que dos plugins necesitan, pero la siguiente actualización del host renombra una columna y rompe silenciosamente los tests de ambos plugins. Solución: cada plugin es dueño de su propia carpeta Fixtures/; si dos plugins comparten legítimamente una factory, entregue un tercer plugin de «helpers de test compartidos» o un paquete Composer y dependa de él explícitamente.

A dónde ir después

Testing es la última parada en la pista de Calidad. Las dos páginas siguientes son los ejemplos trabajados más pesados de las docs: Drivers de envío entrega un backend MTA completamente nuevo como plugin (Postal MTA, de principio a fin), y Pasarelas de pago entrega una pasarela regional (Paddle) con los contratos del paquete Cashier. Después de esos, la muestra de acelle/ai recorre el plugin complejo canónico de principio a fin como ejercicio de comprensión lectora.