Module 2: Fixture Architecture The Backbone

6. `yield`: setup and teardown in a single fixture

Overview

All the fixtures you wrote until now assemble something and deliver it with return. And for most of Reservo's pieces that is enough: a Room, a Member, an in-memory Calendar do not need anyone to "close" them when done —when the test ends, Python collects them and that is it—. But in a real test framework, sooner or later a fixture opens something that has to be closed: a temporary file to delete, a connection to release, a test database to empty, a directory to clean up. If the setup opens and nobody closes, the garbage accumulates run after run: temporary files that fill the disk, connections that get exhausted, a test database that drags data from the previous test. Cleanup is part of the backbone as much as the assembly, and pytest gives it an elegant form: yield.

A fixture with yield instead of return splits into two halves: what goes before the yield is the setup, what goes after is the teardown. pytest runs the setup, hands the test the value you put in the yield, lets the test run, and when the test ends —pass or fail— returns to the fixture and runs the teardown. By the end of the lesson you will know how to write fixtures with setup and cleanup, verify with code that the teardown runs even when the test fails (which is exactly when it matters most), and understand why in a composed graph the teardowns run in reverse order to the assembly —that thing you glimpsed in the --setup-show of lesson 5—.

Connection to the module: lesson 5 assembled the fixture graph; this one dismantles it cleanly. yield completes the life cycle of a fixture —it is born in the setup, dies in the teardown— and makes visible why the --setup-show of the booking_service backbone showed the TEARDOWNs in reverse. Lesson 7 uses fixtures that manufacture, which sometimes also clean up with yield. And the mini-project (8) includes at least one fixture with teardown in the Reservo backbone. We stay inside the module's topic: yield is a form of the fixture, not config or markers.

The lab left as it was found

Imagine a shared chemistry lab. Before an experiment, you set up the instruments: you take out the flasks, connect the burner, prepare the reagents. You do the experiment. And afterward —this is the part that distinguishes a good lab from a disaster— you dismantle and clean: you turn off the burner, wash the flasks, put away the reagents, leave the bench as you found it for the next person who arrives.

There is a golden rule in that lab: the cleanup is done no matter what happens with the experiment. If the experiment goes well, you clean. If the experiment explodes, goes wrong, or the reagent does not react, you also clean —in fact, all the more so, because an experiment that went wrong usually leaves more mess—. Nobody would say "the experiment failed, so I leave the burner on and the flasks dirty". On the contrary: fail or not, the bench is left clean for the next person. If the cleanup depended on the experiment going well, a single failed experiment would leave the lab unusable for everyone who follows.

A fixture with yield is that well-run lab. The part before the yield is setting up the instruments —the setup—. The yield is the moment of the experiment —the test runs with what the fixture set up—. And the part after the yield is cleaning and dismantling —the teardown—. The golden rule holds: pytest runs the teardown even if the test fails, just like the lab is cleaned even if the experiment explodes. That guarantee is what keeps your suite from getting dirty over time: each test leaves the scenario as it found it, regardless of whether it passed or blew up, so the next test starts clean.

Worked example: setup, yield, teardown

Let us start with the basic form. A fixture that "opens" a temporary calendar and "closes" it when done. We use print to see the life cycle:

# conftest.py
import pytest


class TempCalendar:
    def __init__(self):
        self.bookings = []

    def close(self):
        self.bookings.clear()


@pytest.fixture
def calendar():
    print("\n  -> setup: open calendar")
    cal = TempCalendar()
    yield cal
    print("\n  -> teardown: close calendar")
    cal.close()

Read the fixture from top to bottom. Before the yield is the setup: it prints the notice and creates the TempCalendar. The yield cal is the hinge: it delivers cal to the test and suspends the fixture there —the test runs now—. When the test ends, the fixture resumes right after the yield and runs the teardown: it prints and calls cal.close(). A test that uses it:

# test_yield.py
def test_add_one_booking(calendar):
    calendar.bookings.append("bk-1")
    assert len(calendar.bookings) == 1

Run with -s (which lets you see the prints). What to expect:

  -> setup: open calendar
.
  -> teardown: close calendar

1 passed in 0.00s

There is the complete cycle, in order: first the setup (before the test runs), then the . of the test passing, then the teardown (after the test). The yield split the fixture into those two halves and pytest ran them around the test. And --setup-show confirms it with its vocabulary:

        SETUP    F calendar
        test_yield.py::test_add_one_booking (fixtures used: calendar) .
        TEARDOWN F calendar

