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 = [], array $type = [], array $notType = [], 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 = [], array $type = [], array $notType = [], 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 types to include (OR logic):
test,inline,bench, or custom. An empty list runs all types. See#[TestType]. $notType- Test types to exclude. A test of such a type is skipped — exclusion wins over inclusion.
$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'],
notType: ['bench'],
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 = [], array $type = [], array $notType = [], 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 = [], array $type = [], array $notType = [], 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), OR(type), NOT OR(notType), 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 Type
A type is the kind of test: test (regular #[Test]), inline (#[TestInline]#[TestInline(array $arguments, mixed $result = null)]Declares an inline test on a method or function.), bench (#[Bench]), or custom. See #[TestType].
Unlike groups, the type filter does not work per individual test — it works at the pipeline level: it selects which locator interceptors (and other middleware) take part in the pipeline based on their declared type (#[InterceptorOptions], the testType parameter). Every locator that produces tests of a given type declares that type; universal middleware (no testType) always runs.
Selection is done via the --type CLI flag (or the $type / $notType properties of the Filternew Filter(array $suites = [], array $names = [], array $paths = [], array $type = [], array $notType = [], array $groups = [], array $excludeGroups = [])Immutable DTO containing test filtering criteria. DTO):
- Inclusion —
--type=benchkeeps only thebenchtype. Multiple--typevalues combine with OR. - Exclusion — a leading
!means exclusion:--type=!benchruns everything except benches. Exclusion always wins over inclusion. - Type filters combine with name, path, Test Suite, and group filters using AND.
# Benchmarks only
testo run --type=bench
# Regular tests OR inline tests
testo run --type=test --type=inline
# Everything except benches
testo run --type=!benchA type passes when
A type t passes when it is in the include list (or the include list is empty) and it is not in the exclude list. An interceptor stays in the pipeline when it is universal (no testType) or at least one of its declared types passes.
Filtering 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 = [], array $type = [], array $notType = [], 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✗