Filtering System
ProTest supports composable filters: suite, keyword, tag, and last-failed. All filters are plugins that hook into COLLECTION_FINISH.
Filter Chain
Filters compose via intersection (AND logic). Each filter reduces the test list for the next:
Collector.collect()
│
▼
SuiteFilterPlugin.on_collection_finish()
│
▼
KeywordFilterPlugin.on_collection_finish()
│
▼
TagFilterPlugin.on_collection_finish()
│
▼
CachePlugin.on_collection_finish()
│
▼
Filtered tests → Runner
Order matters. Selective filters (suite, keyword, tag) run before cache to ensure --lf only considers tests matching other filters.
Suite Filter
Filters tests by suite path, specified in the target.
protest run demo:session::API # Suite "API" and children
protest run demo:session::API::Users # Suite "API::Users" only
Implementation: protest/filters/suite.py
class SuiteFilterPlugin(PluginBase):
def on_collection_finish(self, items: list[TestItem]) -> list[TestItem]:
if not self.suite_path:
return items
return [
item for item in items
if item.suite_path and (
item.suite_path == self.suite_path
or item.suite_path.startswith(f"{self.suite_path}::")
)
]
- Matches exact path or prefix with
:: - Standalone tests (no suite) are excluded when filtering by suite
Keyword Filter
Filters by substring in test name (including case_ids for parameterized tests).
protest run demo:session -k "login" # Tests containing "login"
protest run demo:session -k "login" -k "auth" # OR logic: "login" OR "auth"
Implementation: protest/filters/keyword.py
class KeywordFilterPlugin(PluginBase):
def on_collection_finish(self, items: list[TestItem]) -> list[TestItem]:
if not self.keywords:
return items
def matches(item: TestItem) -> bool:
# Match against "test_name[case_id]"
name = item.test_name
if item.case_ids:
name = f"{name}[{','.join(item.case_ids)}]"
return any(kw.lower() in name.lower() for kw in self.keywords)
return [item for item in items if matches(item)]
- Case-insensitive matching
- Multiple keywords = OR logic
Tag Filter
Filters by test tags (including inherited tags from fixtures and suites).
protest run demo:session -t database # Tests with "database" tag
protest run demo:session -t slow -t unit # OR: "slow" OR "unit"
protest run demo:session --no-tag flaky # Exclude "flaky"
Implementation: protest/tags/plugin.py
See Tags for tag inheritance rules.
Cache Filter (--lf)
Re-runs only tests that failed in the previous run.
protest run demo:session --lf # Last-failed tests only
protest run demo:session --cache-clear # Clear cache first
Implementation: protest/cache/plugin.py
Behavior: - Empty cache or no failures → all tests run - Failures in cache → only matching failed tests run - No matching tests → 0 tests (no fallback)
Combining Filters
All filters compose:
# Suite + keyword
protest run demo:session::API -k "login"
# Suite + keyword + tag
protest run demo:session::API -k "login" -t "slow"
# Suite + keyword + tag + last-failed
protest run demo:session::API -k "login" -t "slow" --lf
Example reduction:
100 tests collected
│
├─ ::API filter → 40 tests (60 excluded)
├─ -k "login" → 12 tests (28 excluded)
├─ -t "slow" → 5 tests (7 excluded)
└─ --lf → 2 tests (3 passed last time)
Writing Custom Filters
Create a plugin implementing on_collection_finish:
class PriorityFilter(PluginBase):
"""Run high-priority tests first."""
name = "priority"
def on_collection_finish(self, items: list[TestItem]) -> list[TestItem]:
def priority(item: TestItem) -> int:
if "critical" in item.tags:
return 0
if "high" in item.tags:
return 1
return 2
return sorted(items, key=priority)
Return None to pass through unchanged. Return a modified list to filter or reorder.