skip to content

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

level: seniorimportance: should knowfreq 48%

answer

  1. The destination is decided too early
  2. Configuration belongs to the caller
  3. Give the function somewhere to write
  4. A temp directory plus an injected path
  5. Stage beside the target, then replace

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.

solid answer

~50 s

The untestable part is not the file I/O, it is the hardcoded destination: a module-level constant resolved at import time leaves the test no way to redirect the write except by reaching into the module's internals. Give the function a `store: Path` parameter, defaulting to the production constant so callers are unaffected, and the test can point it at `Path(tmpdir) / "memory.tsv"`. Now the partial-failure path is reachable: write the new content to a staged sibling file, `Path.replace` it onto the target only after the write succeeds, and remove the staging file on the error path. The test writes a known original, forces the failure, then asserts the original content is intact and no stray staging file remains. Keep the staging file **next to the target**, because the rename is only atomic within one filesystem.

code

python · 13 lines
python
from pathlib import Path

DEFAULT_STORE = Path("/var/lib/tm/memory.tsv")

def update_memory(entries, store: Path = DEFAULT_STORE) -> None:
    store = Path(store)
    staged = store.with_suffix(".tmp")
    try:
        staged.write_text("\n".join(entries) + "\n", encoding="utf-8")
        staged.replace(store)
    except Exception:
        staged.unlink(missing_ok=True)
        raise

go deeper

for a junior

Recall the shape: a function that decides its own output path cannot be redirected, so pass the path in as an argument and let the test supply one inside a temporary directory it controls.

for a middle

Explain why a module-level constant resolved at import blocks the test, and how a defaulted Path parameter keeps production callers unchanged. Know that Path children are built with the / operator and that encoding is passed explicitly.

for a senior

An interviewer expects the durability story: stage beside the target, replace only after a complete write, clean the staging file on the error path, and know that the rename stops being atomic across filesystems. Then show the test that proves the original survived.

for a principal

Own the boundary between configuration and behaviour. Deciding where to write is a caller's job; doing the write is a small testable function. Establishing that split is what lets a team test durability logic in milliseconds instead of booking a staging environment.

### The seam, and why the constant blocks it A *seam* is a place where you can change behaviour without editing the code under test. When a writer resolves its destination itself — `STORE = Path(os.environ["TM_HOME"]) / "memory.tsv"` evaluated at import — there is no seam at all. The path is fixed before any test runs, so the only lever left is mutating the module's global, which couples every test to the module's internals and breaks the moment the constant is renamed or inlined. The fix is a parameter: ```python def update_memory(entries, store: Path = DEFAULT_STORE) -> None: ... ``` Production callers keep the one-argument call. The test passes its own path and exercises the shipped code path end to end, against a real filesystem, with no interception anywhere. That is the property that matters: nothing about the write is simulated, so encoding, permissions, rename semantics and the rollback branch are all genuinely tested. A few details make the parameter pull its weight: * **Type it as `Path` and normalise on entry** with `store = Path(store)`, so a caller passing a `str` or any `os.PathLike` still works. `Path` implements `os.PathLike`, so anything you pass it on to accepts it. * **Build children with `/`**, never string concatenation, and never a hardcoded separator. * **Decide directory-or-file deliberately.** Taking the directory and appending a fixed filename gives the caller less control but keeps the layout owned by the module; taking the full file path is more flexible. Pick one and be consistent, because tests read much better when the parameter means the same thing everywhere. * **A default that is an immutable `Path` is safe** to evaluate at `def` time — unlike a mutable default such as a list, there is nothing to accumulate. ### Making the partial-failure rollback observable Once the path is injectable, the interesting test becomes possible: what does the store look like when the update dies half-way? A truncate-and-rewrite implementation (`open(store, "w")` then a loop) has already destroyed the old content by the time entry three fails, so the answer is "a corrupt half-file" — and a service that reads it on start-up now fails on data it wrote itself. The standard shape is stage-then-swap: 1. Write the complete new content to a staging file beside the target — `store.with_suffix(".tmp")`. 2. `staged.replace(store)` once the write has finished. `Path.replace` maps to `os.replace`, which overwrites the destination and is atomic on POSIX: a concurrent reader sees either the whole old file or the whole new one, never a partial write. 3. On any exception, delete the staging file (`unlink(missing_ok=True)`) and re-raise. The target is untouched, which *is* the rollback. The atomicity has a hard boundary worth stating out loud in an interview: `os.replace` fails across filesystems. Staging into the platform temp directory and renaming onto a target on a different mount raises `OSError`, and the naive "fix" — copy instead of rename — throws the atomicity away. Stage in the target's own directory. With the seam in place the test is direct: create a temporary directory, write a known original into it, call the updater with an input whose serialisation fails part-way, catch the exception, then assert the original bytes are still there and that `list(tmpdir.iterdir())` contains only the target. Injecting the failure is itself a design question — a value that fails to serialise, a second injected callable that raises on the third record, or a target directory made read-only after the staging write. ### Why not just drive the real system The reflex answer is "run it against the real store in a staging environment". For a service with a 45-second cold start, that buys one assertion per minute and makes the failure branch, which requires killing the process at exactly the wrong moment, effectively untestable. A temporary directory plus an injected path exercises the same code in milliseconds and lets you provoke failures on demand. Keep the end-to-end run for the deployment question — does the process have permission to write where it is configured to — which is the one thing the temp directory genuinely cannot answer. ### The wider point Injecting the path is one instance of a rule: a function should not both *decide* what to touch and *do* the touching. Pull the decision up to the caller, where configuration lives, and the doing stays a small, testable function. The signature also becomes self-documenting — a reader can see that this function writes to exactly one place, which a module-level constant hides.

  • Is Path.replace atomic when the staging file lives in the platform temp directory?
    No. Path.replace delegates to os.replace, which cannot rename across filesystems and raises OSError when the source and destination are on different mounts. Staging in the target's own directory keeps the rename within one filesystem, which is what makes the swap atomic. Replacing the rename with a copy to work around the error silently reintroduces the partial-write window.
  • How do you make the test actually take the failure branch rather than the happy path?
    Feed it input that fails during serialisation, hand in a collaborator that raises on the nth record, or make the destination directory unwritable after the staging file exists. Whichever you choose, assert two things: the previous content is byte-for-byte intact, and no staging file was left behind for the next run to trip over.
  • Why give the store parameter a default instead of making it required?
    So production call sites stay short and the module still documents where it writes, while tests override it freely. An immutable Path default is safe to evaluate at def time. If the value must come from the environment, resolve it in the caller or in a small factory rather than at import, so nothing is fixed before the process has finished configuring itself.

It is the difference between a machine bolted to one outlet in one building and one with a power cable: same machine, but you can now plug it into a test bench.

saying these in an interview costs you the question

  • Points the test at a real path inside the repository
  • Says the writer cannot be tested without a staging environment
  • Stages the file in the temp root and renames across filesystems
  • Truncates the target before the new content exists
  • Builds paths by concatenating strings with a separator
  • Resolves the destination from the environment at import time

context