Skip to content

Commit b064b24

Browse files
authored
Merge pull request #83 from buildkite/te-5135-add-custom-option-to-filter-tests-by-execution-tag-in-pytest
Add `--tag-filters` option to filter tests using `execution_tag` marker
2 parents 8c1b40c + c64ec8e commit b064b24

5 files changed

Lines changed: 206 additions & 1 deletion

File tree

src/buildkite_test_collector/pytest_plugin/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def pytest_unconfigure(config):
4141

4242
if plugin:
4343
api = API(os.environ)
44-
numprocesses = config.getoption("numprocesses")
44+
numprocesses = config.getoption("numprocesses", None)
4545
xdist_enabled = (
4646
config.pluginmanager.getplugin("xdist") is not None
4747
and numprocesses is not None
@@ -89,3 +89,10 @@ def pytest_addoption(parser):
8989
dest="mergejson",
9090
help='merge json output with existing file, if it exists'
9191
)
92+
group.addoption(
93+
'--tag-filters',
94+
default=None,
95+
action='store',
96+
dest="tag_filters",
97+
help='filter tests by execution_tag with `key:value`, e.g. `--tag-filters color:red`'
98+
)

src/buildkite_test_collector/pytest_plugin/buildkite_plugin.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ def __init__(self, payload):
1717
self.in_flight = {}
1818
self.spans = {}
1919

20+
def pytest_collection_modifyitems(self, config, items):
21+
"""pytest_collection_modifyitems hook callback to filter tests by execution_tag markers"""
22+
tag_filter = config.getoption("tag_filters")
23+
if not tag_filter:
24+
return
25+
26+
filtered_items, unfiltered_items = self._filter_tests_by_tag(items, tag_filter)
27+
28+
config.hook.pytest_deselected(items=unfiltered_items)
29+
items[:] = filtered_items
30+
2031
def pytest_runtest_logstart(self, nodeid, location):
2132
"""pytest_runtest_logstart hook callback"""
2233
logger.debug('hook=pytest_runtest_logstart nodeid=%s', nodeid)
@@ -144,3 +155,32 @@ def save_payload_as_json(self, path, merge=False):
144155

