Pytest Quick Refresher

maddyonline

Madhav Jha

Posted on July 23, 2023

Pytest Quick Refresher

Welcome to the PyTest refresher guide. This guide will provide an overview of the pytest library, focusing mainly on its usage for writing and managing tests, especially in relation to pytest fixtures.

Overview of PyTest

PyTest is a full-featured testing tool for Python that supports simple unit tests to complex functional testing. It allows for more readable and easier-to-write tests by using Python's assert keyword directly, without any wrappers around it. Its auto-discovery feature makes it easier to set up and run tests, and its detailed output for failed tests makes debugging a smoother process.

PyTest Fixtures

One of the most powerful features of pytest is its fixture system. A fixture is a function that is run before (and sometimes after) the tests. It is used to set up some state or data that will be used by the tests. Fixtures are modular and can be created to serve specific tests or reused across multiple tests, modules, or even the entire project.

Here's a simple example of a fixture:

import pytest

@pytest.fixture
def example_data():
    return "some data"
Enter fullscreen mode Exit fullscreen mode

In this example, example_data is a fixture that simply returns a string. This fixture can be used in any test that requires this string.

Using Fixtures in Tests

To use a fixture in a test, you simply need to include an argument in your test function with the same name as the fixture:

def test_example(example_data):
    assert example_data == "some data"
Enter fullscreen mode Exit fullscreen mode

In the above test, pytest sees that example_data is a fixture and therefore calls the example_data fixture function and passes the returned value into the test function.

This system allows for powerful modular testing setups and can be used to represent things like database connections, data, mock objects, and state setup/teardown.

Parameterizing Fixtures

Another powerful feature of pytest is the ability to parameterize fixtures. This allows you to run a test multiple times with different inputs from the fixture. Here's an example:

import pytest

@pytest.fixture(params=[0, 1, 2])
def example_data(request):
    return request.param

def test_example(example_data):
    assert 0 <= example_data <= 2
Enter fullscreen mode Exit fullscreen mode

In this example, the test test_example will be run three times, once for each value in params. The current value is accessed via the request.param attribute.

Fixture Scope

By default, pytest will call a fixture function for every test that uses it, creating new fixture data for each test. However, pytest allows you to control the scope of a fixture using the @pytest.fixture(scope='...') decorator. The available scopes are:

  • function (default): Run the fixture function before each test that uses it.
  • class: Run the fixture function once per test class, regardless of how many test methods use it.
  • module: Run the fixture function once per module, regardless of how many tests or test classes use it.
  • package: Run the fixture function once per package.
  • session: Run the fixture function only once per session (i.e., per test run).

For example, here's how to create a fixture with module scope:

import pytest

@pytest.fixture(scope="module")
def example_data():
    return "some data"
Enter fullscreen mode Exit fullscreen mode

In this case, example_data will be called only once per module, and the same data will be used for every test in the module that uses the fixture.

Managing Multiple PyTest Fixtures

When dealing with multiple fixtures in PyTest, the order in which they're declared in your test function does not dictate the order of their execution. PyTest manages each fixture independently, setting up and tearing down each in the appropriate order.

import pytest

@pytest.fixture
def fixture_a():
    print("Setting up fixture_a")
    yield
    print("Tearing down fixture_a")

@pytest.fixture
def fixture_b():
    print("Setting up fixture_b")
    yield
    print("Tearing down fixture_b")

def test_example(fixture_b, fixture_a):
    print("Running test_example")
Enter fullscreen mode Exit fullscreen mode

In this scenario, the print statements will execute in the following sequence:

  1. Setting up fixture_a
  2. Setting up fixture_b
  3. Running test_example
  4. Tearing down fixture_b
  5. Tearing down fixture_a

Despite fixture_b being listed first in the test_example function arguments, pytest orchestrates the setup of all fixtures before executing the test. The teardown of fixtures occurs in the reverse order of their setup, ensuring a smooth handling of dependencies between fixtures. This remains true even when the fixtures share the same scope. If the fixtures have differing scopes (e.g., session vs function), the fixtures with broader scopes are set up prior to those with narrower scopes.

