testing with pytest

Introduction to testing with pytest

what Is the purpose?

Verify code logic.

what Is the purpose?

Think the design through.

what Is the purpose?

Documentation.

what Is the purpose?

Protection against mistakes.

testing concepts

What needs to be tested?

Everything.

If something can't be tested that's the exception, not the rule.

testing concepts

Unit testing are low level tests.

They test particular methods/classes in isolation.

Third-party systems are mocked through libraries or patched by us.

testing concepts

Integration testing verifies different components working together.

testing concepts

No internet access is required.

testing concepts

Tests do not use source code for arranging scenarios.

testing concepts

Tests do not contain logic.

testing concepts

Tests follow the same clean-code rules as the code.

Meaningful names, single responsibility principle, don't repeat yourself, etc.

testing concepts

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))

testing concepts

Each line of code has a meaning.

If a line doesn't have a meaning must be removed.

what's pytest?

Testing framework for python.

what does it provide?

It runs tests.

what does it provide?

It organises tests.

what does it provide?

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)

setup/teardown

Achieved through "fixtures".

setup/teardown

A "fixture" must do one thing, and only one thing.

setup/teardown

Reusable.

setup/teardown

Built-in.

setup/teardown

Flexible scopes.

@pytest.fixture(scope=function|class|module|package|session)
def this_is_a_fixture():
  pass
  • function: destroyed at the end of the test.
  • class: destroyed during the teardown of the last test in the class.
  • module: destroyed during the teardown of the last test in the module.
  • package: destroyed during the teardown of the last test in the package.
  • session: destroyed at the end of the test session.

setup/teardown

Implicit dependencies.

@pytest.fixture
def this_is_a_fixture():
  pass


@pytest.fixture
def this_is_another_fixture(this_is_a_fixture):
  pass

setup/teardown

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.

setup/teardown

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"

setup/teardown

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

markers

Set metadata for our tests.

markers

They are processed before fixtures.

markers

They can not be applied to fixtures.

markers

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

markers

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

parametrizing

Run multiple variations of same test.

parametrizing

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

parametrizing

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.

parametrizing

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

parametrizing

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

parametrizing

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

patching

Replace parts of your system under test with mock objects.

patching

Assert how those mocks have been used.

patching

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

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

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

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

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

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"

sources

courses

Testing with Pytest

By Gorka Guridi

Testing with Pytest

Introduction to testing with pytest

  • 132