Skip to content

Commit 7188444

Browse files
authored
Add checks for test id length and content (#21)
1 parent c2aeed0 commit 7188444

File tree

7 files changed

+128
-21
lines changed

7 files changed

+128
-21
lines changed

.github/workflows/tox.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
---
22
name: tox
3+
34
on:
4-
create: # is used for publishing to PyPI and TestPyPI
5-
tags: # any tag regardless of its name, no branches
6-
- "**"
75
push: # only publishes pushes to the main branch to TestPyPI
86
branches: # any integration branch but not tag
97
- "main"
@@ -38,6 +36,7 @@ jobs:
3836
devel
3937
build:
4038
name: ${{ matrix.name }}
39+
environment: test
4140
runs-on: ${{ matrix.os || 'ubuntu-22.04' }}
4241
needs:
4342
- prepare
@@ -48,7 +47,7 @@ jobs:
4847
fail-fast: false
4948
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
5049
env:
51-
PYTEST_REQPASS: 1
50+
PYTEST_REQPASS: 4
5251

5352
steps:
5453
- uses: actions/checkout@v4

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ files, that makes it harder to identify and copy/paste the test name in order
3333
to reproduce the failure locally. That is why this plugin forces its users to
3434
avoid having the same function name anywhere in the tested project.
3535

36+
You can disable this check by defining `PYTEST_CHECK_TEST_DUPLICATE=0`.
37+
38+
## Avoiding problematic test identifiers
39+
40+
This plugin will raise errors when it encounters test IDs that are either too
41+
long or that contain unsafe characters. While pytest is very flexible in allowing
42+
a wide range of test IDs, using these does make development harder as it prevents
43+
people from doing a copy/paste with failed test and pasting in in their terminal
44+
to reproduce the failed test locally.
45+
46+
You can disable regex check by defining `PYTEST_CHECK_TEST_ID_REGEX=0`.
47+
48+
You can disable the length check by defining `PYTEST_MAX_TEST_ID_LENGTH=0`.
49+
3650
## Release process
3751

3852
Releases are triggered from [GitHub Releases](https://github.com/pytest-dev/pytest-plus/releases)

pyproject.toml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ classifiers = [
4141
"Topic :: Utilities",
4242
]
4343
keywords = ["testing", "pytest", "plugin"]
44-
dependencies = ["pytest>=6.0.1"]
44+
dependencies = ["pytest>=7.4.2"]
4545

4646
[project.urls]
4747
homepage = "https://github.com/pytest-dev/pytest-plus"
@@ -55,15 +55,22 @@ test = ["coverage>=7.0.0", "pytest-html"]
5555
plus = "pytest_plus"
5656

5757
[tool.coverage.run]
58-
omit = ["tests/*"]
58+
omit = ["test/*", "/private/var/folders/*", "/tmp/*"]
5959

6060
[tool.coverage.report]
61-
fail_under = 81
62-
omit = ["tests/*"]
61+
fail_under = 100
62+
omit = ["test/*", "/private/var/folders/*", "/tmp/*"]
63+
show_missing = true
6364

6465
[tool.pytest.ini_options]
6566
addopts = "-p no:flaky"
6667

68+
filterwarnings = [
69+
"error",
70+
"ignore:ast.(Str|Num|NameConstant) is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite",
71+
"ignore:Attribute s is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite",
72+
]
73+
6774
[tool.ruff]
6875
ignore = [
6976
"D203", # incompatible with D211

requirements.txt

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
#
55
# pip-compile --extra=test --no-annotate --output-file=requirements.txt --strip-extras pyproject.toml
66
#
7-
attrs==22.1.0
8-
coverage==7.0.0
9-
exceptiongroup==1.0.4
10-
iniconfig==1.1.1
11-
packaging==22.0
12-
pluggy==1.0.0
13-
py==1.11.0
14-
pytest==7.2.0
15-
pytest-html==3.2.0
16-
pytest-metadata==2.0.4
7+
coverage==7.3.2
8+
exceptiongroup==1.1.3
9+
iniconfig==2.0.0
10+
jinja2==3.1.2
11+
markupsafe==2.1.3
12+
packaging==23.2
13+
pluggy==1.3.0
14+
pytest==7.4.2
15+
pytest-html==4.0.2
16+
pytest-metadata==3.0.0
1717
tomli==2.0.1

src/pytest_plus/__init__.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
"""PyTest Config File."""
2+
from __future__ import annotations
23

34
import os
5+
import re
46

57
import pytest
68
from _pytest.terminal import TerminalReporter
79

10+
PYTEST_CHECK_TEST_DUPLICATE = int(os.environ.get("PYTEST_CHECK_TEST_DUPLICATE", "1"))
11+
12+
13+
def get_max_test_id_length() -> int:
14+
"""Return max test id length."""
15+
return int(os.environ.get("PYTEST_MAX_TEST_ID_LENGTH", "60"))
16+
17+
18+
def get_test_id_regex() -> None | re.Pattern[str]:
19+
"""Return regex to use for checking test ids."""
20+
if int(os.environ.get("PYTEST_CHECK_TEST_ID_REGEX", "1")):
21+
return re.compile(r"^\w+$")
22+
return None
23+
824

925
def pytest_sessionfinish(session: pytest.Session) -> None:
1026
"""Assure passed test match PYTEST_REQPASS value.
@@ -15,7 +31,7 @@ def pytest_sessionfinish(session: pytest.Session) -> None:
1531
"""
1632
terminalreporter = session.config.pluginmanager.get_plugin("terminalreporter")
1733
if not isinstance(terminalreporter, TerminalReporter):
18-
raise TypeError
34+
raise TypeError # pragma: no cover
1935
req_passed = int(os.environ.get("PYTEST_REQPASS", "0"))
2036
if req_passed and not session.config.option.collectonly:
2137
passed = 0
@@ -35,14 +51,26 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
3551
"""Ensure testing fails if tests have duplicate names."""
3652
errors = []
3753
names = {}
54+
max_test_id_length = get_max_test_id_length()
55+
test_id_regex = get_test_id_regex()
3856
for item in items:
3957
base_name = item.name.split("[")[0]
4058
if base_name not in names:
4159
names[base_name] = item.location
42-
elif item.location[:2] != names[base_name][:2]:
60+
elif item.location[:2] != names[base_name][:2] and PYTEST_CHECK_TEST_DUPLICATE:
4361
error = f"Duplicate test name '{base_name}', found at {item.location[0]}:{item.location[1]} and {names[base_name][0]}:{names[base_name][1]}"
4462
if error not in errors:
4563
errors.append(error)
64+
if hasattr(item, "callspec"):
65+
test_id = item.callspec.id
66+
if max_test_id_length and len(test_id) > max_test_id_length:
67+
errors.append(
68+
f"{item} has an id that looks above {max_test_id_length} characters.",
69+
)
70+
elif test_id_regex and not test_id_regex.match(test_id):
71+
errors.append(
72+
f"Test {item} has an id that does not match our safe pattern '{test_id_regex.pattern}' for use with a terminal.",
73+
)
4674
if errors:
4775
msg = f"Failed run due to following issues being identified:\n{os.linesep.join(errors)}"
4876
raise pytest.UsageError(

test/test_plugin.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Tests."""
2+
import os
3+
24
import pytest
35

46
pytest_plugins = ["pytester"]
@@ -29,3 +31,59 @@ def test_a():
2931
== "Duplicate test name 'test_a', found at test_two.py:0 and test_one.py:0"
3032
)
3133
assert result.ret == pytest.ExitCode.USAGE_ERROR
34+
35+
36+
@pytest.mark.parametrize(
37+
("rc", "disable"),
38+
[
39+
pytest.param(pytest.ExitCode.USAGE_ERROR, False, id="0"),
40+
pytest.param(pytest.ExitCode.OK, True, id="1"),
41+
],
42+
)
43+
def test_check_test_id(pytester: pytest.Pytester, rc: int, *, disable: bool) -> None:
44+
"""Validates that we can detect duplicate test names."""
45+
if disable:
46+
os.environ["PYTEST_CHECK_TEST_ID_REGEX"] = "0"
47+
p1 = pytester.makepyfile(
48+
test_one="""
49+
import pytest
50+
51+
@pytest.mark.parametrize(
52+
"some",
53+
(pytest.param("", id="invalid name"),),
54+
)
55+
def test_a(some: str):
56+
assert True
57+
""",
58+
)
59+
60+
result = pytester.runpytest_inprocess("--collect-only", p1)
61+
if not disable:
62+
assert (
63+
"Test <Function test_a[invalid name]> has an id that does not match our safe pattern '^\\w+$' for use with a terminal."
64+
in result.stderr.lines
65+
)
66+
assert result.ret == rc
67+
68+
69+
def test_check_test_id_length(pytester: pytest.Pytester) -> None:
70+
"""Validates that we can detect duplicate test names."""
71+
p1 = pytester.makepyfile(
72+
test_one="""
73+
import pytest
74+
75+
@pytest.mark.parametrize(
76+
"some",
77+
(pytest.param("", id="this-is-too-long-for-our-taste-so-we-ask-you-to-make-it-shorter"),),
78+
)
79+
def test_a(some: str):
80+
assert True
81+
""",
82+
)
83+
84+
result = pytester.runpytest_inprocess("--collect-only", p1)
85+
assert (
86+
"<Function test_a[this-is-too-long-for-our-taste-so-we-ask-you-to-make-it-shorter]> has an id that looks above 60 characters."
87+
in result.stderr.lines
88+
)
89+
assert result.ret == pytest.ExitCode.USAGE_ERROR

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ commands_pre =
1818
commands =
1919
pytest --collect-only
2020
# Do not use PYTEST_REQPASS as here, read README.md for details
21-
sh -c "PYTEST_REQPASS=1 coverage run -m pytest --color=yes --html={envlogdir}/reports.html --self-contained-html {tty:-s}"
21+
sh -c "PYTEST_REQPASS=4 coverage run -m pytest --color=yes --html={envlogdir}/reports.html --self-contained-html {tty:-s}"
2222
# We want to fail if result code is zero:
2323
sh -c "PYTEST_REQPASS=100 pytest >/dev/null && exit 1 || true"
2424
sh -c "coverage combine -a -q --data-file=.coverage .tox/.coverage.*"
25+
sh -c "COVERAGE_FILE=.coverage python3 -m coverage report"
2526
deps =
2627
-e .[test]
2728
devel: git+https://github.com/pytest-dev/pytest.git

0 commit comments

Comments
 (0)