Skip to content

Commit a4d952d

Browse files
nedbationelmc
authored andcommitted
The --cov-context option for setting context per test
1 parent 419a010 commit a4d952d

File tree

8 files changed

+256
-2
lines changed

8 files changed

+256
-2
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ Authors
3030
* Семён Марьясин - https://github.com/MarSoft
3131
* Alexander Shadchin - https://github.com/shadchin
3232
* Thomas Grainger - https://graingert.co.uk
33+
* Ned Batchelder - https://nedbatchelder.com

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Changelog
44
2.7.2.dev0 (unreleased)
55
-----------------------
66

7+
* Added --cov-context option for setting the coverage.py dynamic context for
8+
each test.
79
* Match pytest-xdist master/worker terminology.
810
Contributed in `#321 <https://github.com/pytest-dev/pytest-cov/pull/321>`_
911

docs/config.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ The complete list of command line options is:
6363
--cov-append Do not delete coverage but append to current. Default:
6464
False
6565
--cov-branch Enable branch coverage.
66+
--cov-context Choose the method for setting the dynamic context.

docs/contexts.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
========
2+
Contexts
3+
========
4+
5+
Coverage.py 5.0 can record separate coverage data for different contexts during
6+
one run of a test suite. Pytest-cov can use this feature to record coverage
7+
data for each test individually, with the ``--cov-context=test`` option.
8+
9+
The context name recorded in the coverage.py database is the pytest test id,
10+
and the phase of execution, one of "setup", "run", or "teardown". These two
11+
are separated with a pipe symbol. You might see contexts like::
12+
13+
test_functions.py::test_addition|run
14+
test_fancy.py::test_parametrized[1-101]|setup
15+
test_oldschool.py::RegressionTests::test_error|run
16+
17+
Note that parameterized tests include the values of the parameters in the test
18+
id, and each set of parameter values is recorded as a separate test.

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Contents:
1212
debuggers
1313
xdist
1414
subprocess-support
15+
contexts
1516
tox
1617
plugins
1718
markers-fixtures

src/pytest_cov/plugin.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Coverage plugin for pytest."""
22
import argparse
3+
import coverage
34
import os
4-
import warnings
5-
65
import pytest
6+
import warnings
77
from coverage.misc import CoverageException
88

99
from . import compat
@@ -48,6 +48,14 @@ def validate_fail_under(num_str):
4848
return float(num_str)
4949

5050

51+
def validate_context(arg):
52+
if coverage.version_info <= (5, 0):
53+
raise argparse.ArgumentTypeError('Contexts are only supported with coverage.py >= 5.x')
54+
if arg != "test":
55+
raise argparse.ArgumentTypeError('--cov-context=test is the only supported value')
56+
return arg
57+
58+
5159
class StoreReport(argparse.Action):
5260
def __call__(self, parser, namespace, values, option_string=None):
5361
report_type, file = values
@@ -88,6 +96,9 @@ def pytest_addoption(parser):
8896
'Default: False')
8997
group.addoption('--cov-branch', action='store_true', default=None,
9098
help='Enable branch coverage.')
99+
group.addoption('--cov-context', action='store', metavar='CONTEXT',
100+
type=validate_context,
101+
help='Dynamic contexts to use. "test" for now.')
91102

92103

93104
def _prepare_cov_source(cov_source):
@@ -198,6 +209,9 @@ def pytest_sessionstart(self, session):
198209
elif not self._started:
199210
self.start(engine.Central)
200211

212+
if self.options.cov_context == 'test':
213+
session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts')
214+
201215
def pytest_configure_node(self, node):
202216
"""Delegate to our implementation.
203217
@@ -308,6 +322,24 @@ def pytest_runtest_call(self, item):
308322
yield
309323

310324

325+
class TestContextPlugin(object):
326+
def __init__(self, cov):
327+
self.cov = cov
328+
329+
def pytest_runtest_setup(self, item):
330+
self.switch_context(item, 'setup')
331+
332+
def pytest_runtest_teardown(self, item):
333+
self.switch_context(item, 'teardown')
334+
335+
def pytest_runtest_call(self, item):
336+
self.switch_context(item, 'run')
337+
338+
def switch_context(self, item, when):
339+
context = "{item.nodeid}|{when}".format(item=item, when=when)
340+
self.cov.switch_context(context)
341+
342+
311343
@pytest.fixture
312344
def no_cover():
313345
"""A pytest fixture to disable coverage."""

