Test Filtering
This document describes the internal logic of the filtering plugin: the algorithm, pipeline stages, and criteria combination. If you just need to filter tests when running — see the CLI reference.
Plugin class: FilterPlugin\Testo\Filter\FilterPlugin. Included in ApplicationPlugins\Testo\Application\Config\Plugin\ApplicationPlugins — enabled by default.
Overview
Testo provides a flexible filtering system that operates in multiple stages to progressively narrow down the test set. Filtering can be controlled programmatically via the Filternew Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null, array $groups = [], array $excludeGroups = [])Immutable DTO containing test filtering criteria. class or automatically from CLI arguments.
Filter
Immutable DTO containing test filtering criteria.
new Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null, array $groups = [], array $excludeGroups = [])Parameters:
$suites- Test suite names to filter by.
$names- Class, method, or function names. Formats:
ClassName::methodName,Namespace\ClassName, fragmentmethodName. Optional DataProvider indices via colon:name:providerIndex:datasetIndex. $paths- File or directory paths. Supports glob patterns:
*,?,[abc]. $type- Test type:
test,inline,bench, or other custom type. If not specified — all types are run. $groups- Group names to include (OR logic). A test matches if it belongs to any of these groups. See #[Group]
#[Group(string ...$names)]Labels a class, method, or function with one or more group names for selective filtering.. $excludeGroups- Group names to exclude. A test in any of these groups is skipped — exclusion wins over inclusion.
Examples:
$filter = new Filter(
suites: ['Unit', 'Integration'],
names: ['UserTest::testLogin', 'testAuthentication'],
paths: ['tests/Unit/*', 'tests/Integration/*'],
type: 'test',
groups: ['database'],
excludeGroups: ['slow'],
);Usage
Via CLI options — when creating via Application::createFromInput(), the plugin automatically creates Filternew Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null, array $groups = [], array $excludeGroups = [])Immutable DTO containing test filtering criteria. from command options: --filter, --path, --suite, --type, --group:
$app = Application::createFromInput(
inputOptions: ['filter' => ['UserTest'], 'suite' => ['Unit']],
);
$result = $app->run();Via container — register the Filternew Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null, array $groups = [], array $excludeGroups = [])Immutable DTO containing test filtering criteria. object directly:
$app = Application::createFromConfig($config);
$app->getContainer()->set(Filter::class, new Filter(
suites: ['Unit'],
names: ['UserTest'],
));
$result = $app->run();When running from CLI, the Filter\Testo\Common\Filter is populated automatically from command arguments via Filter::fromScope().
Filter Combination Logic
Same Type: OR Logic
Multiple values within the same filter type are combined with OR logic:
names: ['test1', 'test2']→ matches if name is test1 OR test2paths: ['path1', 'path2']→ matches if path is path1 OR path2suites: ['Unit', 'Integration']→ matches if suite is Unit OR Integration
Different Types: AND Logic
Different filter types are combined with AND logic:
names: ['test1'], suites: ['Unit']→ matches if name is test1 AND suite is Unitnames: ['UserTest'], paths: ['tests/Unit/*']→ matches if name is UserTest AND path matches tests/Unit/*names: ['test1'], type: 'inline'→ matches if name is test1 AND type is inline
Formula: AND(OR(names), OR(paths), OR(suites), type, OR(groups), NOT OR(excludeGroups))
Example:
$filter = new Filter(
names: ['test1', 'test2'], // test1 OR test2
paths: ['path1', 'path2'], // path1 OR path2
suites: ['Unit', 'Critical'], // Unit OR Critical
type: 'test', // regular tests only
);
// Result: (test1 OR test2) AND (path1 OR path2) AND (Unit OR Critical) AND type=testFiltering by Groups
Groups are flat string labels you attach to tests with the #[Group]#[Group(string ...$names)]Labels a class, method, or function with one or more group names for selective filtering. attribute. Unlike names and paths, they don't depend on how a test is named or where it lives — you mark tests by category (db, slow, integration) and then run or skip whole categories at once.
#[Group]
Labels a class, method, or function with one or more group names for selective filtering.
#[Group(string ...$names)]The attribute is variadic — pass several group names at once. Groups have no key/value semantics; they're plain string labels.
The effective group set of a test is the union of all groups reachable from it: the test method (including any parent method it overrides), the test class, its parent classes, and traits. So a group declared on a class is inherited by every test in it.
Parameters:
$names- Group labels to assign.
Examples:
#[Test]
#[Group('integration')]
final class OrderTest
{
public function createsOrder(): void {} // groups: integration
#[Group('slow')]
public function importsLargeDataset(): void {} // groups: integration, slow
}Selection happens through the --group CLI flag (or the $groups / $excludeGroups properties of the Filternew Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null, array $groups = [], array $excludeGroups = [])Immutable DTO containing test filtering criteria. DTO):
- Include —
--group=dbruns only tests in groupdb. Multiple--groupvalues use OR logic. - Exclude — the
!prefix marks an exclusion:--group=!slowskips tests in groupslow. Exclusion always wins over inclusion. - Group filters combine with name, path, suite, and type filters using AND logic.
# Only tests in the "database" group
testo run --group=database
# Tests in "database" OR "unit"
testo run --group=database --group=unit
# Everything except the "slow" group
testo run --group=!slow
# Combine with a name filter (AND)
testo run --group=database --filter=UserTestName Filter Behavior
The behavior of name filtering is implemented in FilterInterceptor\Testo\Filter\Internal\FilterInterceptor and depends on the name format:
Method Format (ClassName::methodName)
When using method format with :: separator:
- Only the specified method is matched
- Other methods in the same class are excluded
- Result: Test case with only the specified method
Example:
$filter = new Filter(names: ['UserTest::testLogin']);
// Result: UserTest class with only testLogin methodFQN or Fragment Format
When using FQN (with \) or simple fragment (no separators):
Case 1: Class name matches
- Result: Entire test case with all methods
Case 2: Class name doesn't match
- System checks individual methods/functions
- Result: Test case with only matched methods
- If no methods match: Test case is skipped
Examples:
// FQN - matches entire class
$filter = new Filter(names: ['Tests\Unit\UserTest']);
// Result: UserTest class with all methods
// Fragment - matches entire class
$filter = new Filter(names: ['UserTest']);
// Result: UserTest class with all methods
// Fragment - matches method in any class
$filter = new Filter(names: ['testLogin']);
// Result: All classes with testLogin method, each with only that methodNarrowing by DataProvider and DataSet
After the name, you can narrow down to a specific DataProvider via colon, and further to a specific DataSet within it via another colon.
Format: name:providerIndex:datasetIndex
- The format maps to DataPointer
\Testo\Filter\DataPointerand is passed to the data provider module. - "Provider" here means any attribute that spawns a separate test: #[DataProvider]
#[DataProvider(callable|string $provider)]Provides data for a parameterized test from a method or callable., #[DataSet]#[DataSet(array $arguments, ?string $name = null)]Declares a set of arguments for a parameterized test. Can be used multiple times — each attribute creates a separate test run., #[TestInline]#[TestInline(array $arguments, mixed $result = null)]Declares an inline test on a method or function.,#[Bench], etc. - Indices are 0-based, independent of dataset labels.
datasetIndexis optional — you can specify only the provider.- Works with all name formats (method, FQN, fragment).
Examples:
// First provider
$filter = new Filter(names: ['UserTest::testLogin:0']);
// First provider, second dataset
$filter = new Filter(names: ['UserTest::testLogin:0:1']);
// Second provider, fourth dataset — for any test named testAuth
$filter = new Filter(names: ['testAuth:1:3']);
// First provider — for entire UserTest class
$filter = new Filter(names: ['UserTest:0']);Filtering Pipeline
Filtering operates in five stages:
Stage 1: Suite Filter (Configuration Level)
Input: Filter::$suites
- Filters configuration scopes based on suite names
- Each suite defines file scanning locations and patterns
- Determines initial set of directories to scan
- Multiple suites use OR logic
Stage 2: Path Filter (Finder Level)
Input: Filter::$paths
- Applied at file finder level during directory scanning
- Uses glob patterns to match file paths
- Supports wildcards:
*,?,[abc] - Multiple paths use OR logic
- Returns list of files to be processed
Stage 3: File Filter (Tokenizer Level)
Input: Filter::$namesImplementation: FilterInterceptor::locateFile()
- Pre-filters test files before loading for reflection
- Uses lightweight tokenization instead of full reflection
- Checks if file contains any matching classes, methods, or functions
- Skips files that don't match any patterns
- Multiple names use OR logic
Stage 4: Test Filter (Reflection Level)
Input: Filter::$namesImplementation: FilterInterceptor::locateTestCases()
- Filters individual test cases and methods after reflection
- Implements hierarchical filtering:
- For method format (
::) - filters specific methods only - For FQN/fragment format - checks class name first, then methods
- For method format (
- Extracts DataProvider indices and associates them with matched tests
- Returns filtered test case definitions ready for execution
Stage 5: DataProvider Injection (Execution Level)
Input: DataProvider indices from Stage 4 Implementation: FilterInterceptor::runTest()
- Injects
DataPointerinto test metadata before execution - Makes
DataPointeravailable to other interceptors - If no indices specified: no
DataPointeris injected
Pattern Matching
FilterInterceptor\Testo\Filter\Internal\FilterInterceptor uses whole-word boundary matching with regex:
private static function has(string $needle, string $haystack): bool
{
return \preg_match('/\\b' . \preg_quote($needle, '/') . '\\b$/', $haystack) === 1;
}Behavior:
UsermatchesApp\User✓Userdoes NOT matchApp\UserManager✗testmatchestestMethod✓testdoes NOT matchlatestMethod✗