Mixing Fixture and Non-Fixture Arguments

A test function can indeed have a mix of fixture and non-fixture arguments. When doing so, the non-fixture arguments must be passed via pytest's mark.parametrize decorator to feed in the values. Here's an example:

import pytest

@pytest.fixture
def fixture_a():
    return "fixture_a value"

@pytest.mark.parametrize("param", ["value1", "value2"])
def test_example(fixture_a, param):
    print("Running test_example with", fixture_a, "and", param)
Enter fullscreen mode Exit fullscreen mode

In this example, the test function test_example uses a fixture fixture_a and a parameterized non-fixture argument param. PyTest will run test_example twice, once with param as "value1" and once with "value2", setting up and tearing down fixture_a for each run.

This allows for very flexible and powerful test setups, combining the benefits of both fixtures and parameterized inputs.

Using Fixture Instances Across Multiple Fixtures and Tests

When working with PyTest fixtures, you may often find yourself in situations where you need to share an instance of an object between multiple fixtures or tests. PyTest makes this easy and ensures that the same instance is used across your tests, promoting consistency and reducing redundancy.

Consider an example where we have a Database class and a Store class. The Store class uses an instance of the Database class.

class Database:
    def __init__(self):
        self.data = "database data"

class Store:
    def __init__(self, database):
        self.database = database
        self.data = "store data"
Enter fullscreen mode Exit fullscreen mode

We can create PyTest fixtures for these classes and use the instance of Database in both the Store fixture and the test function.

import pytest

@pytest.fixture
def context():
    print("Setting up context")
    return Database()

@pytest.fixture
def store(context):
    print("Setting up store with context:", context.data)
    return Store(context)

def test_example(store, context):
    print("Running test_example")
    assert store.database is context
Enter fullscreen mode Exit fullscreen mode

In the above code, the context fixture sets up and returns an instance of the Database class. This instance is then used in the store fixture to set up a Store instance. In the test_example function, we assert that the Database instance within the Store instance (store.database) is the same as the context instance. This assertion will pass because PyTest ensures that the store fixture and the test_example function receive the same instance of the context fixture.

This example illustrates how PyTest manages instances and dependencies, allowing us to create sophisticated and consistent test scenarios. The ability to share and manage instances across fixtures and tests is a powerful feature that makes PyTest an excellent choice for testing in Python.

Common Patterns in PyTest

Parameterized testing

PyTest allows you to parameterize tests so that you can run the same test multiple times with different arguments.

import pytest

@pytest.mark.parametrize("test_input,expected", [("input1", "output1"), ("input2", "output2")])
def test_eval(test_input, expected):
    assert eval(test_input) == expected
Enter fullscreen mode Exit fullscreen mode

In the example above, test_eval will run twice, once with test_input="input1" and expected="output1", and once with test_input="input2" and expected="output2".

Exception Testing

Testing that certain code raises an exception can be done using pytest.raises as a context manager:

import pytest

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0
Enter fullscreen mode Exit fullscreen mode

In the above example, the test will pass if 1 / 0 raises a ZeroDivisionError, and fail otherwise.

Skipping or Expecting Test Failures

You can skip tests or expect them to fail under certain conditions:

import pytest
import sys

@pytest.mark.skip(reason="no way of currently testing this")
def test_the_unknown():
    ...

@pytest.mark.skipif(sys.version_info < (3,7), reason="requires python3.7 or higher")
def test_function():
    ...

@pytest.mark.xfail
def test_function():
    ...
Enter fullscreen mode Exit fullscreen mode

In the examples above, test_the_unknown is unconditionally skipped, test_function is skipped if the Python version is lower than 3.7, and another test_function is expected to fail.

💖 💪 🙅 🚩
maddyonline
Madhav Jha

Posted on July 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related