Introduction to testing with pytest
Verify code logic.
Think the design through.
Documentation.
Protection against mistakes.
What needs to be tested?
Everything.
If something can't be tested that's the exception, not the rule.
Unit testing are low level tests.
They test particular methods/classes in isolation.
Third-party systems are mocked through libraries or patched by us.
Integration testing verifies different components working together.
No internet access is required.
Tests do not use source code for arranging scenarios.
Tests do not contain logic.
Tests follow the same clean-code rules as the code.
Meaningful names, single responsibility principle, don't repeat yourself, etc.
Code coverage is meaningless without proper tests.
def is_active(user: Optional[User] = None):
return user.active
def test_user_is_active():
assert is_active(User(active=true))
Each line of code has a meaning.
If a line doesn't have a meaning must be removed.
Testing framework for python.
It runs tests.
It organises tests.
It provides context.
# Receives <FixtureRequest for <Function test_context>>
def test_context(request: FixtureRequest):
print(request)
assert False
# <SubRequest 'this_is_a_fixture' for <Function test_context>>
@pytest.fixture
def this_is_a_fixture(request: SubRequest):
print(request)
Achieved through "fixtures".
A "fixture" must do one thing, and only one thing.
Reusable.
Built-in.
Flexible scopes.
@pytest.fixture(scope=function|class|module|package|session)
def this_is_a_fixture():
pass
Implicit dependencies.
@pytest.fixture
def this_is_a_fixture():
pass
@pytest.fixture
def this_is_another_fixture(this_is_a_fixture):
pass
Allows parametrisation.
@pytest.fixture(params=["a", "b"])
def this_is_a_fixture(request):
return request.param
def test_something(this_is_a_fixture):
print(this_is_a_fixture)
assert True
Using the fixture will run the test once for each of the params.
Factories as fixtures.
@pytest.fixture
def run_something(capsys):
def _runner(data: str):
print(data)
return _runner
def test_run_something_bla(run_something, capsys):
run_something("bla")
assert capsys.readouterr().out == "bla"
def test_run_something_foo(run_something, capsys):
run_something("foo")
assert capsys.readouterr().out == "foo"
Teardown using generators.
@pytest.fixture
def this_is_a_fixture(capsys):
print("this will happen before the test")
yield
print("this will happen after the test")
def test_something(this_is_a_fixture):
assert True
Set metadata for our tests.
They are processed before fixtures.
They can not be applied to fixtures.
They can pass metadata to fixtures.
@pytest.fixture
def this_is_a_fixture(request):
mark = request.node.get_closest_marker("gorka")
print(f"mark args={mark.args} kwargs={mark.kwargs}")
@pytest.mark.gorka("arg", kwarg="kwarg")
def test_something(this_is_a_fixture):
assert True
Use "usefixtures" if you don't need the fixture return value.
@pytest.fixture
def this_does_something(request):
pass
def test_something(this_does_something):
assert True
@pytest.fixture
def this_does_something(request):
pass
@pytest.mark.usefixtures("this_does_something")
def test_something():
assert True
Run multiple variations of same test.
Tests as specific as possible.
def test_run_has_arguments(run):
assert run.args == {
"keyA": "valueA",
"keyB": "valueB",
}
@pytest.mark.parametrize("key, value", [
("keyA", "valueA"),
("keyB", "valueB"),
])
def test_run_has_argument(
run, key, value
):
assert run.args[key] == value
Permutations of the same test.
@pytest.mark.parametrize("name", [
"Chris", "Laura"
])
@pytest.mark.parametrize("surname", [
"Hemsworth", "Pausini"
])
def test_full_name_composition(name, surname):
assert full_name(name, surname) == f"{name} {surname}"
This will run the test 4 times, for each combination of parameters.
Override test meaning.
@pytest.mark.parametrize("user_id, expected", [
("111", "active"),
("222", "inactive")
("333", "inactive"),
], ids=[
"user active",
"user inactive",
"user deleted",
])
def test_user_status(user_id, expected):
assert User(user_id).status == expected
Override test meaning.
@pytest.mark.parametrize("user_id, expected", [
pytest.param("111", "active", id="user active"),
pytest.param("222", "inactive", id="user inactive")
pytest.param("333", "inactive", id="user deleted"),
])
def test_user_status(user_id, expected):
assert User(user_id).status == expected
Indirect parametrising of fixtures.
@pytest.fixture
def user(request):
return User(request.param)
@pytest.mark.parametrize("user, expected", [
pytest.param("111", "active", id="user active"),
pytest.param("222", "inactive", id="user inactive")
pytest.param("333", "inactive", id="user deleted"),
], indirect=["user"])
def test_user_status(user, expected):
assert user.status == expected
Replace parts of your system under test with mock objects.
Assert how those mocks have been used.
Patching at test level.
@mock.patch.object(os, "getenv")
def test_host_is_retrieved_from_environment(getenv):
run_something()
getenv.assert_called_with("SERVICE_HOST")
Patching at test level without mock assertion.
@mock.patch.object(os, "getenv", new=MagicMock(return_value="foo"))
def test_host_is_retrieved_from_environment():
assert run_something()
Patching at fixture level.
@pytest.fixture
def getenv():
with mock.patch.object(os, "getenv") as mocked:
yield mocked
def test_host_is_retrieved_from_environment(getenv):
run_something()
getenv.assert_called_with("SERVICE_HOST")
Patching from dependencies.
@pytest.fixture(params=["dev", "staging"])
def environment(request):
return request.param
@pytest.fixture
def get_environment(environment):
with mock.patch("get_environment", return_value=environment) as mocked:
yield mocked
def test_get_environment_is_used(get_environment):
run_something()
get_environment.assert_called()
def test_path_points_to_environment(environment):
assert run_something().path == f"path/{environment}"
Patching from parameters.
@pytest.fixture(params=["dev", "staging"])
def environment(request):
return request.param
@pytest.fixture
def get_environment(environment):
with mock.patch("get_environment", return_value=environment) as mocked:
yield mocked
@pytest.mark.parametrize("environment", ["production"], indirect=True)
def test_path_does_not_have_environment_in_production():
assert run_something().path == "path"
Patching factory as fixture.
@pytest.fixture(params=["dev", "staging"])
def environment(request):
return request.param
@pytest.fixture
def get_environment(environment):
with mock.patch("get_environment", return_value=environment) as mocked:
yield mocked
@pytest.fixture
def launcher(get_environment):
def _runner(data: str):
return Launcher(data)
yield _runner
@pytest.fixture
def test_path_contains_environment(launcher, environment):
assert launcher("foo").path == f"{environment}/foo"