145156
with open(path, "w", encoding="utf-8") as f:
146157
json.dump(data, f)
158+
159+
def _filter_tests_by_tag(self, items, tag_filter):
160+
"""
161+
Filters tests based on the tag_filter option.
162+
Supports filtering by a single tag in the format key:value.
163+
Only equality comparison is supported.
164+
Returns a tuple of (filtered_items, unfiltered_items).
165+
"""
166+
key, _, value = tag_filter.partition(":")
167+
168+
filtered_items = []
169+
unfiltered_items = []
170+
for item in items:
171+
# Extract all execution_tag markers and store them in a dict
172+
tags = {}
173+
markers = item.iter_markers("execution_tag")
174+
for tag_marker in markers:
175+
# Ensure the marker has exactly two arguments: key and value
176+
if len(tag_marker.args) != 2:
177+
continue
178+
179+
tags[tag_marker.args[0]] = tag_marker.args[1]
180+
181+
if tags.get(key) == value:
182+
filtered_items.append(item)
183+
else:
184+
unfiltered_items.append(item)
185+
186+
return filtered_items, unfiltered_items
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This is sample test file used for integration testing of execution_tag marker
2+
import pytest
3+
4+
5+
@pytest.mark.execution_tag("language.version", "3.12")
6+
@pytest.mark.execution_tag("team", "backend")
7+
def test_with_multiple_tags():
8+
assert True
9+
10+
@pytest.mark.execution_tag("team", "frontend")
11+
def test_with_single_tag():
12+
assert True
13+
14+
def test_without_tags():
15+
assert True
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# This is sample test file used for integration testing of execution_tag marker
2+
import pytest
3+
4+
5+
@pytest.mark.execution_tag("color", "red")
6+
@pytest.mark.execution_tag("size", "medium")
7+
def test_apple():
8+
assert True
9+
10+
@pytest.mark.execution_tag("color", "orange")
11+
@pytest.mark.execution_tag("size", "medium")
12+
def test_orange():
13+
assert True
14+
15+
@pytest.mark.execution_tag("color", "yellow")
16+
@pytest.mark.execution_tag("size", "large")
17+
def test_banana():
18+
assert True
19+
20+
@pytest.mark.execution_tag("color", "purple")
21+
@pytest.mark.execution_tag("size", "small")
22+
def test_grape():
23+
assert True
24+
25+
@pytest.mark.execution_tag("color", "red")
26+
@pytest.mark.execution_tag("size", "small")
27+
def test_strawberry():
28+
assert True
29+
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
import os
3+
import pytest
4+
import subprocess
5+
import sys
6+
7+
from pathlib import Path
8+
9+
def test_add_tag_to_execution_data(tmp_path, fake_env):
10+
"""Verify that tags added via the execution_tag marker are correctly captured in the test data."""
11+
test_file = Path(__file__).parent / "data" / "test_sample_execution_tag.py"
12+
json_output_file = tmp_path / "test_results.json"
13+
14+
# Run pytest with our plugin on the test file
15+
cmd = [
16+
sys.executable, "-m", "pytest",
17+
str(test_file),
18+
f"--json={json_output_file}",
19+
]
20+
21+
# Run pytest in a subprocess
22+
result = subprocess.run(cmd, capture_output=True, text=True)
23+
if result.returncode != 0:
24+
print(result.stdout)
25+
print(result.stderr)
26+
pytest.fail("pytest run failed, see output above")
27+
28+
assert json_output_file.exists(), "JSON output file was not created"
29+
30+
with open(json_output_file, 'r') as f:
31+
test_results = json.load(f)
32+
33+
tests_by_name = {test["name"]: test for test in test_results}
34+
35+
multi_tag_test = tests_by_name["test_with_multiple_tags"]
36+
assert "tags" in multi_tag_test
37+
assert multi_tag_test["tags"] == {
38+
"language.version": "3.12",
39+
"team": "backend"
40+
}
41+
42+
single_tag_test = tests_by_name["test_with_single_tag"]
43+
assert "tags" in single_tag_test
44+
assert single_tag_test["tags"] == {"team": "frontend"}
45+
46+
no_tag_test = tests_by_name["test_without_tags"]
47+
assert "tags" not in no_tag_test or no_tag_test.get("tags") == {}
48+
49+
class TestTagFiltering:
50+
import subprocess
51+
import sys
52+
53+
def test_filter_by_single_tag(self,tmp_path, fake_env):
54+
test_file = Path(__file__).parent / "data" / "test_sample_execution_tag_filter.py"
55+
56+
cmd = [
57+
sys.executable, "-m", "pytest", "--co", "-q", "--tag-filters", "color:red",
58+
str(test_file),
59+
]
60+
61+
# Run pytest in a subprocess
62+
result = subprocess.run(cmd, cwd=str(tmp_path), capture_output=True, text=True)
63+
64+
assert "2/5 tests collected" in result.stdout, "collect count mismatch"
65+
66+
# Parse the output to find collected tests
67+
lines = result.stdout.strip().splitlines()
68+
collected_tests = []
69+
for line in lines:
70+
if "::" in line:
71+
collected_tests.append(line)
72+
73+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_apple" in collected_tests
74+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_strawberry" in collected_tests
75+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_orange" not in collected_tests
76+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_banana" not in collected_tests
77+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_grape" not in collected_tests
78+
79+
def test_wrong_filter_format(self,tmp_path, fake_env):
80+
test_file = Path(__file__).parent / "data" / "test_sample_execution_tag_filter.py"
81+
82+
cmd = [
83+
sys.executable, "-m", "pytest", "--co", "-q", "--tag-filters", "foobar",
84+
str(test_file),
85+
]
86+
87+
# Run pytest in a subprocess
88+
result = subprocess.run(cmd, cwd=str(tmp_path), capture_output=True, text=True)
89+
90+
assert "no tests collected" in result.stdout
91+
92+
def test_no_filter(self,tmp_path, fake_env):
93+
test_file = Path(__file__).parent / "data" / "test_sample_execution_tag_filter.py"
94+
95+
cmd = [
96+
sys.executable, "-m", "pytest", "--co", "-q",
97+
str(test_file),
98+
]
99+
100+
# Run pytest in a subprocess
101+
result = subprocess.run(cmd, cwd=str(tmp_path), capture_output=True, text=True)
102+
103+
# Parse the output to find collected tests
104+
lines = result.stdout.strip().splitlines()
105+
collected_tests = []
106+
for line in lines:
107+
if "::" in line:
108+
collected_tests.append(line)
109+
110+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_apple" in collected_tests
111+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_strawberry" in collected_tests
112+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_orange" in collected_tests
113+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_banana" in collected_tests
114+
assert "tests/buildkite_test_collector/data/test_sample_execution_tag_filter.py::test_grape" in collected_tests

0 commit comments

Comments
 (0)