tests/contextful.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# A test file for test_pytest_cov.py:test_contexts
2+
3+
import unittest
4+
5+
import pytest
6+
7+
8+
def test_01():
9+
assert 1 == 1 # r1
10+
11+
12+
def test_02():
13+
assert 2 == 2 # r2
14+
15+
16+
class OldStyleTests(unittest.TestCase):
17+
items = []
18+
19+
@classmethod
20+
def setUpClass(cls):
21+
cls.items.append("hello") # s3
22+
23+
@classmethod
24+
def tearDownClass(cls):
25+
cls.items.pop() # t4
26+
27+
def setUp(self):
28+
self.number = 1 # r3 r4
29+
30+
def tearDown(self):
31+
self.number = None # r3 r4
32+
33+
def test_03(self):
34+
assert self.number == 1 # r3
35+
assert self.items[0] == "hello" # r3
36+
37+
def test_04(self):
38+
assert self.number == 1 # r4
39+
assert self.items[0] == "hello" # r4
40+
41+
42+
@pytest.fixture
43+
def some_data():
44+
return [1, 2, 3] # s5 s6
45+
46+
47+
def test_05(some_data):
48+
assert len(some_data) == 3 # r5
49+
50+
51+
@pytest.fixture
52+
def more_data(some_data):
53+
return [2*x for x in some_data] # s6
54+
55+
56+
def test_06(some_data, more_data):
57+
assert len(some_data) == len(more_data) # r6
58+
59+
60+
@pytest.fixture(scope='session')
61+
def expensive_data():
62+
return list(range(10)) # s7
63+
64+
65+
def test_07(expensive_data):
66+
assert len(expensive_data) == 10 # r7
67+
68+
69+
def test_08(expensive_data):
70+
assert len(expensive_data) == 10 # r8
71+
72+
73+
@pytest.fixture(params=[1, 2, 3])
74+
def parametrized_number(request):
75+
return request.param # s9-1 s9-2 s9-3
76+
77+
78+
def test_09(parametrized_number):
79+
assert parametrized_number > 0 # r9-1 r9-2 r9-3
80+
81+
82+
def test_10():
83+
assert 1 == 1 # r10
84+
85+
86+
@pytest.mark.parametrize("x, ans", [
87+
(1, 101),
88+
(2, 202),
89+
])
90+
def test_11(x, ans):
91+
assert 100 * x + x == ans # r11-1 r11-2
92+
93+
94+
@pytest.mark.parametrize("x, ans", [
95+
(1, 101),
96+
(2, 202),
97+
], ids=['one', 'two'])
98+
def test_12(x, ans):
99+
assert 100 * x + x == ans # r12-1 r12-2
100+
101+
102+
@pytest.mark.parametrize("x", [1, 2])
103+
@pytest.mark.parametrize("y", [3, 4])
104+
def test_13(x, y):
105+
assert x + y > 0 # r13-1 r13-2 r13-3 r13-4

tests/test_pytest_cov.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import collections
12
import glob
23
import os
34
import platform
5+
import re
46
import subprocess
57
import sys
68
from itertools import chain
@@ -1928,3 +1930,95 @@ def test_cov_and_no_cov(testdir):
19281930
script)
19291931

