DIY Factories with Pytest

Article summary

Pytest’s fixtures are a pretty convenient route to dependency injection throughout your tests. They’re flexible, too, and involve very little “magic.” This means they’re easy to learn, understand, and use correctly.

The Pytest documentation explains that a fixture can pass back anything to the caller, including a function. This allows you to essentially create parameterized fixtures. The cleanup code for these fixtures looks exactly the same whether you’re returning a value or a function. You simply yield the result instead of returning it and then do any necessary clean up after the yield statement. That’s a pretty powerful test fixture.

But you can also take Pytest fixtures one or two steps further in a way that is not explained in the documentation. You can return or yield an entire class, and the result is basically an object-oriented factory pattern. You can make this factory available in all of your tests by adding a single parameter, and you can keep the state of these factories independent across tests.

“But what about Factory Boy?” you may be asking. Factory Boy is a popular solution, and it’ll work great with popular ORMs like Django’s. In most cases, it’s totally appropriate to use Factory Boy off the bat.

The DIY approach I’m about to demonstrate will work best if your ORM is highly customized. It also works great if you just need a small, simple factory — or if you want to try something new!

The Factory Fixture

This is a basic factory fixture that can create shape objects and then delete them when the test is finished. Details are included in the inline comments.


# Using the "function" scope will technically produce
# a different Factory class for each individual test.
@pytest.fixture(scope="function")
# As with any Pytest fixture, we can reference other fixtures
# as parameters as long as their scope is not smaller than this one's.
def factory(polygon_creator, shape_deleter):
    class Factory():

        # The following are static, mutable variables for the Factory class.
        # They allow us to track state in order to write cleanup code.

        # The only reason it's OK to use static variables here is that
        # the `factory` fixture has the "function" scope.

        # If you want to use a wider scope, you should use instance variables
        # and define an `__init__` constructor.
        circle_ids = []
        polygon_ids = []

        # Each factory "method" will be static so that we don't need to
        # actually instantiate a `Factory` object in the tests.
        @staticmethod
        # This function would be called in your tests like so:
        # factory.create_circle(10)
        def create_circle(radius):
            circle = Circle(radius)
            # We're keeping track of created objects
            # so that they can be cleaned up at the end of the
            # `factory` function (not the `Factory` class).
            Factory.circle_ids.append(circle.id)
            return circle

        @staticmethod
        def create_triangle(side_length):
            # Here we're using a different fixture provided
            # as one of the `factory` function's parameters.
            triangle = polygon_creator.create_regular_polygon(3, side_length)
            Factory.polygon_ids.append(triangle.id)
            return triangle

        @staticmethod
        def create_square(side_length):
            # Notice that we're introducing abstraction by
            # re-using `create_regular_polygon` across multiple
            # staic methods in this class.
            # That's one of the main purposes of the factory pattern!
            square = polygon_creator.create_regular_polygon(4, side_length)
            Factory.polygon_ids.append(square.id)
            return square

    # Now we're sending the `Factory` class back to the test function.
    # This is the `Factory` class itself, not an instance object!
    yield Factory

    # Here comes the cleanup code.
    # Once again, we're using a fixture provided to `factory`.
    for circle_id in Factory.circle_ids:
        shape_deleter.delete_circle(circle_id)

    for polygon_id in Factory.polygon_ids:
        shape_deleter.delete_polygon(polygon_id)

That’s it! Excluding the comments, there’s really not much code involved.

This is a very simple example, but Pytest’s fixture system makes it easy to extend. For example, it would be trivial to create a fixture that returns a function that itself returns a factory class based on some test-specific parameters.