wp-cli/wp-cli-tests
WP-CLI testing framework that bundles shared Behat, PHPUnit, PHP_CodeSniffer, and PHPStan tooling for WP-CLI commands and packages. Add it as a dev dependency to get ready-made test scripts, Behat feature contexts, and linting configs out of the box.
This is not a Laravel package — there is no service provider, no config publish, no facade. Treat it the way you treat phpunit/phpunit or orchestra/testbench: a dev-only toolchain you install into a package that ships WP-CLI commands. If your day job is Laravel but you maintain a WP-CLI command (a data migration command, a custom wp subcommand for a WordPress side of the estate), this is the harness that gives you Behat acceptance tests, PHPCS, PHPStan, and PHPUnit in one composer test.
Minimal setup, from the package root:
composer require --dev wp-cli/wp-cli-tests
composer update # regenerates autoloader + vendor/bin
Then edit composer.json to expose the runners as scripts — this is the package's real interface, the equivalent of artisan test aliases:
"scripts": {
"behat": "run-behat-tests",
"behat-rerun": "rerun-behat-tests",
"lint": "run-linter-tests",
"phpcs": "run-phpcs-tests",
"phpcbf": "run-phpcbf-cleanup",
"phpstan": "run-phpstan-tests",
"phpunit": "run-php-unit-tests",
"prepare-tests": "install-package-tests",
"test": ["@lint", "@phpcs", "@phpstan", "@phpunit", "@behat"]
},
"config": { "process-timeout": 1800 }
Bump process-timeout immediately. Behat installs WordPress from scratch per scenario and will blow past Composer's default 300s.
Add behat.yml at the root — mandatory on Windows, harmless everywhere else:
default:
suites:
default:
contexts:
- WP_CLI\Tests\Context\FeatureContext
paths:
- features
Then composer prepare-tests once (needs a MySQL/MariaDB client and a prepared test database), and write your first feature file. The first use case to reach for is a smoke test of a command you already trust manually:
Feature: My command
Scenario: It reports the site URL
Given a WP install
When I run `wp mycommand url`
Then STDOUT should be:
"""
http://example.com
"""
Run it with composer behat -- features/mycommand.feature. If that passes, you have a working harness and can start asserting on exit codes, JSON output, and generated files.
Composer scripts are the workflow, not the underlying binaries. Never call vendor/bin/behat directly. The wrappers set up paths, database config, and the WordPress version. Every argument you want to forward to the tool needs a -- separator, because Composer otherwise swallows it:
composer behat -- features/cli-info.feature
composer behat -- features/cli-info.feature:12 # single scenario by line
composer behat -- [email protected]
composer behat -- --stop-on-failure
composer behat-rerun # only last run's failures
composer phpunit -- --filter MyCommandTest
composer phpcs -- src/Command.php
Match the assertion step to the output format. The step vocabulary is the part you'll actually live in. Pick the tightest matcher and you stop writing brittle string comparisons:
Then STDOUT should be a table containing rows: for tabular command output.Then STDOUT should be JSON containing: / STDOUT should be a JSON array containing: for --format=json.STDOUT should be CSV containing: / YAML containing: for the other formatters — this is your cheap way of covering every --format implementation at once.STDOUT should be a version string >= 5.9 and the return code should be 0 for the unglamorous contract stuff.the wp-content/plugins/foo/foo.php file should contain: for anything that writes to disk.Use When I try ... whenever a non-zero exit is expected. This is the single most common beginner mistake. I run asserts success; I try lets you then assert the return code should be 1 and check STDERR. Model it as: happy path uses run, everything else uses try.
Set up expensive fixtures once per feature, not per scenario. Given a WP install dominates runtime. Combine Given a WP install, Given a database, and Given these installed and active plugins: into a Background: block per feature file, and split features by fixture rather than by topic.
Static analysis over the PHP inside your .feature docstrings. This is the sleeper feature. Add phpstan-feature-files.neon.dist and composer phpstan starts analysing the PHP snippets you embed, reporting errors against the feature file line numbers. Snippets are extracted to <feature>_L<first>_E<last>.php. Start with an empty file (defaults apply), then raise the level:
parameters:
level: 4
ignoreErrors:
-
identifier: function.notFound
path: */shutdown-handler.feature_L*.php
Because snippets are fixtures running in a WordPress the analyzer can't see, expect a steady trickle of ignores for deliberately-undefined functions. The equivalent for code style is automatic: composer phpcs already checks PHP docstrings attached to steps that create a .php file, and composer phpcbf fixes them in place. Override the shipped sniff set with phpcs-feature-files.xml in the package root.
Unit tests extend WP_CLI\Tests\TestCase, the same way Laravel tests extend the framework's base case. Put them in tests/, keep phpunit.xml.dist at the root, and reserve PHPUnit for pure logic (arg parsing, formatting helpers) — anything that needs WordPress belongs in Behat.
Control the WordPress version and source via environment, which is how you cover the support matrix without touching code:
WP_VERSION=trunk composer behat
WP_VERSION=6.4 composer behat
WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat # unreleased build
Tag the scenarios that care (@require-wp-5.0) and use --tags to select them, rather than duplicating feature files per version.
For CI and agent shells, neutralise the output before it ever runs:
export NO_COLOR=1
export WP_CLI_TEST_QUIET=1
WP_CLI_TEST_QUIET collapses PHPCS to file:line:col and PHPStan to file:line:message, no progress bars, no result table. Without these, captured logs are unreadable ANSI soup.
composer test is not a pre-commit check. It runs the full Behat suite, which installs WordPress per scenario — tens of minutes, plus network access to WordPress.org. Run the narrowest thing that covers your change: PHP file touched → composer phpcs -- <file> and composer phpstan; one feature → composer behat -- features/<name>.feature; one scenario → append :<line>. This mirrors the "run the narrowest check" discipline you'd apply to a Laravel test suite, only with a much harsher cost gradient.
A green composer test is not a green CI. actionlint and typos run separately on pull requests and aren't part of the aggregate script. Don't treat the local suite as authoritative.
Only commands declared in composer.json are runnable from Behat steps. If When I run \wp mycommand`` fails with "command not found", the answer is almost always a missing require or a stale autoloader, not a bug in your step definitions.
Feature-file PHPCS checks are silently skipped when you narrow to a path. composer phpcs -- src/ intentionally ignores the embedded blocks, because that argument is about the package's own files. So a path-scoped run will never tell you your docstrings are dirty — only the bare composer phpcs does. Easy to believe you're clean when you aren't.
PHPStan ignores for embedded blocks match the extracted filename, not the feature file. Use path: */mycommand.feature_L*.php. Two consequences worth internalising: an ignore pattern that matches nothing is not reported as unused, so stale ignores accumulate invisibly; and blocks are analysed in multiple runs (blocks declaring the same class/function are split apart to avoid name resolution collisions), so "no error" may just mean "not in this run". Verify by deleting the ignore and confirming the error reappears.
Blocks get excluded from analysis in two cases, listed at the end of each run: non-standalone PHP (placeholder syntax like get_the_title( {POST_ID} ), or deliberately broken code) and docstrings that don't belong to a .php-creating step and don't open with <?php. A single unparseable file aborts PHPStan entirely, so this filtering is load-bearing — if you add a snippet with an intentional syntax error, expect the whole analysis to skip it rather than flag it.
WP_VERSION affects Behat only. Every other suite runs without WordPress loaded. Don't expect WP_VERSION to change PHPUnit behaviour.
Version resolution is network-dependent but cached. latest and bare X.Y need the WordPress versions data, cached in the system temp dir for a day; offline runs fall back to the last known copy. WP_CLI_TEST_WP_VERSION_CACHE_TTL=0 forces a fetch. In air-gapped CI, seed or accept the stale cache rather than expecting a clean failure.
Gherkin lint needs Node 20+ via npx. Where npx is missing, the check reports a skip rather than failing — so a CI job that looks green may simply not be linting. Override the shipped rules with your own .gherkin-lintrc if the defaults fight your feature style; the package's own package.json exists only to pin that version.
Don't hand-edit the README. It's generated via wp scaffold package-readme from the docblocks; changes go in the source, not the markdown.
Extension points, in the order you'll need them: a custom Behat context class alongside WP_CLI\Tests\Context\FeatureContext in behat.yml for project-specific steps; phpcs.xml.dist with <rule ref="WP_CLI_CS"/> for style; phpstan-feature-files.neon.dist for snippet analysis; phpcs-feature-files.xml to replace the feature-docstring sniffs wholesale; .gherkin-lintrc for Gherkin rules. That's the whole surface — there's no plugin system beyond it.
On the ecosystem comparison: think of this as orchestra/testbench for WP-CLI. Testbench spins up a Laravel app so you can test a package against the real framework; this spins up a real WordPress install so you can test a command against the real CLI. The difference is cost — Testbench is seconds, this is minutes — so the discipline of tagging, Background: fixtures, --stop-on-failure, and behat-rerun isn't optional nicety, it's the difference between a usable loop and a suite nobody runs.
How can I help you explore Laravel packages today?