A tiny Bun test fixture helper
Published July 16, 2027
import { beforeEach, afterEach, test, expect } from "bun:test";
import { file } from "bun";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export const useTempDir = () => {
let dir = "";
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "bun-test-")); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
return () => dir;
};
const getTempDir = useTempDir();
test("writes and reads a file in an isolated temp dir", async () => {
const filePath = join(getTempDir(), "note.txt");
await writeFile(filePath, "hello");
expect(await file(filePath).text()).toBe("hello");
});What
useTempDir() is a small factory that wires up beforeEach/afterEach once and hands back a getter for the current test's isolated temp directory. Call it once per test file, then call the returned getter wherever a test needs a real, empty directory on disk.
Why it matters
A lot of filesystem-touching tests need the same three steps at the top: make a fresh temp directory, remember it, delete it afterward. Copy-pasting that beforeEach/afterEach pair into every test file — or every describe block — is exactly the kind of repeated setup that's easy to get subtly wrong in one file and not the others: a missed recursive: true on cleanup here, a directory that leaks when a test throws before cleanup runs there. Wrapping the pair in a function turns it into one import instead of four lines of boilerplate repeated per file, with exactly one place to fix it if the pattern needs to change.
How it works
useTempDir()has to be called at the top level of a test file (or inside adescribeblock) — that's what registers itsbeforeEach/afterEachwith Bun's test runner in the right scope. Calling it doesn't run anything immediately.- The closure variable
diris what ties the two hooks together:beforeEachassigns a fresh directory before every test,afterEachremoves exactly that directory after every test, and the returned() => dirgetter always reads whatever the current test'sbeforeEachjust set. mkdtempappends a random suffix to the"bun-test-"prefix, so parallel test files — or a crashed previous run — never collide on the same directory name.- The
test(...)block proves the fixture is real rather than just plausible: it writes a file into the directory the fixture handed back, then reads it back withfile(filePath).text(). IfuseTempDirwired the hooks in the wrong order, this test fails against an empty or already-deleted directory.
Gotchas
bun:testdoesn't have a built-in fixture API — notest.extendlike Vitest or Playwright. This pattern is justbeforeEach/afterEachwrapped in a plain function, not a special Bun feature; it works because Bun registers whateverbeforeEach/afterEacha file calls at load time, in whatever scope (top-level or insidedescribe) they're called from.- Calling
useTempDir()more than once in the same file registers a second, independentbeforeEach/afterEachpair — each with its owndirclosure — rather than sharing one. That's usually not what you want if twodescribeblocks are meant to share the same temp-directory logic. force: trueonrmswallows the error if the directory is already gone (a test that deletes it manually, for instance). Without it, a test that cleans up after itself would make the fixture's ownafterEachthrow on top of an already-passing test.
Related
beforeAll/afterAll— the same idea at file/describe scope instead of per-test. Use those when setup is expensive and safe to share across every test in the file, rather than isolated per test.Bun.write/Bun.file— the same filesystem primitives used here, outside of a testing context.