skip to content

Filesystem and Stream Seams

Testing code that touches files, stdout or stdin without faking the filesystem: real temp dirs, injected Path objects, in-memory streams. Interviewers ask which one you would reach for and why.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

How do tempfile.TemporaryDirectory and unittest's addCleanup keep a test's scratch files isolated?

level: middleimportance: must knowfreq 60%

answer

  1. Each test needs its own private directory
  2. Never a fixed path under /tmp
  3. Register the teardown at acquisition time
  4. Cleanup must survive a failing setUp
  5. TemporaryDirectory plus addCleanup, path as Path

basics

~10 s

tempfile.TemporaryDirectory creates a fresh, uniquely named directory per test, so no two tests share a path. Registering its cleanup with TestCase.addCleanup deletes the tree even when setUp or the test itself raises.

solid answer

~40 s

`tempfile.TemporaryDirectory()` makes a new directory with a random name under the platform temp root and gives you its path as `.name`; calling `.cleanup()` removes the tree. In a `unittest.TestCase` you create it in `setUp` and immediately register `self.addCleanup(tmp.cleanup)`. That ordering is the point: cleanups registered with `addCleanup` run in last-in-first-out order and run **even when `setUp` raises afterwards**, whereas `tearDown` is skipped entirely if `setUp` fails. Wrap the path in a `pathlib.Path` and build everything under it with `/`. Since 3.11 `self.enterContext(tempfile.TemporaryDirectory())` does the enter-and-register in one line. The alternative — a hardcoded path such as `/tmp/report-tests` — collides between concurrent runs, leaks state from a failed run into the next one, and turns an unrelated failure into a mystery.

code

python · 16 lines
python
import tempfile
import unittest
from pathlib import Path

class ExportTest(unittest.TestCase):
    def setUp(self):
        tmp = tempfile.TemporaryDirectory()
        self.addCleanup(tmp.cleanup)
        self.workdir = Path(tmp.name)

    def test_writes_report(self):
        target = self.workdir / "report.txt"
        target.write_text("ok", encoding="utf-8")
        self.assertEqual(target.read_text(encoding="utf-8"), "ok")

unittest.main(argv=["prog"], exit=False, verbosity=0)

go deeper

for a junior

Know that each test should get its own throwaway directory and that tempfile.TemporaryDirectory creates one. Be able to say why writing to a fixed path such as /tmp/mytests, or into the repository, causes trouble between runs.

for a middle

Explain the mechanics: TemporaryDirectory gives a uniquely named tree plus a cleanup() call, and addCleanup runs that cleanup last-in-first-out even when setUp raises after registering it. Wrap the path in pathlib.Path and pass encoding explicitly.

for a senior

An interviewer expects the failure modes you have actually hit: leftover files from a crashed run turning into order-dependent flakes, cleanup errors on locked files, and concurrent runs sharing a fixed path. Know ignore_cleanup_errors and the debugging value of delete=False.

for a principal

Own the policy: scratch state is per-test and framework-managed, never a shared path a team can come to depend on. That choice is what makes a suite safe to parallelise and safe to run twice in a row, which is the foundation of trustworthy CI.

