Testing Plugin
Plugin pada dasarnya adalah class dengan dua method yang memiliki side effect dan satu value object, sehingga sebagian besar test tidak membutuhkan HTTP. Bangun Panel di memory, pasang plugin, lalu assert apa yang sekarang dimiliki Panel. Gunakan halaman ini ketika menulis test suite plugin, atau ketika aplikasi ingin membuktikan bahwa plugin yang dipasang benar-benar melakukan apa yang dijanjikan.
Test plugin milik framework sendiri berada di tests/Feature/Panel/PluginTest.php dengan fixture di tests/Fixtures/Panel/Plugins/. Semua contoh pada halaman ini mengikuti bentuk test tersebut.
Contoh minimal yang berfungsi
<?php
use App\Panels\Plugins\ReportingPlugin;
use App\Panels\Admin\Resources\Reports\ReportResource;
use PandaPanel\Core\Panel;
it('registers its resource', function (): void {
$panel = Panel::make('test')->path('test')->plugins([
ReportingPlugin::make(),
]);
expect($panel->getResources())->toContain(ReportResource::class);
});2
3
4
5
6
7
8
9
10
11
12
13
Panel::make() membuat Panel di memory. Tidak dibutuhkan provider, config entry, route, maupun database — plugins() langsung memanggil register(), lalu assertion membaca object Panel tersebut.
Gunakan ID dan path yang berbeda untuk setiap test Panel. Panel::make() sendiri tidak mendaftarkan Panel ke PandaPanel\Core\PanelManager, sehingga ID tidak akan bentrok di manager pada test sederhana. Kebiasaan memberi ID unik tetap penting untuk test lain di bawah yang benar-benar melakukan registration.
Meng-assert hasil register()
Semua getter Panel dapat digunakan untuk assertion:
use PandaPanel\Core\Panel;
$panel = Panel::make('test')->path('test')->plugins([ReportingPlugin::make()]);
expect($panel->getResources())->toContain(ReportResource::class)
->and($panel->getWidgets())->toContain(RevenueChart::class)
->and($panel->getPages())->toContain(ReportSettings::class)
->and($panel->getNavigationGroups())->toContain('Insights')
->and($panel->getResourceDiscoveryPaths())->toContain(__DIR__.'/Resources');2
3
4
5
6
7
8
9
| Getter | Return |
|---|---|
getResources() | list<class-string> |
getResourceConfigurations() | list<ResourceConfiguration> |
getPages() | list<class-string> |
getWidgets() | list<class-string> |
getNavigationGroups() | list<string> |
getResourceDiscoveryPaths(), getPageDiscoveryPaths(), getWidgetDiscoveryPaths() | list<string> |
getRenderHooks() | array<string, list<array{component: string, data: array, scopes: list<string>}>> |
getCssHooks() | array<string, string> |
getAssets() | list<string> |
getPlugins() | array<string, PanelPlugin> |
Meng-assert bahwa konfigurasi benar-benar berpengaruh
Contract utama plugin configurable adalah bahwa dua konfigurasi dapat menghasilkan dua bentuk Panel berbeda. Test negative case dan positive case:
it('is configurable, so one plugin can be two shapes', function (): void {
$bare = Panel::make('bare')->path('bare')->plugins([
ReportingPlugin::make()->withCharts(false)->group(null),
]);
expect($bare->getWidgets())->not->toContain(RevenueChart::class)
->and($bare->getNavigationGroups())->not->toContain('Insights');
});2
3
4
5
6
7
8
Meng-assert ID dan lookup
it('takes its id from its class name', function (): void {
expect(ReportingPlugin::make()->id())->toBe('reporting');
});
it('lets a panel be asked whether it has one', function (): void {
$panel = Panel::make('ask')->path('ask')->plugins([ReportingPlugin::make()]);
expect($panel->hasPlugin('reporting'))->toBeTrue()
->and($panel->hasPlugin('billing'))->toBeFalse()
->and($panel->plugin('reporting'))->toBeInstanceOf(ReportingPlugin::class)
->and(ReportingPlugin::in($panel))->toBeInstanceOf(ReportingPlugin::class)
->and(ReportingPlugin::in(null))->toBeNull();
});2
3
4
5
6
7
8
9
10
11
12
13
ID merupakan bagian dari public contract karena aplikasi dapat melakukan branch berdasarkan hasPlugin('reporting'). Mengunci ID dalam test mencegah rename berubah menjadi breaking change yang tidak terdeteksi.
Meng-assert duplicate-ID guard
use PandaPanel\Exceptions\PanelRegistrationException;
it('refuses two plugins claiming one id', function (): void {
expect(fn () => Panel::make('dupe')->path('dupe')->plugins([
ReportingPlugin::make(),
ReportingPlugin::make(),
]))->toThrow(PanelRegistrationException::class, 'claim the id');
});2
3
4
5
6
7
8
Menguji dua fase lifecycle
Ketika plugins() selesai, register() sudah dijalankan. boot() belum berjalan dan membutuhkan pemanggilan eksplisit. Perbedaan ini membuat kedua fase mudah diuji secara terpisah:
it('runs register while the panel is being built, and boot later', function (): void {
$panel = Panel::make('phase')->path('phase')->plugins([ReportingPlugin::make()]);
expect($panel->getResources())->toContain(ReportResource::class)
->and($panel->getCssHooks())->not->toHaveKey('page');
$panel->boot();
expect($panel->getCssHooks()['page'] ?? '')->toContain('reporting-page');
});2
3
4
5
6
7
8
9
10
Panel::boot() tidak menerima argument dan mengembalikan void. Dalam request nyata, method ini dipanggil middleware ResolvePanel; dalam unit/feature test sederhana, panggil sendiri.
Urutan boot
Plugin boot sebelum callback bootUsing() milik Panel. Fixture yang merekam urutan call dapat membuktikannya tanpa menginspeksi closure:
<?php
namespace Tests\Fixtures\Plugins;
use PandaPanel\Core\Panel;
use PandaPanel\Plugins\Plugin;
final class RecordingPlugin extends Plugin
{
/** @var list<string> */
public static array $calls = [];
public function register(Panel $panel): void
{
self::$calls[] = 'register';
}
public function boot(Panel $panel): void
{
self::$calls[] = 'boot';
}
public static function reset(): void
{
self::$calls = [];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
it('boots plugins before the panel\'s own callback', function (): void {
RecordingPlugin::reset();
$panel = Panel::make('order')
->path('order')
->plugins([new RecordingPlugin])
->bootUsing(static function (): void {
RecordingPlugin::$calls[] = 'panel';
});
expect(RecordingPlugin::$calls)->toBe(['register']);
$panel->boot();
expect(RecordingPlugin::$calls)->toBe(['register', 'boot', 'panel']);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Static state membuat fixture dapat merekam call dari dua fase. reset() di awal test mencegah state bocor ke test berikutnya.
Meng-assert boot() idempotent
boot() berjalan per request dan beberapa method Panel melakukan append, bukan replace. Plugin yang memasang guard seharusnya membuktikannya melalui test:
it('injects its hook once, however many times it boots', function (): void {
$panel = Panel::make('twice')->path('twice')->plugins([ReportingPlugin::make()]);
$panel->boot();
$panel->boot();
expect($panel->getRenderHooks()['page.start'] ?? [])->toHaveCount(1);
});2
3
4
5
6
7
8
Key render hook adalah backing value dari enum RenderHook, misalnya page.start, sidebar.end, dan seterusnya.
Menguji metadata
use PandaPanel\Plugins\PluginMetadata;
it('names itself and its package', function (): void {
$metadata = ReportingPlugin::make()->metadata();
expect($metadata->name)->toBe('Acme Reporting')
->and($metadata->package)->toBe('acme/panda-reporting')
->and($metadata->requiresPanel)->toBe('^1.2');
});
it('reads a version from composer rather than from what the author typed', function (): void {
$metadata = new PluginMetadata(name: 'Pest', package: 'pestphp/pest');
expect($metadata->version())->not->toBeNull()
->and($metadata->toArray()['version'])->toBe($metadata->version());
});
it('reports a package composer has never heard of as unknown', function (): void {
expect((new PluginMetadata(name: 'Ghost', package: 'nobody/nothing-like-this'))->version())
->toBeNull();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Jangan assert exact version string untuk package plugin Anda sendiri. Di test suite package, package tersebut biasanya tidak terpasang sebagai package Composer terhadap dirinya sendiri sehingga version() dapat menghasilkan null. Assert shape dan behavior, bukan angka versi spesifik.
Menguji kompatibilitas versi
Compatibility check dilewati ketika framework tidak memiliki versi yang dapat dibandingkan — kondisi yang umum pada checkout package. Karena itu test harus mengisi versi secara eksplisit melalui parameter ketiga yang memang disediakan untuk kebutuhan ini:
use PandaPanel\Core\Panel;
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Plugins\Plugin;
use PandaPanel\Plugins\PluginCompatibility;
use PandaPanel\Plugins\PluginMetadata;
function demandingPlugin(string $constraint): Plugin
{
return new class($constraint) extends Plugin
{
public function __construct(private readonly string $constraint) {}
public function register(Panel $panel): void {}
public function metadata(): PluginMetadata
{
return new PluginMetadata(name: 'Demanding', requiresPanel: $this->constraint);
}
};
}
it('refuses a plugin built against a version that is no longer installed', function (): void {
expect(fn () => PluginCompatibility::assert(demandingPlugin('^2.0'), 'admin', '1.4.1'))
->toThrow(PanelRegistrationException::class);
});
it('accepts a plugin whose constraint the installed version satisfies', function (): void {
expect(fn () => PluginCompatibility::assert(demandingPlugin('^1.2'), 'admin', '1.4.1'))
->not->toThrow(PanelRegistrationException::class);
});
it('names the plugin, the constraint and what is installed', function (): void {
try {
PluginCompatibility::assert(demandingPlugin('^2.0'), 'admin', '1.4.1');
} catch (PanelRegistrationException $exception) {
expect($exception->getMessage())
->toContain('Demanding')
->toContain('admin')
->toContain('^2.0')
->toContain('1.4.1');
return;
}
$this->fail('An incompatible plugin was allowed to register.');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Skip case juga layak diuji karena jika logic-nya salah, compatibility check dapat mati secara silent:
it('lets a plugin through when it declares no constraint', function (): void {
$plugin = new class extends Plugin
{
public function register(Panel $panel): void {}
};
expect(fn () => Panel::make('unconstrained')->plugins([$plugin]))
->not->toThrow(PanelRegistrationException::class);
});2
3
4
5
6
7
8
9
Anonymous class yang extend Plugin mendapatkan ID dari class_basename(), yang untuk anonymous class berupa generated string panjang. Itu tidak masalah untuk throwaway fixture, tetapi jangan gunakan bentuk ini pada test yang meng-assert exact plugin ID.
Menguji publishing
panel:publish mengunjungi Panel yang benar-benar diregistrasikan di PandaPanel\Core\PanelManager. Karena itu test Panel harus diregistrasikan, tidak cukup hanya dibuat di memory:
use Illuminate\Support\Facades\File;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelManager;
it('publishes its components into the application tree', function (): void {
$manager = app(PanelManager::class);
if (! $manager->has('publish-test')) {
$manager->register(
Panel::make('publish-test')->path('publish-test')->plugins([
ReportingPlugin::make(),
]),
);
}
$destination = resource_path('js/pages/Panels/Reporting/Widgets/RevenueChart.vue');
File::delete($destination);
$this->artisan('panel:publish', ['plugin' => 'reporting'])->assertSuccessful();
expect(File::exists($destination))->toBeTrue();
File::deleteDirectory(resource_path('js/pages/Panels/Reporting'));
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Tiga hal pada test tersebut dilakukan dengan sengaja:
has()sebelumregister(). Mendaftarkan ID sama dua kali melemparPanelRegistrationException, sedangkan manager adalah singleton di container.File::delete()terlebih dahulu. Command tidak menimpa file tanpa--force. Sisa file dari test sebelumnya dapat membuat assertion lolos dengan alasan yang salah.deleteDirectory()setelah selesai. Command menulis keresources/jsnyata milik repository. Test yang melakukan publish harus membersihkannya.
Skip behavior juga layak memiliki test sendiri karena behavior inilah yang melindungi modifikasi aplikasi:
it('never overwrites a published file without being told to', function (): void {
$destination = resource_path('js/pages/Panels/Reporting/Widgets/RevenueChart.vue');
File::ensureDirectoryExists(dirname($destination));
File::put($destination, '<!-- edited by the application -->');
$this->artisan('panel:publish', ['plugin' => 'reporting'])->assertSuccessful();
expect(File::get($destination))->toBe('<!-- edited by the application -->');
File::deleteDirectory(resource_path('js/pages/Panels/Reporting'));
});2
3
4
5
6
7
8
9
10
11
12
Menguji report command
it('lists what is registered, with versions', function (): void {
$this->artisan('panel:plugins')->assertSuccessful();
});2
3
panel:plugins selalu exit 0, sehingga assertion yang lebih bermakna adalah terhadap output, bukan status:
$this->artisan('panel:plugins', ['--panel' => 'admin'])
->expectsOutputToContain('acme-reporting')
->assertSuccessful();2
3
Menguji plugin end-to-end
Test sebelumnya membuktikan bahwa plugin mengonfigurasi Panel. Untuk membuktikan route dan page plugin benar-benar dilayani, dibutuhkan registered panel provider dan HTTP.
Pada test suite aplikasi, panel provider biasanya sudah ada di config/panda-panel.php, sehingga tidak ada setup tambahan:
it('serves the plugin\'s resource', function (): void {
$this->actingAs(User::factory()->admin()->create())
->get('/admin/reports')
->assertOk();
});2
3
4
5
Pada test suite package plugin, gunakan Orchestra Testbench, siapkan panel provider sebagai fixture, lalu arahkan config ke fixture tersebut:
<?php
namespace Acme\Reporting\Tests;
use Acme\Reporting\Tests\Fixtures\TestPanelProvider;
use Orchestra\Testbench\TestCase as Orchestra;
use PandaPanel\PandaPanelServiceProvider;
abstract class TestCase extends Orchestra
{
protected function getPackageProviders($app): array
{
return [
\Inertia\ServiceProvider::class,
PandaPanelServiceProvider::class,
];
}
protected function defineEnvironment($app): void
{
$app->make('config')->set('panda-panel.panels', [
TestPanelProvider::class,
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
namespace Acme\Reporting\Tests\Fixtures;
use Acme\Reporting\ReportingPlugin;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class TestPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('test')
->plugins([ReportingPlugin::make()]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
panda-panel.panels adalah list class PanelProvider dan dibaca saat service provider boot. Mengubahnya pada defineEnvironment() masih cukup awal. Mengubah config di dalam body test sudah terlambat karena Panel telah dibangun.
panda-panel.register_routes dapat di-set false untuk test harness yang hanya perlu boot Panel tanpa HTTP route.
Catatan
- Panel yang hanya dibuat dengan
Panel::make()tetapi tidak pernah diregistrasikan tidak memiliki route, registry, maupun navigation. Itu alat yang tepat untuk mengujiregister()danboot(), tetapi bukan untuk menguji apakah resource dapat diakses melalui HTTP. PanelManageradalah container singleton. Panel yang diregistrasikan pada satu test tidak bertahan ke test berikutnya karena Testbench dan Laravel test case membangun ulang aplikasi. Static state pada class plugin dapat bertahan; reset state tersebut secara eksplisit.- Framework tidak me-resolve plugin dari container, sehingga tidak ada object container yang perlu di-mock. Construct plugin, pasang ke Panel, lalu assert behavior-nya.
Panel::plugins()melemparPanelRegistrationExceptionuntuk duplicate ID maupun failedrequiresPanelcheck. Assert fragment error message, bukan hanya class exception, agar test dapat membedakan kedua failure mode.