19301932
assert result.ret == 0
1933+
1934+
1935+
def find_labels(text, pattern):
1936+
all_labels = collections.defaultdict(list)
1937+
lines = text.splitlines()
1938+
for lineno, line in enumerate(lines, start=1):
1939+
labels = re.findall(pattern, line)
1940+
for label in labels:
1941+
all_labels[label].append(lineno)
1942+
return all_labels
1943+
1944+
1945+
# The contexts and their labels in contextful.py
1946+
EXPECTED_CONTEXTS = {
1947+
'': 'c0',
1948+
'test_contexts.py::test_01|run': 'r1',
1949+
'test_contexts.py::test_02|run': 'r2',
1950+
'test_contexts.py::OldStyleTests::test_03|setup': 's3',
1951+
'test_contexts.py::OldStyleTests::test_03|run': 'r3',
1952+
'test_contexts.py::OldStyleTests::test_04|run': 'r4',
1953+
'test_contexts.py::OldStyleTests::test_04|teardown': 't4',
1954+
'test_contexts.py::test_05|setup': 's5',
1955+
'test_contexts.py::test_05|run': 'r5',
1956+
'test_contexts.py::test_06|setup': 's6',
1957+
'test_contexts.py::test_06|run': 'r6',
1958+
'test_contexts.py::test_07|setup': 's7',
1959+
'test_contexts.py::test_07|run': 'r7',
1960+
'test_contexts.py::test_08|run': 'r8',
1961+
'test_contexts.py::test_09[1]|setup': 's9-1',
1962+
'test_contexts.py::test_09[1]|run': 'r9-1',
1963+
'test_contexts.py::test_09[2]|setup': 's9-2',
1964+
'test_contexts.py::test_09[2]|run': 'r9-2',
1965+
'test_contexts.py::test_09[3]|setup': 's9-3',
1966+
'test_contexts.py::test_09[3]|run': 'r9-3',
1967+
'test_contexts.py::test_10|run': 'r10',
1968+
'test_contexts.py::test_11[1-101]|run': 'r11-1',
1969+
'test_contexts.py::test_11[2-202]|run': 'r11-2',
1970+
'test_contexts.py::test_12[one]|run': 'r12-1',
1971+
'test_contexts.py::test_12[two]|run': 'r12-2',
1972+
'test_contexts.py::test_13[3-1]|run': 'r13-1',
1973+
'test_contexts.py::test_13[3-2]|run': 'r13-2',
1974+
'test_contexts.py::test_13[4-1]|run': 'r13-3',
1975+
'test_contexts.py::test_13[4-2]|run': 'r13-4',
1976+
}
1977+
1978+
1979+
@pytest.mark.skipif("coverage.version_info < (5, 0)")
1980+
@xdist_params
1981+
def test_contexts(testdir, opts):
1982+
with open(os.path.join(os.path.dirname(__file__), "contextful.py")) as f:
1983+
contextful_tests = f.read()
1984+
script = testdir.makepyfile(contextful_tests)
1985+
result = testdir.runpytest('-v',
1986+
'--cov=%s' % script.dirpath(),
1987+
'--cov-context=test',
1988+
script,
1989+
*opts.split()
1990+
)
1991+
assert result.ret == 0
1992+
result.stdout.fnmatch_lines([
1993+
'test_contexts* 100%*',
1994+
])
1995+
1996+
data = coverage.CoverageData(".coverage")
1997+
data.read()
1998+
assert data.measured_contexts() == set(EXPECTED_CONTEXTS)
1999+
measured = data.measured_files()
2000+
assert len(measured) == 1
2001+
test_context_path = list(measured)[0]
2002+
assert test_context_path.lower() == os.path.abspath("test_contexts.py").lower()
2003+
2004+
line_data = find_labels(contextful_tests, r"[crst]\d+(?:-\d+)?")
2005+
for context, label in EXPECTED_CONTEXTS.items():
2006+
if context == '':
2007+
continue
2008+
context_pattern = re.sub(r"[\[\|]", r"[\g<0>]", context)
2009+
actual = data.lines(test_context_path, contexts=[context_pattern])
2010+
assert line_data[label] == actual, "Wrong lines for context {!r}".format(context)
2011+
2012+
2013+
@pytest.mark.skipif("coverage.version_info >= (5, 0)")
2014+
def test_contexts_not_supported(testdir):
2015+
script = testdir.makepyfile("a = 1")
2016+
result = testdir.runpytest('-v',
2017+
'--cov=%s' % script.dirpath(),
2018+
'--cov-context=test',
2019+
script,
2020+
)
2021+
result.stderr.fnmatch_lines([
2022+
'*argument --cov-context: Contexts are only supported with coverage.py >= 5.x',
2023+
])
2024+
assert result.ret != 0

0 commit comments

Comments
 (0)