name: testo-configure
description: Set 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".
Configuring Testo (testo.php)
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(includes, excludes) — when a flat array isn't enough (e.g. exclude module's own tests):
new FinderConfig(
includes: ['core', 'plugin', 'bridge'],
excludes: ['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=.
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 # all suites
vendor/bin/testo --suite=Unit # one suite
vendor/bin/testo --suite=Unit --suite=Integration # multiple
vendor/bin/testo --path=tests/Unit/Foo # subdirectory of a suite
vendor/bin/testo --filter='UserService' # by name
vendor/bin/testo --type=test # only #[Test], not benches/inline
vendor/bin/testo --coverage # force coverage on
vendor/bin/testo --no-coverage # force coverage off
vendor/bin/testo --coverage-clover=build/clover.xml # write Clover report (no testo.php needed)
vendor/bin/testo --coverage-cobertura=build/cobertura.xml # write Cobertura report
vendor/bin/testo --coverage-xml=build/coverage-xml # write coverage XML dir (Infection)
vendor/bin/testo --teamcity # TeamCity output (CI/IDE)
vendor/bin/testo --config=path/to/testo.php
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.