SETUP is everything before the yield; TEARDOWN is everything after. Notice that every fixture appears with a TEARDOWN in --setup-show, even the return ones you saw in previous lessons —only in those the teardown does nothing—. With yield, the teardown does the cleanup work you put after the hinge.

The teardown runs even if the test fails

The guarantee that makes yield valuable is not that it cleans when all goes well —that is the easy part—: it is that it cleans when the test fails, which is exactly when the scenario was left half-done and needs cleanup most. Let us verify it with a test that fails on purpose.

# conftest.py
import pytest


@pytest.fixture
def calendar_file():
    print("\n  -> setup: open temp calendar")
    resource = {"open": True, "bookings": []}
    yield resource
    print("  -> teardown: close temp calendar (runs even if the test failed)")
    resource["open"] = False


# test_teardown_on_failure.py
def test_that_fails(calendar_file):
    calendar_file["bookings"].append("bk-1")
    assert len(calendar_file["bookings"]) == 2  # fails on purpose: there is 1, not 2

The test adds a booking and then asserts there are two —false, there is one—, so it fails. The question is: does the teardown run anyway? Run with -s. What to expect:

  -> setup: open temp calendar
F  -> teardown: close temp calendar (runs even if the test failed)

=================================== FAILURES ===================================
_______________________________ test_that_fails ________________________________

calendar_file = {'open': True, 'bookings': ['bk-1']}

    def test_that_fails(calendar_file):
        calendar_file["bookings"].append("bk-1")
>       assert len(calendar_file["bookings"]) == 2  # fails on purpose
E       AssertionError: assert 1 == 2

Look at the second line: the F of the test failing, and right after the teardown message. Even though the assert blew up, pytest returned to the fixture and ran the cleanup —the lab was cleaned even though the experiment exploded—. This is the deep reason for using yield instead of putting the cleanup at the end of the test body: if you put it in the test, after the assert, an assert that fails stops the test there and the cleanup never runs. With yield, the cleanup lives in the fixture and pytest guarantees it runs no matter what. That is why resources —files, connections, test databases— are cleaned in fixtures with yield, not at the end of the test.

The teardown in a composed graph: reverse order

In lesson 5 you saw that the --setup-show of booking_service dismantled the pieces in reverse order to the assembly, and we promised to explain it here. With yield in several composed fixtures, it is crystal clear. Two fixtures, one depends on the other, both with a yield that prints:

# conftest.py
import pytest


@pytest.fixture
def calendar():
    print("\n  setup calendar")
    yield []
    print("  teardown calendar")


@pytest.fixture
def booking_service(calendar):
    print("  setup booking_service (uses calendar)")
    yield {"calendar": calendar}
    print("  teardown booking_service")


# test_order.py
def test_uses_service(booking_service):
    assert booking_service["calendar"] == []

Run with -s. What to expect:

  setup calendar
  setup booking_service (uses calendar)
.  teardown booking_service
  teardown calendar

Read the order carefully, because it is a rule, not a coincidence:

  • Setup, from the bottom up: first calendar (the dependency), then booking_service (which uses it). It has to be this way: booking_service cannot be set up until calendar exists.
  • Teardown, from the top down (reverse): first booking_service, then calendar. It also has to be this way: you cannot dismantle calendar while booking_service —which uses it inside— is still alive. You have to dismantle first the one that depends, and only then the dependency.

It is the natural order of dismantling anything stacked: the last thing you set up is the first thing you dismantle (systems people call it LIFO, last in, first out). You laid the foundations, then the walls, then the roof; to demolish, you remove the roof, then the walls, and finally the foundations. Never the other way around. That is why the booking_service graph of lesson 5 dismantled booking_service first and calendar last: pytest respects the reverse order so no piece is dismantled while something that needs it is still standing. When your composed fixtures have real cleanup —close the connection, delete the file—, this order is what guarantees the cleanup happens without one piece trying to use another already dismantled.

yield or return: when each one

Not every fixture needs yield. Choosing well avoids useless ceremony and avoids resource leaks.

Use return when the fixture only assembles a value and there is nothing to close. Reservo's pure data —Room, Member, an in-memory Calendar, a dict— do not open external resources; when the test ends, Python collects them on its own. Giving them a yield with an empty teardown would be noise. The vast majority of your data fixtures stay at return.

