Configuring Testo (testo.php)
SkillDev toolsSet up or edit `testo.php` — the Testo application config. Use when the user is bootstrapping a project (including running `vendor/bin/testo init`), adding/removing a suite, scoping a finder, wiring an application-wide plugin (coverage, JUnit), or asking "where do I configure Testo" / "how do I initialize Testo".
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Configuring Testo (testo.php) skill
What this skill tells your AI
The instructions your AI receives, as published by php-testo/testo in skills/testo-configure/SKILL.md and read by ahel’s review.
Testo's config is a real PHP file at the project root returning an ApplicationConfig. No XML, no JSON.
This means: full IDE completion, refactoring, and conditional logic (e.g. CI-only suites).
Fetch https://php-testo.github.io/llms.txt before introducing new classes — the namespaces here are
the most commonly drifted-on detail.
Bootstrap with init
If the project has no testo.php yet, prefer the built-in command over hand-writing the file:
vendor/bin/testo init
vendor/bin/testo init --path=app
vendor/bin/testo init --no-interaction
What it does, in order:
- Ensures the base directory (
--path, default.) exists.--pathis treated as the project root — every subsequent lookup (src/,tests/,composer.json) and every path baked into the generatedtesto.phpis resolved relative to it. - Resolves the source directory under
--path:- if
<path>/srcexists, uses it; - otherwise in interactive mode prompts for a directory (default
src, must exist under--path); - otherwise (non-interactive) fails — create
<path>/srcfirst or run interactively. The path is written into the config relative totesto.php, so asrcentry resolves back to<path>/srcat runtime, regardless of wherevendor/bin/testois invoked from.
- if
- Creates
<path>/tests/and scans it for known suite folders:Unit,Integration,Functional,Acceptance,Feature,E2E,Contract. Whatever exists is picked up;Unitis always added (and<path>/tests/Unit/created if missing). - Writes scripts to the
composer.jsoncolocated with--path(so a monorepo sub-app updates its own composer.json, not the parent one). If nocomposer.jsonis present at that path the step is skipped silently.composer test→vendor/bin/testocomposer test:unit,composer test:integration, … one per detected suite (vendor/bin/testo --suite=<Name>). Existing keys are preserved.
- Generates
<path>/testo.phpfrom the stub, withsrcand oneSuiteConfigper detected suite. If the file already exists: prompts to overwrite (interactive) or skips with a warning (non-interactive).
When to use init vs. hand-editing:
- New project / empty repo → run
initfirst, then tunetesto.php. - Adding a suite to an existing project → create the directory (e.g.
tests/Integration) and re-runinitto get the matching composer script, or edittesto.phpdirectly.initwill not overwrite an existing config unless confirmed. - Monorepo / sub-app layout (a self-contained app under
app/, with its ownsrc/, tests, and optionallycomposer.json) →vendor/bin/testo init --path=app.--pathis the sub-app's project root:app/src/must exist, and the generatedapp/testo.phpreferencessrcandtests/<Suite>as paths relative to itself — so they resolve correctly regardless of wherevendor/bin/testois invoked from.
After init completes, re-read testo.php and adjust src, suite locations, and plugins to match the project (see sections below).
Minimal testo.php
<?php
declare(strict_types=1);
use Testo\Application\Config\ApplicationConfig;
use Testo\Application\Config\SuiteConfig;
return new ApplicationConfig(
src: ['src'],
suites: [
new SuiteConfig(name: 'Unit', location: ['tests/Unit']),
],
);
Run it: vendor/bin/testo. The single suite Unit will be discovered under tests/Unit.
Anatomy
Suite is the plugin-scope boundary. A Test Suite is a named, configured collection of Test Cases (a Test Case = methods of one class or functions of one file). Different suites can carry different plugin sets — that's the whole reason
SuiteConfig::$pluginsandSuitePlugins::only(...)exist.
ApplicationConfig takes:
src— directories (string[] orFinderConfig) holding production code. Used by coverage and inline tests.suites— array ofSuiteConfig, one per logical test area.plugins— application-wide plugins (coverage, JUnit, anything cross-cutting).
SuiteConfig takes:
name— string, must be unique. Selectable via--suite=<name>.location— directories orFinderConfigfor the suite's test files.plugins— suite-specific plugins, orSuitePlugins::only(...)to override inherited application plugins.
FinderConfig(include, exclude) — when a flat array isn't enough (e.g. exclude module's own tests):
new FinderConfig(
include: ['core', 'plugin', 'bridge'],
exclude: ['plugin/assert/tests', 'plugin/codecov/tests'],
);
Multi-suite layout
A typical real-world testo.php:
<?php
declare(strict_types=1);
use Testo\Application\Config\ApplicationConfig;
use Testo\Application\Config\FinderConfig;
use Testo\Application\Config\SuiteConfig;
use Testo\Application\Config\Plugin\SuitePlugins;
use Testo\Codecov\CodecovPlugin;
use Testo\Codecov\Config\CoverageLevel;
use Testo\Codecov\Report\CloverReport;
use Testo\Inline\InlineTestPlugin;
return new ApplicationConfig(
src: ['src'],
suites: [
new SuiteConfig(name: 'Unit', location: ['tests/Unit']),
new SuiteConfig(name: 'Integration', location: ['tests/Integration']),
new SuiteConfig(
name: 'Sources',
location: ['src'],
plugins: SuitePlugins::only(new InlineTestPlugin()),
),
],
plugins: [
new CodecovPlugin(
level: CoverageLevel::Line,
reports: [
new CloverReport(__DIR__ . '/runtime/clover.xml', 'MyProject'),
],
),
],
);
Notes on the example:
Sourcesscans the production tree to pick up#[TestInline]cases;SuitePlugins::onlyensures onlyInlineTestPluginruns for that suite.CodecovPluginis application-wide → it applies to every suite.- Names are arbitrary; keep them short — they appear in CI logs and in
--suite=.
Reports on every run
Output plugins are application-wide. Add them to plugins: when a report should be produced by every run
rather than only when a flag asks for it:
use Testo\Output\Html\HtmlPlugin;
use Testo\Output\JUnit\JUnitPlugin;
plugins: [
// runtime/report/index.html; a path ending in .html inlines everything into that one file instead
new HtmlPlugin(),
new JUnitPlugin(__DIR__ . '/build/junit.xml'),
],
HtmlPlugin takes a second path for the report's own JSON document
(new HtmlPlugin('runtime/report', 'runtime/report.json')) — the HTML is a view over that
document, and CI or external tooling can consume it on its own. The HTML report opens over file://
with no server and reaches no network.
Conditional / dynamic config
Because testo.php is real PHP, conditional logic is fine:
$ciOnly = \filter_var(\getenv('CI'), FILTER_VALIDATE_BOOLEAN);
return new ApplicationConfig(
src: ['src'],
suites: \array_merge(
[new SuiteConfig(name: 'Unit', location: ['tests/Unit'])],
$ciOnly ? [] : [new SuiteConfig(name: 'Sandbox', location: ['tests/Sandbox'])],
),
);
Keep it readable — if the logic gets long, extract a helper, don't pile up ternaries.
CLI cheat-sheet
vendor/bin/testo init # bootstrap testo.php + composer scripts
vendor/bin/testo init --path=app # bootstrap inside a subdirectory
vendor/bin/testo --config=path/to/testo.php # run with a config outside the project root
Running and filtering (--json, --suite, --filter, --path, --group, --type, report
outputs) are covered by the testo-run-tests skill; coverage flags by the testo-coverage skill.
Pitfalls
srcmust include production code only, not tests. Otherwise inline tests will run against test fixtures and coverage will count test files.SuitePlugins::only(...)replaces inherited plugins. If the suite still needs coverage, addCodecovPluginto theonly()list too.- Don't name two suites the same. The first one wins silently in some builds — keep names unique.
- Don't shell out to
composer dump-autoloadfromtesto.php. Composer's autoloader is already booted by the CLI entry. Avoid side-effects in config. - Don't read environment variables without a fallback.
\getenv('FOO') ?: 'default'— CI dropouts otherwise silently change which suites run. testo.phpis required. If a project doesn't have one, runvendor/bin/testo init(see Bootstrap withinit) or, for full control, write the minimal version above by hand.- An empty run is not a success. Zero discovered tests → status risky, non-zero exit (details in the
testo-run-testsskill). On the config side the usual causes are a suitelocationpointing at the wrong directory or an empty suite.
Signals
- GitHub stars
- 213
- Forks
- 16
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
testo-configure- Source
- github.com/php-testo/testo