Skip to content

Commit ad9aa64

Browse files
author
Sylvain MARIE
committed
New documentation page concerning theory of fixture unions. Added associated tests. Fixes #109
1 parent 9e0e3f8 commit ad9aa64

File tree

10 files changed

+262
-1
lines changed

10 files changed

+262
-1
lines changed
37.1 KB
Loading
79.4 KB
Loading
43.4 KB
Loading
129 KB
Loading

docs/imgs/source.pptx

16.2 KB
Binary file not shown.

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ site_dir: ../site
66
nav:
77
- Home: index.md
88
- pytest goodies: pytest_goodies.md
9+
- fixture unions theory: unions_theory.md
910
- API reference: api_reference.md
1011
- Changelog: changelog.md
1112
theme: material # readthedocs mkdocs

docs/pytest_goodies.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ As of `pytest` 5, it is not possible to create a "union" fixture, i.e. a paramet
108108

109109
The topic has been largely discussed in [pytest-dev#349](https://github.com/pytest-dev/pytest/issues/349) and a [request for proposal](https://docs.pytest.org/en/latest/proposals/parametrize_with_fixtures.html) has been finally made.
110110

111-
`fixture_union` is an implementation of this proposal. It is also used by `parametrize` to support `fixture_ref` in parameter values, see [below](#parametrize).
111+
`fixture_union` is an implementation of this proposal. It is also used by `parametrize` to support `fixture_ref` in parameter values, see [below](#parametrize). The theory is presented in more details in [this page](unions_theory.md), while below are more practical examples.
112112

113113
```python
114114
from pytest_cases import fixture, fixture_union

docs/unions_theory.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Theory behind [`fixture_union`](pytest_goodies.md#fixture_union)
2+
3+
## 1. How `pytest` works today
4+
5+
As of `pytest` 5, there are three kind of concepts at play to generate the list of test nodes and their received parameters ("call spec" in pytest internals).
6+
7+
- test functions are the functions defined with `def test_<name>(<args>)`.
8+
9+
- they can be parametrized using `@pytest.mark.parametrize` (or our enhanced version [`@parametrize`](pytest_goodies.md#parametrize)). That means that some of the `<args>` will take several values, and for each combination a distinct test node will be created
10+
11+
- they can require *fixtures*, that is, functions decorated with `@pytest.fixture` (or our enhanced version [`@fixture`](pytest_goodies.md#fixture)). That means that some of the `<args>` will take the value of the corresponding fixture(s).
12+
13+
- fixtures can be parametrized too (with [`@fixture`](pytest_goodies.md#fixture) it is easier :) ), and can require other fixtures.
14+
15+
- finally fixtures can enable an "auto-use" mode, so that they are called even when not explicitly required by anything.
16+
17+
Therefore, a test plan can be represented as an acyclic directed graph of fixtures, where nodes are fixtures and edges represent dependencies. On top of this layout, we can overlay the information of which fixture nodes are parametrized, which ones are required by which test function, and which test function is parametrized. The resulting figure is presented below:
18+
19+
![fixture graph pytest](imgs/3_fixture_graph_pytest.png)
20+
21+
The following code can be used to easily check the number of tests run. Note that we use `@fixture` and `@parametrize` from `pytest-cases` to ease code readability here but you would get a similar behaviour with `@pytest.fixture` and `@pytest.mark.parametrize` (the test ids would not show the parameter names by default though, which is helpful for our demonstration here).
22+
23+
```python
24+
from pytest_cases import fixture, parametrize
25+
26+
@fixture(autouse=True)
27+
@parametrize(ie=[-1, 1])
28+
def e(ie):
29+
return "e%s" % ie
30+
31+
@fixture
32+
def d():
33+
return "d"
34+
35+
@fixture
36+
def c():
37+
return "c"
38+
39+
@fixture
40+
@parametrize(ia=[0, 1])
41+
def a(c, d, ia):
42+
return "a%s" % ia + c + d
43+
44+
@parametrize(i2=['x', 'z'])
45+
def test_2(a, i2):
46+
assert (a + i2) in ("a0cdx", "a0cdz", "a1cdx", "a1cdz")
47+
48+
@fixture
49+
@parametrize(ib=['x', 'z'])
50+
def b(a, c, ib):
51+
return "b%s" % ib + c + a
52+
53+
def test_1(a, b):
54+
assert a in ("a0cd", "a1cd")
55+
assert a == b[-4:]
56+
assert b[:-4] in ("bxc", "bzc")
57+
```
58+
59+
calling `pytest` yields:
60+
61+
```
62+
============================= test session starts =============================
63+
collecting ... collected 16 items
64+
65+
test_doc_fixture_graph.py::test_2[ie=-1-ia=0-i2=x]
66+
test_doc_fixture_graph.py::test_2[ie=-1-ia=0-i2=z]
67+
test_doc_fixture_graph.py::test_2[ie=-1-ia=1-i2=x]
68+
test_doc_fixture_graph.py::test_2[ie=-1-ia=1-i2=z]
69+
test_doc_fixture_graph.py::test_2[ie=1-ia=0-i2=x]
70+
test_doc_fixture_graph.py::test_2[ie=1-ia=0-i2=z]
71+
test_doc_fixture_graph.py::test_2[ie=1-ia=1-i2=x]
72+
test_doc_fixture_graph.py::test_2[ie=1-ia=1-i2=z]
73+
test_doc_fixture_graph.py::test_1[ie=-1-ia=0-ib=x]
74+
test_doc_fixture_graph.py::test_1[ie=-1-ia=0-ib=z]
75+
test_doc_fixture_graph.py::test_1[ie=-1-ia=1-ib=x]
76+
test_doc_fixture_graph.py::test_1[ie=-1-ia=1-ib=z]
77+
test_doc_fixture_graph.py::test_1[ie=1-ia=0-ib=x]
78+
test_doc_fixture_graph.py::test_1[ie=1-ia=0-ib=z]
79+
test_doc_fixture_graph.py::test_1[ie=1-ia=1-ib=x]
80+
test_doc_fixture_graph.py::test_1[ie=1-ia=1-ib=z]
81+
82+
============================= 16 passed in 0.14s ==============================
83+
```
84+
85+
So each test is called 8 times. How are these calls computed ?
86+
87+
- first for each test, `pytest` computes the set of all fixtures that are directly or indirectly required to run it. This is known as the "fixture closure". So for `test_1` this closure is `{a, b, c, d, e}` while for test 2 it is `{a, c, d, e}`. We can show this on the following picture:
88+
89+
![fixture graph pytest closure](imgs/4_fixture_graph_pytest_closure.png)
90+
91+
- then a cartesian product is made across the parameters of all parametrization marks found on any item in the closure (including parameters of the test itself), So for `test_1` the cartesian product is `<ie> x <ia> x <ib>` while for `test_2` it is `<ie> x <ia> x <i2>`. This is why both tests result in having 8 variants being called (see details in the test ids above).
92+
93+
94+
## 2. Extension to fixture unions.
95+
96+
A fixture union is by definition a fixture that is parametrized to alternately depend on other fixtures. We will represent this in the figures with a special dashed orange arrow, to remind that a special parameter is associated with the selection of which arrow is activated.
97+
98+
Let's consider the following modification of the above example, where we introduce two "unions": one as an explicit fixture `u`, and the other implicitly created by using `fixture_ref`s in the parametrization of `b`.
99+
100+
![fixture graph union](imgs/5_fixture_graph_union.png)
101+
102+
We can create such a configuration with a slight modification to the above example:
103+
104+
```python
105+
from pytest_cases import fixture, parametrize, fixture_ref, fixture_union
106+
107+
(... same as above ...)
108+
109+
@fixture
110+
@parametrize(ub=(fixture_ref(a), fixture_ref(c)), ib=['x', 'z'])
111+
def b(ub, ib):
112+
return "b%s" % ib + ub
113+
114+
u = fixture_union("u", (a, b))
115+
116+
def test_1(u):
117+
pass
118+
```
119+
120+
calling `pytest` yields:
121+
122+
```
123+
============================= test session starts =============================
124+
collecting ... collected 24 items
125+
126+
test_doc_fixture_graph_union.py::test_2[ie=-1-ia=0-i2=x] PASSED [ 4%]
127+
test_doc_fixture_graph_union.py::test_2[ie=-1-ia=0-i2=z] PASSED [ 8%]
128+
test_doc_fixture_graph_union.py::test_2[ie=-1-ia=1-i2=x] PASSED [ 12%]
129+
test_doc_fixture_graph_union.py::test_2[ie=-1-ia=1-i2=z] PASSED [ 16%]
130+
test_doc_fixture_graph_union.py::test_2[ie=1-ia=0-i2=x] PASSED [ 20%]
131+
test_doc_fixture_graph_union.py::test_2[ie=1-ia=0-i2=z] PASSED [ 25%]
132+
test_doc_fixture_graph_union.py::test_2[ie=1-ia=1-i2=x] PASSED [ 29%]
133+
test_doc_fixture_graph_union.py::test_2[ie=1-ia=1-i2=z] PASSED [ 33%]
134+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_a-ia=0] PASSED [ 37%]
135+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_a-ia=1] PASSED [ 41%]
136+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_a-ib=x-ia=0] PASSED [ 45%]
137+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_a-ib=x-ia=1] PASSED [ 50%]
138+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_a-ib=z-ia=0] PASSED [ 54%]
139+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_a-ib=z-ia=1] PASSED [ 58%]
140+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_c-ib=x] PASSED [ 62%]
141+
test_doc_fixture_graph_union.py::test_1[ie=-1-u_is_b-ub_is_c-ib=z] PASSED [ 66%]
142+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_a-ia=0] PASSED [ 70%]
143+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_a-ia=1] PASSED [ 75%]
144+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_a-ib=x-ia=0] PASSED [ 79%]
145+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_a-ib=x-ia=1] PASSED [ 83%]
146+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_a-ib=z-ia=0] PASSED [ 87%]
147+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_a-ib=z-ia=1] PASSED [ 91%]
148+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_c-ib=x] PASSED [ 95%]
149+
test_doc_fixture_graph_union.py::test_1[ie=1-u_is_b-ub_is_c-ib=z] PASSED [100%]
150+
151+
======================== 24 passed, 1 warning in 0.30s ========================
152+
```
153+
154+
Now 24 tests were created ! `test_2` still has 8 runs, which is normal as it does not depend on any union fixture. Let's try to understand what happened to parametrization of `test_1`. It is actually fairly simple:
155+
156+
- first a global fixture closure is created as usual, consisting in `{u, a, b, c, d, e}`
157+
158+
- then for each union fixture in `test_1`'s closure, starting from the bottom of the graph, we generate several closures by activating each of the arrows in turn. We progress upwards through the graph of remaining dependencies for each alternative:
159+
160+
- first `u` is used to split between subgraphs `u_is_a` and `u_is_b`
161+
- subgraph `u_is_a` does not contain any union. Its final closure is `{u, a, c, d, e}`
162+
- for subgraph `u_is_b` there is another union. So a new split is generated:
163+
164+
- subgraph `u_is_b-ub_is_a` does not contain any union. Its final closure is `{u, b, a, c, d, e}`
165+
- subgraph `u_is_b-ub_is_c` does not contain any union. Its final closure is `{u, b, c, e}`
166+
167+
168+
So the result consists in **3 alternate fixture closures** for `test_1`:
169+
170+
![fixture graph union closures](imgs/6_fixture_graph_union_closures.png)
171+
172+
- finally, as usual, for each closure a cartesian product is made across the parameters of all parametrization marks found on any item in the closure (including parameters of the test itself), So
173+
174+
- for `test_1` alternative `u_is_a`, the cartesian product is `<ie> x <ia>` (4 tests)
175+
- for `test_1` alternative `u_is_b-ub_is_a`, the cartesian product is `<ie> x <ia> x <ib>` (8 tests)
176+
- for `test_1` alternative `u_is_b-ub_is_c`, the cartesian product is `<ie> x <ib>` (4 tests)
177+
- for `test_2` it is `<ie> x <ia> x <i2>`. (8 tests).
178+
179+
The total is indeed 4 + 8 + 4 + 8 = 24 tests. Once again the test ids may be used to check that everything is correct, see above.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pytest_cases import fixture, parametrize
2+
3+
4+
@fixture(autouse=True)
5+
@parametrize(ie=[-1, 1])
6+
def e(ie):
7+
return "e%s" % ie
8+
9+
10+
@fixture
11+
def d():
12+
return "d"
13+
14+
15+
@fixture
16+
def c():
17+
return "c"
18+
19+
20+
@fixture
21+
@parametrize(ia=[0, 1])
22+
def a(c, d, ia):
23+
return "a%s" % ia + c + d
24+
25+
26+
@parametrize(i2=['x', 'z'])
27+
def test_2(a, i2):
28+
assert (a + i2) in ("a0cdx", "a0cdz", "a1cdx", "a1cdz")
29+
30+
31+
@fixture
32+
@parametrize(ib=['x', 'z'])
33+
def b(a, c, ib):
34+
return "b%s" % ib + c + a
35+
36+
37+
def test_1(a, b):
38+
assert a in ("a0cd", "a1cd")
39+
assert a == b[-4:]
40+
assert b[:-4] in ("bxc", "bzc")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from pytest_cases import fixture, parametrize, fixture_union, fixture_ref
2+
3+
4+
@fixture(autouse=True)
5+
@parametrize(ie=[-1, 1])
6+
def e(ie):
7+
return "e%s" % ie
8+
9+
10+
@fixture
11+
def d():
12+
return "d"
13+
14+
15+
@fixture
16+
def c():
17+
return "c"
18+
19+
20+
@fixture
21+
@parametrize(ia=[0, 1])
22+
def a(c, d, ia):
23+
return "a%s" % ia + c + d
24+
25+
26+
@parametrize(i2=['x', 'z'])
27+
def test_2(a, i2):
28+
assert (a + i2) in ("a0cdx", "a0cdz", "a1cdx", "a1cdz")
29+
30+
31+
@fixture
32+
@parametrize(ub=(fixture_ref(a), fixture_ref(c)), ib=['x', 'z'])
33+
def b(ub, ib):
34+
return "b%s" % ib + ub
35+
36+
37+
u = fixture_union("u", (a, b))
38+
39+
40+
def test_1(u):
41+
pass

0 commit comments

Comments
 (0)