Pytest Part 2 - Fixtures, Marking, Configs
Introduction
This is a further “advanced” section into pytest
We will covering a few more use cases & problems you might have encountered:
- How to set pytest configuration?
- How to share fixtures across scripts?
- What are more interesting things you can do with fixtures?
- Custom marking of tests
Pre-req
I assume the following prerequisites:
- Python
- Terminal (cli)
Good to have:
Setup
The final code can be found in same github repo under the folder fixtures
. The vscode devcontainer is also provided!
-
“requirements.txt”
Note, we probably only need
pytest
over here but the remaining are useful in configuring your environment or running other tests likemypy
orflake8
if you are familiar with them.1 2 3 4 5 6 7
black flake8 pytest pylint mypy pydantic jupyter
-
“Dockerfile”
The dockerifle has default entrypoint make
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM continuumio/miniconda3:4.8.2
RUN apt-get update - && \
apt-get install -y build-essential && apt-get install -y make curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR $HOME/my_project
COPY requirements.txt $HOME/my_project/
RUN pip install -r requirements.txt
COPY pytest.ini Makefile $HOME/my_project/
COPY tests $HOME/my_project/tests
ENTRYPOINT ["make"]
CMD ["run"]
Structure
In a typical pytest structure, this is what you might have:
1
2
3
4
5
6
7
.
├── Dockerfile
├── Makefile
├── pytest.ini
├── requirements.txt
├── src
└── tests
But in this case we do not need src
as the tests are self-sufficient.
Conftests
The first topic we are going to introduce is conftest.py
. This file usually sits with each file directory. This file must be named conftest.py
.
To start, we first create a conftest.py
under your tests
directory:
1
2
3
4
5
6
7
8
## tests/conftest.py
import pytest
import logging
@pytest.fixture()
def dummy_data():
return dict(user_id=123, sales="apple", quantity=400, price=1.12)
Followed by a python script tests/test_conf.py
1
2
3
4
5
6
import pytest
import logging
def test_calculate_sales_volume(dummy_data):
logging.info("this is to demostrate that the logging does not print out")
assert dummy_data.get("user_id") == 123
Followed by running pytest tests/test_conf.py
Output:
1
2
3
tests/test_conf.py. [100%]
======== 1 passed in 0.05s ========
Special note (from the official docs):
You can have multiple nested directories/packages containing your tests, and each directory can have its own
conftest.py
with its own fixtures, adding on to the ones provided by theconftest.py
files in parent directories.
Configuration - pytest.ini
The first thing to notice that there was no logging output. By checking the python docs on how we can output the logs to console, we can run:
1
pytest tests/test_conf.py --log-cli-level=INFO
Output:
1
2
3
4
5
6
tests/test_conf.py::test_calculate_sales_volume
------------------ live log call ------------------
INFO root:test_conf.py:6 this is to demostrate that the logging does not print out
PASSED [100%]
================ 1 passed in 0.01s ================
Logging
Instead of adding the --log-cli-level
parameter, we can use configure our pytest with config file. There are multiple ways to configure our pytest, we will be using pytest.ini
.
We create a pytest.ini
file
1
2
3
[pytest]
log_cli=true
log_level=INFO
and we can run the same command pytest tests/test_conf.py
to observe the same output.
Marking
Sometimes it might be hard to run selection of tests via regex or marking or file names, and probably better to do with [marking] instead. Here is a trivial example:
In tests/test_conf.py
add:
1
2
3
4
@pytest.mark.special
def test_special_marker(dummy_data):
logging.info("special marker test")
assert dummy_data.get("user_id") == 123
Because we are using special markers, we need to configure our pytest.ini
to accept such a marker. (We will also add other markers we need in the future section)
1
2
3
4
5
6
7
8
9
[pytest]
log_cli=true
log_level=INFO
markers=
special: test at special level
function: test at function level
class_: test at class level
module: test at a module level
session: test at a session level
and we can trigger the tests with:
1
pytest -m special
output:
1
2
3
4
tests/test_conf.py::test_special_marker
------------------ live log call ------------------
INFO root:test_conf.py:12 special marker test
PASSED [100%]
Setup and Teardown
When running pytests, sometimes you might want to setup code and potentially tear down code.
To do this with pytests, we can make use of the yield
statement:
In tests/conftest.py
add:
1
2
3
4
5
6
7
8
@pytest.fixture()
def demo_yield():
logging.info("setting up based on demo yield")
dummy_func = lambda x: x ** 2 # noqa
yield dummy_func
logging.info("tearing down based on demo yield")
In tests/test_conf.py
add:
1
2
3
4
5
def test_yield(demo_yield):
my_func = demo_yield # yield the function
assert my_func(10) == 100
logging.info("this is to demostrate its still happening in this test function")
Run:
1
pytest -k test_yield
Output - notice the setup
and teardown
:
1
2
3
4
5
6
7
8
tests/test_conf.py::test_yield
----------------- live log setup ------------------
INFO root:conftest.py:12 setting up based on demo yield
------------------ live log call ------------------
INFO root:test_conf.py:19 this is to demostrate its still happening in this test function
PASSED [100%]
---------------- live log teardown ----------------
INFO root:conftest.py:15 tearing down based on demo yield
Use cases
Here is an example of a database usecase (notice the scope
parameter which we will cover in next section).
1
2
3
4
5
6
@pytest.fixture(scope='module')
def test_database():
db.create_all()
yield db # testing happens here
db.session.remove()
db.drop_all()
Scope
By default, fixtures are loaded at a functional level. To demostrate this:
In tests/conftests.py
add:
1
2
3
4
@pytest.fixture()
def function_fixture():
logging.info("function trigger")
return True
Create a new script tests/test_function.py
1
2
3
4
5
6
7
8
9
import pytest
@pytest.mark.function
def test_one(function_fixture):
assert function_fixture
@pytest.mark.function
def test_two(function_fixture):
assert function_fixture
and we run it with:
1
pytest -m function
Output:
1
2
3
4
5
6
7
8
tests/test_function.py::test_one
----------------- live log setup ------------------
INFO root:conftest.py:20 function trigger
PASSED [ 50%]
tests/test_function.py::test_two
----------------- live log setup ------------------
INFO root:conftest.py:20 function trigger
PASSED [100%]
As we can see, this means that for each tests (which is a test function) will cause the fixture to be loaded again.
In certain cases, you might not want this behaviour, such as a connection with a database you do not want to trigger multiple connections for each tests.
There are 5 different scopes when it comes to fixtures:
Scope | Description |
---|---|
function | default scope - runs at every function level |
class | runs at every class level |
module | runs at every module level (note, a module is “sort of” like a script) |
package | runs at every package level, a package is a collection of modules |
session | runs at the python session (which usually consists of multiple packages) |
More examples
Examples on each of the other scopes:
We add each of the new scopes in tests/conftests.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@pytest.fixture(scope="class")
def class_fixture():
logging.info("class trigger")
return True
@pytest.fixture(scope="module")
def module_fixture():
logging.info("module trigger")
return True
@pytest.fixture(scope="session")
def session_fixture():
logging.info("session trigger")
return True
And to demostrate each of them:
-
“Class”
Create a script
tests/test_class.py
Note - the
class_
is due toclass
being a reserved keyword in pytest.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import pytest @pytest.mark.class_ @pytest.mark.usefixtures("class_fixture") class TestMyFixtures: def test_one(self): assert self def test_two(self): assert self @pytest.mark.class_ @pytest.mark.usefixtures("class_fixture") class TestMyFixturesAgain: def test_three(self): assert self def test_four(self): assert self
Run with
pytest -m class_
Output:
1 2 3 4 5 6 7 8 9 10
tests/test_class.py::TestMyFixtures::test_one ----------------- live log setup ------------------ INFO root:conftest.py:26 class trigger PASSED [ 25%] tests/test_class.py::TestMyFixtures::test_two PASSED [ 50%] tests/test_class.py::TestMyFixturesAgain::test_three ----------------- live log setup ------------------ INFO root:conftest.py:26 class trigger PASSED [ 75%] tests/test_class.py::TestMyFixturesAgain::test_four PASSED [100%]
-
“Module”
To demostrate a module tests we need to create two scripts,
Create a script
tests/test_module.py
1 2 3 4 5 6 7 8 9 10 11 12
import pytest @pytest.mark.module def test_one(module_fixture): assert module_fixture @pytest.mark.module def test_two(module_fixture): assert module_fixture
Create a duplicate
tests/test_module2.py
1 2 3 4 5 6 7 8 9 10 11
import pytest @pytest.mark.module def test_three(module_fixture): assert module_fixture @pytest.mark.module def test_four(module_fixture): assert module_fixture
Run with
pytest -m module
:1 2 3 4 5 6 7 8 9
----------------- live log setup ------------------ INFO root:conftest.py:32 module trigger PASSED [ 25%] tests/test_module.py::test_two PASSED [ 50%] tests/test_module2.py::test_three ----------------- live log setup ------------------ INFO root:conftest.py:32 module trigger PASSED [ 75%] tests/test_module2.py::test_four PASSED [100%]
-
Session
And we do the same for sessions:
In
tests/test_session.py
:1 2 3 4 5 6 7 8 9 10 11 12
import pytest @pytest.mark.session def test_one(session_fixture): assert session_fixture @pytest.mark.session def test_two(session_fixture): assert session_fixture
In
tests/test_session2.py
:1 2 3 4 5 6 7 8 9 10 11
import pytest @pytest.mark.session def test_three(session_fixture): assert session_fixture @pytest.mark.session def test_four(session_fixture): assert session_fixture
Run with
pytest -m session
Output:
1 2 3 4 5 6 7
tests/test_session.py::test_one ----------------- live log setup ------------------ INFO root:conftest.py:38 session trigger PASSED [ 25%] tests/test_session.py::test_two PASSED [ 50%] tests/test_session2.py::test_three PASSED [ 75%] tests/test_session2.py::test_four PASSED [100%]
Notice that:
- Fixture with scope
class
are triggered for eachTestClass
- Fixture with scope
modules
are triggered twice, for each scripttest_module
andtest_module2
- Fixture with scope
sessions
is triggered once only, despite having two modules.
Adding Autouse
Autouse is when you want to trigger a fixture despite circumstances. This is useful when you know your multiple of your tests uses a particular fixture.
But first, lets observe the behaviour of what it does:
If we go to tests/conftests.py
and edit the function trigger:
1
2
3
4
5
## @pytest.fixture()
@pytest.fixture(autouse=True)
def function_fixture():
logging.info("function trigger")
return True
and run pytest -m session
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tests/test_session.py::test_one
--------------------- live log setup ---------------------
INFO root:conftest.py:39 session trigger
INFO root:conftest.py:21 function trigger
PASSED [ 25%]
tests/test_session.py::test_two
--------------------- live log setup ---------------------
INFO root:conftest.py:21 function trigger
PASSED [ 50%]
tests/test_session2.py::test_three
--------------------- live log setup ---------------------
INFO root:conftest.py:21 function trigger
PASSED [ 75%]
tests/test_session2.py::test_four
--------------------- live log setup ---------------------
INFO root:conftest.py:21 function trigger
PASSED [100%]
The session fixture is triggered once, while the function fixture is triggered four times, despite not being used.
Autouse usecases
Why or when is autouse
useful then?
Quoting from the Real Python:
Another interesting use case for fixtures is in guarding access to resources. Imagine that you’ve written a test suite for code that deals with API calls. You want to ensure that the test suite doesn’t make any real network calls, even if a test accidentally executes the real network call code. pytest provides a monkeypatch fixture to replace values and behaviors, which you can use to great effect:
1
2
3
4
5
6
7
8
import pytest
import requests
@pytest.fixture(autouse=True)
def disable_network_calls(monkeypatch):
def stunted_get():
raise RuntimeError("Network access not allowed during testing!")
monkeypatch.setattr(requests, "get", lambda *args, **kwargs: stunted_get()
Order of scopes
Now, if you are going to use the scope
parameter, it is important to know the order of scopes is being executed. Generally it follows from the highest order to the lowest:
To illustrate, create a file tests/test_all.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import pytest
import logging
@pytest.fixture(scope="function")
def function():
logging.info("scope: function")
@pytest.fixture(scope="class")
def class_():
logging.info("scope: class")
@pytest.fixture(scope="module")
def module():
logging.info("scope: module")
@pytest.fixture(scope="package")
def package():
logging.info("scope: package")
@pytest.fixture(scope="session")
def session():
logging.info("scope: session")
def test_order(module, class_, session, function, package):
assert True
and run with pytest tests/test_all.py
:
1
2
3
4
5
6
7
8
9
tests/test_all.py::test_order
--------------------- live log setup ---------------------
INFO root:test_all.py:27 scope: session
INFO root:test_all.py:22 scope: package
INFO root:test_all.py:17 scope: module
INFO root:test_all.py:12 scope: class
INFO root:conftest.py:21 function trigger
INFO root:test_all.py:7 scope: function
PASSED [100%]
If we add autouse
fixture for each of the scopes, it will still obey the scope ordering:
1
2
3
4
5
6
7
8
9
10
11
12
13
tests/test_all_autouse.py::test_order
------------------------ live log setup ------------------------
INFO root:test_all_autouse.py:47 scope: session autouse
INFO root:test_all_autouse.py:52 scope: session
INFO root:test_all_autouse.py:37 scope: package autouse
INFO root:test_all_autouse.py:42 scope: package
INFO root:test_all_autouse.py:27 scope: module autouse
INFO root:test_all_autouse.py:32 scope: module
INFO root:test_all_autouse.py:17 scope: class autouse
INFO root:test_all_autouse.py:22 scope: class
INFO root:test_all_autouse.py:7 scope: function autouse
INFO root:test_all_autouse.py:12 scope: function
PASSED [100%]
References
- Conftests
- Marking
- Configuration
- Fixtures with yield
- Scope & Autouse