Use yield when the fixture opens something that has to be closed. A temporary file (you have to delete it), a connection (you have to close it), a test database (you have to empty it), a working directory (you have to clean it up), a test server (you have to shut it down). The sign is simple: if the setup has an "open", "connect", "create on disk" or "start", it almost certainly needs a "close", "disconnect", "delete" or "shut down" that goes after the yield.

The pattern: yield delivers the resource, and what comes after releases it. The canonical form is three parts —open, yield the resource, close—:

@pytest.fixture
def temp_db():
    db = create_test_database()   # setup: open
    yield db                      # deliver to the test
    db.drop()                     # teardown: close, runs pass or fail

A note you may have heard about: pytest also offers an older mechanism, request.addfinalizer(...), to register cleanup. It works, but yield is the modern and recommended form —it reads top to bottom like a story (open, use, close) and is harder to get wrong—. In this guide we use yield; if you see addfinalizer in someone else's code, it is the same with more ceremony.

Common mistakes

Putting the cleanup at the end of the test body instead of in the fixture (cleanup that does not run). What happens: someone opens a resource in the test, does their asserts, and puts resource.close() on the last line of the test; when an assert fails, the close() line never executes and the resource stays open. Why it happens: it seems natural to close where you opened. How to detect it: if you have a .close(), .drop() or os.remove() after an assert inside a test, that cleanup is fragile —any failure before it skips it—. How to fix it: move the opening and the closing to a fixture with yield; the closing goes after the yield and pytest runs it whether the test passes or fails. It is exactly the problem the "teardown even if it fails" demo illustrates: the cleanup has to live where pytest guarantees it, not where a broken assert can skip it.

Putting code after the yield that is not cleanup (confused teardown). What happens: someone puts test logic —more asserts, more setup— after the yield, thinking it executes "during" the test. Why it happens: the yield in the middle of the function confuses about when each half runs. How to detect it: if you have assert or data preparation after the yield, it is misplaced —that runs in the teardown, after the test ended, not during—. How to fix it: remember the rule —before the yield is setup (runs before the test), after the yield is teardown (runs after the test)—. Only cleanup goes after the yield. What the test needs to run goes before, and is delivered in the yield.

Using yield without teardown "just in case" (useless ceremony). What happens: someone puts yield on all their data fixtures, with nothing or almost nothing after the yield, imitating examples without understanding when it is needed. Why it happens: yield looks more "complete" or "professional" than return. How to detect it: if your fixture uses yield but there is no cleanup code after (or it is an empty comment), the yield adds nothing over return. How to fix it: use return for fixtures that only assemble a value with no resources to close —it is clearer and communicates "there is nothing to clean here"—. Reserve yield for when there really is a close. The form of the fixture should communicate its intent: return says "I only assemble", yield says "I assemble and clean".

Exercises

Exercise 1 — Convert to yield. This test opens a temporary calendar file, writes to it, and deletes it on the last line. If the assert fails, the file is never deleted. Rewrite the setup and cleanup as a fixture with yield so the file is deleted whether the test passes or fails.

import os


def test_calendar_export():
    path = "temp_calendar.csv"
    with open(path, "w") as f:
        f.write("focus,2500\n")
    contents = open(path).read()
    assert contents == "studio,4000\n"   # fails: it wrote focus, not studio
    os.remove(path)                       # never runs if the assert fails
See solution
import os

import pytest


@pytest.fixture
def calendar_path():
    path = "temp_calendar.csv"           # setup: prepare
    yield path                            # deliver the path to the test
    if os.path.exists(path):              # teardown: delete, runs pass or fail
        os.remove(path)


def test_calendar_export(calendar_path):
    with open(calendar_path, "w") as f:
        f.write("focus,2500\n")
    contents = open(calendar_path).read()
    assert contents == "studio,4000\n"    # even if it fails, the file is deleted

The path and its deletion moved to the calendar_path fixture. The os.remove lives after the yield, so pytest runs it when the test ends, fail or not —the temporary file is no longer left orphaned on the disk when the assert blows up—. The if os.path.exists(path) protects against the case where the file did not even get created. This is the exact pattern for any resource: open before the yield, close after. (In practice, for temporary files pytest offers the built-in tmp_path fixture, which creates and cleans up a temporary directory for you —but the yield pattern is the one to understand first.)

