Writing tests for Polymer core and extensions¶
Polymer commands are thin orchestrators: they read layered configuration,
compose Robo task pipelines that shell out (drush, composer, npm), and chain
each other through the CommandInvoker service. Testing them therefore means
answering three questions — what configuration did the command read, what
subprocess commands would it run, and what files did it write — at the
cheapest tier that can answer them.
This guide is the canonical testing methodology for the whole family: core
and every extension (polymer-drupal, polymer-pantheon-drupal, and
third-party plugins) use the same four tiers. Every tier below is validated
against real commands; the referenced tests are the working examples.
The four tiers¶
| Tier | What it proves | Speed | Where |
|---|---|---|---|
| 1. Unit | Logic extracted from commands into services | ms | packages/<pkg>/tests/phpunit/unit/ |
| 2. Kernel + simulate | Task composition: the exact command lines a command would run | ms | packages/core/tests/phpunit/kernel/ |
| 3. Kernel + binary shims | Real pipeline execution: actual subprocess argv, output-driven branching, file side effects | ms–s | packages/core/tests/phpunit/kernel/ |
| 4. DDEV fixture (CI) | One happy path end-to-end against real Drupal | ~90s | .github/workflows/ci.yml fixture job |
Choose the lowest tier that can observe the behavior under test. Per-command coverage belongs in tiers 1–3; tier 4 is a smoke canary and must not grow per-command assertions.
Suite layout and bootstraps¶
- Each package carries
tests/phpunit/{phpunit.xml.dist, unit/}; the rootphpunit.xml.distdefines one testsuite per package, sovendor/bin/phpunitat the monorepo root runs everything and--testsuite coreruns one package. - Test namespaces are
autoload-devmappings (DigitalPolygon\PolymerTest\,…\PolymerDrupalTest\, …) onto each package'stests/. - Extension namespaces are runtime-registered at Polymer boot — they are
deliberately not in any composer.json. Unit suites reach extension code
via PHPUnit bootstraps that mirror
ExtensionDiscovery's registration: the roottests/bootstrap.phpfor monorepo runs, andpackages/<pkg>/tests/phpunit/bootstrap.phpfor standalone split-repo runs. Never add extension PSR-4 to a composer.json to make a test pass.
Tier 1 — unit tests for extracted logic¶
When a command method accumulates real logic — file inspection, branching on configuration, value parsing — extract it into a service registered through the package's service provider and unit-test the service. The command method shrinks toward pure orchestration, which the higher tiers cover.
Worked example: drupal:config:import embedded its sync-directory checks
inline. They now live in
DigitalPolygon\Polymer\polymer_drupal\Services\ConfigSyncDirectory
(container id configSyncDirectory), unit-tested against temp fixtures in
ConfigSyncDirectoryTest. The settings-file pipeline
(SettingsFileGenerationTest) and Drush task helpers (DrushTaskTest)
follow the same pattern.
Conventions learned validating this tier:
- Make extracted services stateless and pass context-dependent paths per
call. The config-sync path depends on the active
--sitecontext; resolving it at service construction would silently pin the service to the boot-time site. This generalizes to anything reading config thatConfigManagerre-layers per command. - Command classes are instantiated by the command factory — there is no
constructor injection. Resolve services at the top of the command method
via
$this->getContainer()->get('configSyncDirectory'). - Worth extracting: logic with branches a test can drive through the filesystem or plain values. Not worth it: one-line task compositions — that's Tier-2 territory.
Tier 2 — kernel tests: boot the real kernel¶
DigitalPolygon\PolymerTest\phpunit\kernel\PolymerKernelTestCase
(packages/core/tests/phpunit/kernel/PolymerKernelTestCase.php) boots the
production kernel against a throwaway project fixture in a temp directory —
extension discovery and gating, runtime namespace registration, command
discovery, service-provider wiring, and config layering all run the real
boot path. No Drupal, no subprocesses.
The fixture API:
installPackageAsPlugin(string $dir)— symlink a siblingpackages/*package into.polymer/plugins/(skips automatically on standalone split-repo runs, where siblings don't exist on disk).enableExtensions(array $ids)/writeProjectConfig(array $config)— control.polymer/config.yml.writePolymerYml(array $config)— project-levelpolymer/polymer.yml, for exercising config layering.bootPolymer(): Polymer— fresh kernel against the fixture.runCommand(Polymer $polymer, string $commandLine): array{0:int,1:string}— run a CLI line in-process; returns exit status and captured output. Output fromCommandInvoker-chained sub-commands lands in the same buffer.runOk(Polymer $polymer, string $commandLine): string— run + assert exit 0 + return output.
The kit lives in core's test namespace today and is designed to be extracted
as a polymer-test-kit package later, so extensions can depend on it for
their own kernel tests.
Simulate mode: asserting task composition¶
Robo has a built-in execution seam: --simulate maps to the
options.simulated config, and the collection builder then wraps every task
built via $this->task() in Robo\Task\Simulator, which logs the task
class, constructor arguments, and fluent call chain instead of executing.
The kit exposes it as:
runSimulated(Polymer $polymer, string $commandLine): string— appends--simulate, asserts exit 0, returns the simulator log.assertSimulatedTask(string $log, string $needle)— asserts the log records the task invocation, tolerant of console formatting.
SimulatedExecutionTest is the working example:
public function testSyncDatabasePipelineIsSimulated(): void
{
$this->installPackageAsPlugin('drupal');
$this->enableExtensions(['polymer_drupal']);
$this->writePolymerYml([
'drupal' => ['drush' => ['aliases' => ['remote' => 'prod.live']]],
]);
$polymer = $this->bootPolymer();
$log = $this->runSimulated($polymer, 'drupal:site:sync:database');
$this->assertSimulatedTask($log, 'sql-sync');
$this->assertSimulatedTask($log, '@prod.live');
}
This is the highest-value tier for the artifact:*, drupal:site:sync*,
and drupal:setup:* families: a config change that silently alters a
generated command line fails here, in-process, in milliseconds. The fixture
has no drush and no composer project, so a command that escaped simulation
fails loudly — exit 0 plus simulator log lines is the proof.
Validated boundaries of the seam:
- Only tasks built through the collection builder are simulated. The only
direct subprocess calls in the family are two read-only
shell_execgit reads inDeployCommand(current-branch and last-log introspection). They return values the command branches on, so they cannot be simulated without breaking the command — cover that behavior with Tier-3 shims instead. CommandInvoker-chained sub-commands run a fresh console-command lifecycle in which Robo's global-options listener recomputesoptions.simulatedfrom the child's input. Validating this tier found and fixed a real bug: the invoker didn't forward--simulate, so a simulateddrupal:site:syncexecuted its chained sql-sync for real.invokeCommand()now forwards the flag, andSimulatedExecutionTest::testCommandInvokerChainInheritsSimulationpins it.
Tier 3 — kernel tests with binary shims¶
Simulate mode cannot cover commands that branch on subprocess output or must produce real file side effects. For those, install fake executables into the fixture:
installShim(string $binary, array $responses = [])— writes a bash shim into the fixture'sbin/(PATH-prepended) that appends its argv to an invocation log and replays canned responses. Each['stdout' => …, 'exit' => …]entry answers one invocation in order; the last entry repeats.shimInvocations(): array/assertShimInvoked(string $needle)— the log, one<binary> <argv>line per call, in execution order.
BinaryShimTest is the working example — the sync pipeline executing for
real against a shimmed drush with exact argv asserted; the Quicksilver
profile command driving both sides of its terminus-plugin validator
branch; pantheon:files:copy-pantheon-yml writing a rendered pantheon.yml
into the fixture.
Validated gotchas the kit now encodes:
- PATH must reach subprocesses through
$_ENV/$_SERVER, not justputenv(). Symfony Process composes the child environment from all three; withputenv()alone the shims are silently invisible. The kit sets and restores all three. - Tools addressed via config rather than PATH (e.g.
drupal.drush.bin, which defaults to${composer.bin}/drush) are not intercepted by PATH — point the config value atshimBinDir() . '/drush'in the test. Note${composer.bin}does not interpolate in a bare fixture. - The drush task runs in
${docroot}(the fixture'sweb/), which must exist for real runs — shims don't remove cwd requirements. - Shims are bash: fine for CI and DDEV; revisit if Windows runners ever appear.
Tier 4 — the DDEV fixture job: smoke only¶
The fixture CI job installs tests/fixture/ (a real Drupal project
consuming packages/* via path repositories), runs
polymer drupal:setup:site:all under DDEV, and asserts the site bootstraps
plus read-only checks on the installed site's artifacts. It is the only
tier that proves the family works against real Drupal, real drush, and a
real database.
Scope rule (validated against the job's actual contents and cost — ~90s vs ~18s for the entire PHPUnit suite, plus DDEV/network flake surface and far costlier failure triage):
The fixture job proves one thing: the family can stand up a real Drupal site end-to-end. Assertions are limited to (a) that install path and (b) read-only checks piggybacking on the installed site's artifacts. No additional polymer command invocations; no per-command behavior assertions — those go to tiers 1–3. If a new step would run another polymer command in the fixture job, write a kernel test instead.
Choosing a tier¶
| The behavior under test… | Tier |
|---|---|
| Pure logic (file inspection, config branching, value parsing) | 1 — extract and unit-test |
| Extension discovery/gating, command registration, service wiring, config layering | 2 — kernel test |
| Which command lines a pipeline would execute, with what flags | 2 — kernel + simulate |
| Branching on subprocess output; real argv; files written by a real run | 3 — kernel + shims |
| "Does the whole family stand up a real site" | 4 — already covered; don't add to it |