Testing a plugin
The most important pitfall
A fresh test database has every plugin module disabled.
A plugin's switch is fail-closed: if the row is missing, the module counts as off. A freshly set-up test database has none of those rows. Every route of your plugin therefore answers with 404, and your functional test checks nothing.
The insidious part is that it goes green anyway when it checks an error case. A test expecting a 403 gets a 404 and is satisfied, as long as it only checks for "not a 200".
The countermeasure depends on what your test extends:
ApiTestCase — everything under tests/Api/. You do nothing. make test-db creates the switches, and ApiTestCase::setUp() verifies once per process that they are actually there. If they are missing, the run fails with instructions instead of silently returning 404.
KernelTestCase — everything under tests/Functional/. That check does not apply there. Pull in SeedsPluginModuleFlagsTrait and call seedPluginModuleFlags() right after self::bootKernel():
use App\Tests\Support\Plugin\SeedsPluginModuleFlagsTrait;
final class ShipmentServiceTest extends KernelTestCase
{
use SeedsPluginModuleFlagsTrait;
public function testSomething(): void
{
self::bootKernel();
$this->seedPluginModuleFlags();
// from here on your module counts as switched on
}
}
At the end the trait resets the cached view of ModuleGate. Without that reset a cached "everything off" holds on even though the rows are there by now.
Tests do not live in the plugin
PHP tests belong centrally under tests/, mirrored by module. A test under plugins/<Name>/tests/ is never run: the test configuration does not pick it up.
A test the test runner does not find is not a test. An architecture guard holds that in place so it does not happen again.
Exception: a third-party plugin with a repository of its own brings its own test configuration. The scaffold creates it together with a sample test and a workflow for continuous checking.
Interface tests are the other exception. They do live in the plugin, under frontend/**/__tests__/, and are collected from there.
Preparing the test database
make test-db
That empties the test database, runs the migrations and prepares the schema. Necessary after every schema change, including one coming from a foreign plugin.
When data model errors that make no sense suddenly appear after files have been moved around, they are usually stale intermediate state:
vendor/bin/phpstan clear-result-cache
bin/console cache:clear --env=test
What base classes there are
| Base class | What for | How common |
|---|---|---|
ApiTestCase | Real login with a token, one transaction per test, users with targeted permissions | 925 tests |
KernelTestCase | Services without the HTTP layer | 454 tests |
ModuleSecurityTestCase | Extends ApiTestCase, adds collectUlids(), lastStatus(), lastJson(), reload() | 60 tests |
PortalIsolationTestCase | Isolation in the customer portal | 24 tests |
PortalApiTestCase | Login in the customer portal area | 8 tests |
ApiTestCase is the normal case and hands you two methods:
protected function seedUser(array $permissions, ?int $brandId = null): User;
protected function clientFor(User $user): Client;
protected function addMembership(User $user, array $permissions, ?int $brandId): Membership;
The value is in the first parameter: a user with exactly the permissions you want to check, and an access token for them. That is precisely where its value lies, because most real security holes do not arise from a missing permission but from one that is too broad.
The test that finds the most faults
By far the most common fault pattern in the existing code was not the missing permission check but the brand cut at the membership instead of at the permission: whoever is a member in a second brand but holds no permission there still saw its data.
That is why every brand-bound list needs a test following this pattern:
public function testFremdmarkeFliesstNichtInDieSumme(): void
{
// Two documents, two brands.
ShipmentFactory::createOne(['brandId' => self::MARKE_A, 'amount' => 10_000]);
ShipmentFactory::createOne(['brandId' => self::MARKE_B, 'amount' => 99_900]);
// Permission ONLY in brand A. In brand B a membership exists entirely WITHOUT permissions.
$nutzer = $this->seedUser(['acme.shipping.view'], self::MARKE_A);
$this->addMembership($nutzer, [], self::MARKE_B);
$client = $this->clientFor($nutzer);
$client->request('GET', '/api/v1/acme-shipping/shipments');
$daten = $client->getResponse()->toArray();
self::assertSame(10_000, $daten['summe'], 'Der Bestand der Marke B darf nicht in der Summe stecken.');
self::assertSame(1, $daten['anzahl'], 'Auch die ANZAHL verraet den Fremdbestand nicht.');
}
The second assertion is the one people forget. Hiding a total while shipping the count gives away the foreign records all the same.
Commands
make test # everything: backend and interface
make test-api # only the API leg
make lint stan # style and static analysis
make gate # the full gate, before every "done"
vendor/bin/phpunit tests/Api/AcmeShipping # targeted
cd frontend && npx vitest run ../plugins/AcmeShipping/frontend
make gate is the authoritative run. It bundles all the checks, and it is the answer to the question of whether something is finished.
Before shipping
| Checked? | What |
|---|---|
| ☐ | Schema moves additively only, no content in migrations |
| ☐ | Standard data, permissions and lookup values come from the contracts |
| ☐ | Every item operation has a voter of its own |
| ☐ | Every brand-bound list is cut to the permission, not to the membership |
| ☐ | Every collection filter is declared as a parameter and is therefore visible |
| ☐ | A demo contribution exists if the plugin has a domain table of its own |
| ☐ | Every column with a personal reference has a contribution to disclosure and deletion |
| ☐ | Couplings to other plugins are declared |
| ☐ | The module switch is shipped by the plugin itself |
| ☐ | The generator leftovers are gone |
| ☐ | The version reads the same everywhere: descriptor, package files, changelog |
| ☐ | make gate is green |
Next
- Lifecycle — states and commands
- Distribution — building a package and delivering it
- Backend — the rules these tests check