Exercise 2 — Predict the teardown order. You have three composed fixtures, each with a yield that prints its setup and its teardown: repo (no dependencies), booking_service (depends on repo), and report (depends on booking_service). A test requests report. Write the exact order in which the six messages appear (three setup, three teardown).

See solution
setup repo
setup booking_service (uses repo)
setup report (uses booking_service)
[the test runs]
teardown report
teardown booking_service
teardown repo

The setup goes from the bottom to the top of the graph: repo first (depends on nothing), then booking_service (needs repo), then report (needs booking_service). The teardown goes in reverse order: report first (it is the last one set up and the one that depends on the other two), then booking_service, and repo last (it is the base, the one everyone uses, so it is dismantled when nobody needs it anymore). The LIFO rule: the last thing you set up is the first thing you dismantle. If repo were dismantled before booking_service, the latter could try to use an already-closed repo during its own teardown —exactly the disaster the reverse order avoids—.

Exercise 3 — yield or return? For each fixture, say whether it should use return (only assembles) or yield (assembles and cleans), and why: (a) focus_room, which returns a Room; (b) temp_db, which creates a test database on disk; (c) calendar, an in-memory Calendar(); (d) http_server, which starts a test server on a port.

See solution
  • (a) focus_roomreturn. A Room is an in-memory data object; it opens nothing external. When the test ends, Python collects it on its own. There is nothing to close; return is the correct and clearest choice.
  • (b) temp_dbyield. Creating a test database on disk is a "create" that needs its "delete": if you do not clean it, each run leaves one more database, and worse, the next test could inherit data from the previous one. The database drop goes after the yield.
  • (c) calendarreturn. An in-memory Calendar() is like focus_room: a pure Python object, no external resource. return. (That it is mutable does not change this: the fresh-instance guarantee is given by the function scope, not by the yield.)
  • (d) http_serveryield. Starting a server on a port is a "start" that needs its "shut down": if you do not shut it down, the port stays occupied and the next test that tries to use it fails. The shutdown goes after the yield.

The rule the exercise distills: return for in-memory data that Python collects on its own; yield for external resources —disk, network, ports, connections— that have to be released explicitly. The sign is in the verb of the setup: "create an object" → return; "open/create on disk/start/connect" → yield.

Summary and next step

In this lesson you completed the life cycle of a fixture. yield splits a fixture into two halves: what is before is the setup (runs before the test), what is after is the teardown (runs after the test), and pytest guarantees the teardown whether the test passes or fails —you verified it with a test that failed and still ran its cleanup—. With the analogy of the lab left as it was found you understood why that guarantee matters: if the cleanup depended on the test passing, a single failed test would dirty the scenario for everyone who follows. That is why resources —files, connections, test databases— are closed in fixtures with yield, not at the end of the test body, where a broken assert would skip the cleanup.

And you saw, now with the complete explanation, why in a composed graph the teardowns run in reverse order to the setup: the last thing set up is the first thing dismantled (LIFO), because you cannot dismantle a piece while something that uses it is still alive. It is the same reverse order the --setup-show of booking_service showed in lesson 5. And you learned to choose the form: return for in-memory data that opens nothing, yield for external resources that have to be released —the sign is in the verb of the setup—.

Before moving on you should be able to: write a fixture with yield that assembles and cleans a resource; explain why the teardown runs even if the test fails and why that forces you to put the cleanup in the fixture; and predict the reverse teardown order in a composed graph.

What comes next are two tools that take fixtures one step further. Sometimes a test does not need one room but three, or one configured on demand —then the fixture does not return an object, it returns a function that manufactures objects: the fixture-factory, a preview of module 7—. And sometimes you want a fixture to run for all the tests without them requesting it —autouse, powerful and dangerous—. In lesson 7 you are going to meet both, with the criterion to use autouse without hiding magic that confuses.

Resources

  • Teardown / cleanup with yield — the official section on yield for setup and teardown, exactly the mechanism of this lesson, with the guarantee that the cleanup runs at the end. In English.
  • Adding finalizers with request.addfinalizer — the older cleanup mechanism that yield replaces. It is worth recognizing in someone else's code; for new code, yield is the recommended form.
  • The tmp_path fixture — a built-in pytest fixture that creates and cleans up a temporary directory per test, applying the yield pattern you learned here for the common case of temporary files. Useful when your resource to clean up is a file or folder.