Skip to content
...

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, fragment methodName. 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:

php
$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:

php
$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:

php
$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 test2
  • paths: ['path1', 'path2'] → matches if path is path1 OR path2
  • suites: ['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 Unit
  • names: ['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:

php
$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=test

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:

php
#[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=db runs only tests in group db. Multiple --group values use OR logic.
  • Exclude — the ! prefix marks an exclusion: --group=!slow skips tests in group slow. Exclusion always wins over inclusion.
  • Group filters combine with name, path, suite, and type filters using AND logic.
bash
# 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=UserTest

Name 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:

php
$filter = new Filter(names: ['UserTest::testLogin']);
// Result: UserTest class with only testLogin method

FQN 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:

php
// 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 method

Narrowing 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

Examples:

php
// 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
  • 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 DataPointer into test metadata before execution
  • Makes DataPointer available to other interceptors
  • If no indices specified: no DataPointer is injected

Pattern Matching

FilterInterceptor\Testo\Filter\Internal\FilterInterceptor uses whole-word boundary matching with regex:

php
private static function has(string $needle, string $haystack): bool
{
    return \preg_match('/\\b' . \preg_quote($needle, '/') . '\\b$/', $haystack) === 1;
}

Behavior:

  • User matches App\User
  • User does NOT match App\UserManager
  • test matches testMethod
  • test does NOT match latestMethod