### Why a real temporary directory rather than a fake filesystem Code that opens files, renames them, checks permissions or hands a path to another process is only honestly exercised against a real filesystem. A temporary directory gives you that at almost no cost: a private, empty tree that exists for the duration of one test and is deleted afterwards. The two things that make it safe are **uniqueness** (nothing else writes there, so tests can run concurrently) and **guaranteed removal** (nothing survives to influence the next test). ### What TemporaryDirectory gives you `tempfile.TemporaryDirectory()` creates the directory eagerly, under the platform's temp root — honouring `TMPDIR` and friends on Unix — with a random component in the name so two simultaneous runs never collide. The object exposes `.name` (the path, as a `str`) and `.cleanup()`, and it is also a context manager whose `__enter__` returns that same path string. Cleanup recursively removes the tree, including anything the code under test created. Two keyword arguments are worth knowing. `ignore_cleanup_errors` (added in 3.10) stops a stubborn file — the classic case is Windows refusing to delete a file another handle still has open — from turning a passing test into an error during teardown. `delete` (added in 3.12) lets you keep the tree on disk, which is useful when you are debugging a failure and want to inspect what the code actually wrote. `tempfile.mkdtemp()` is the lower-level cousin: it returns a path and removes nothing — *you* own the `shutil.rmtree`. Reach for `TemporaryDirectory` unless you specifically need that control. `tempfile.NamedTemporaryFile` is the single-file equivalent, for code that needs a path to one file rather than a directory. ### Why addCleanup and not tearDown `unittest.TestCase.addCleanup(fn, *args)` pushes a callable onto a stack that the framework unwinds after the test method, in last-in-first-out order. Three properties make it strictly better than `tearDown` for resources: 1. **It runs even when `setUp` fails.** If `setUp` creates a temp directory, then a later line in `setUp` raises, `tearDown` is never called — unittest treats the test as an error before it starts. Any cleanup already registered with `addCleanup` still runs, so the directory is removed. 2. **Registration sits next to acquisition.** The line that creates the resource and the line that promises to release it are adjacent, so it is obvious when one is missing. A `tearDown` twenty lines away drifts out of sync with `setUp`. 3. **Independent failures.** Each cleanup is called even if an earlier one raised, and every error is reported. A single `tearDown` body stops at its first exception, silently skipping the rest. Since 3.11, `TestCase.enterContext(cm)` enters a context manager and registers its `__exit__` as a cleanup in one call, returning whatever `__enter__` returned — the concise form of the same idea. `addClassCleanup` (3.8) and `unittest.enterModuleContext` (3.11) do the same for class- and module-scoped fixtures. ### The path itself Store the directory as a `pathlib.Path`, not a string, and derive every file from it with the `/` operator. That keeps the test free of `os.path.join` noise and of separator assumptions, and it makes the assertions read well: `(self.workdir / "out.tsv").read_text(encoding="utf-8")`. Always pass `encoding=` explicitly in tests — a test that relies on the platform default encoding will pass on your machine and fail in a differently configured container. ### What the anti-pattern costs A hardcoded scratch path — `/tmp/report-tests`, or worse a directory inside the repository — fails in four distinct ways, and naming them is what an interviewer is listening for: * **Collisions.** Two concurrent runs (a local run and CI on the same machine, or two workers of a parallel runner) share the directory and corrupt each other. * **Leakage.** A run that fails half-way leaves files behind; the next run passes because of them, or fails for reasons that have nothing to do with the change under test. That is exactly the shape of a flaky, order-dependent suite. * **Destructiveness.** Cleanup code that removes a fixed path is one typo away from deleting something real; cleanup scoped to a directory you just created cannot be. * **Portability.** `/tmp` is not the temp root everywhere, and repository-relative scratch files end up committed. Do the cleanup registration, not the cleanup itself, inside the test body. A `tmp.cleanup()` call written as the last statement of a test never executes when an assertion above it fails — which is precisely the run whose leftovers you would most like removed.

  • How does addCleanup differ from putting the same code in tearDown?
    Cleanups registered with addCleanup run in last-in-first-out order, run even when setUp raises after registration, and each one runs even if an earlier one failed. tearDown is skipped entirely when setUp errors, and its body stops at the first exception. addCleanup also keeps the release next to the acquisition, so a missing teardown is visible at a glance.
  • When would you use tempfile.NamedTemporaryFile instead of a temporary directory?
    When the code under test needs the path of a single file rather than somewhere to write several. Watch the reopen constraint: on Windows a second open of the same name while the object is open fails, so on 3.12+ pass delete_on_close=False, close the handle, let the code reopen it by name, and let the context manager delete it at exit.
  • Why should the test not call tmp.cleanup() as the last line of the test body?
    Because a failing assertion above it raises and that line never runs, so the one scenario whose leftovers matter most is the one that leaks. Registering the cleanup at creation time moves it onto the framework's unwind path, where it runs on the success path, the failure path and the error path alike.

saying these in an interview costs you the question

  • Hardcodes /tmp/testdata and removes it in tearDown
  • Thinks tearDown still runs when setUp raised
  • Uses tempfile.mkdtemp and never removes the tree
  • Calls cleanup as the last line of the test body
  • Assumes every test receives the same temp path
  • Writes scratch files into the repository working tree

context

open as a page

How does contextlib.redirect_stdout let a test capture what a function prints?

level: juniorimportance: should knowfreq 55%

basics

~10 s

contextlib.redirect_stdout rebinds sys.stdout to any writable text object for the duration of its with block. Point it at an io.StringIO, run the code, then assert on that buffer's getvalue() text.

open as a page

A translation-memory updater bakes its output path in at import time. What seam makes its rollback testable?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Make the destination a parameter: accept a pathlib.Path argument, defaulting to the production location. The test then passes a path inside a temporary directory, drives the real write-and-rollback code, and inspects the files that survive.

open as a page

Where does io.StringIO stop being a faithful stand-in for a file opened by open()?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

io.StringIO holds str only, so no encoding or decoding ever happens; its default newline setting does not translate CRLF the way open() does; and it has no operating-system descriptor, so fileno() raises io.UnsupportedOperation. Byte-level code needs io.BytesIO.

open as a page