StackPractices
beginner By Mathias Paulenko

Set Up Test Fixtures (pytest, Jest, JUnit)

How to create, scope, and clean up test fixtures with pytest fixtures, Jest hooks, and JUnit 5 lifecycle — plus conftest.py, factory fixtures, and teardown patterns.

Topics: testing

Overview

A test fixture is the known, controlled state — data, objects, environment — a test needs before it runs. Fixtures make tests deterministic: without them, tests depend on external databases, filesystems, or leftover state, and you get flaky failures instead of signal.

This recipe covers fixture setup across the three most common test frameworks: pytest fixtures and conftest.py, Jest beforeEach/beforeAll hooks, and JUnit 5 lifecycle annotations. If you want the pytest-specific deep dive (parametrized fixtures, indirect parametrization), see Pytest Fixtures and Parametrize.

When to Use

  • Tests need a database row, file, or object in a known state before assertions
  • Several tests share the same expensive setup (an app instance, a DB connection, a parsed fixture file)
  • You want to vary inputs without duplicating setup code
  • Integration tests need temporary services, queues, or schema state
  • Every run must see identical data — determinism beats convenience

When NOT to Use

  • The object under test has no external dependencies — instantiate it inline
  • Setup is one line — keep it in the test for readability
  • You’d have to share mutable state without reset — fix the design instead
  • The fixture hides the scenario — explicit setup beats magic

Step-by-Step Implementation

Python (pytest)

import pytest
from dataclasses import dataclass
from typing import Generator

@dataclass
class User:
    id: int
    name: str
    email: str
    role: str = "user"

    def can_delete(self) -> bool:
        return self.role == "admin"

    def can_edit(self) -> bool:
        return self.role in ("admin", "editor")

# 1. Simple fixture — fresh object per test
@pytest.fixture
def admin_user() -> User:
    return User(id=1, name="Alice", email="alice@example.com", role="admin")

# 2. Fixture with teardown (yield pattern)
@pytest.fixture
def temp_db(tmp_path) -> Generator[str, None, None]:
    db_path = str(tmp_path / "test_db.sqlite")
    init_schema(db_path)
    yield db_path            # test runs here
    cleanup_schema(db_path)  # teardown after the test

# 3. Parametrized fixture — test runs once per value
@pytest.fixture(params=["admin", "editor", "viewer"])
def role(request) -> str:
    return request.param

# 4. Factory fixture — builds many variants on demand
@pytest.fixture
def user_factory():
    _counter = 0
    def make(name=None, role="user"):
        nonlocal _counter
        _counter += 1
        return User(
            id=_counter,
            name=name or f"user_{_counter}",
            email=f"user_{_counter}@test.com",
            role=role,
        )
    return make

# 5. Session-scoped fixture — expensive setup, compute once
@pytest.fixture(scope="session")
def compiled_model():
    return load_ml_model("large-model.pkl")

def test_admin_can_delete(admin_user: User):
    assert admin_user.can_delete() is True

def test_permissions_vary_by_role(user_factory):
    admin = user_factory(role="admin")
    viewer = user_factory(role="viewer")
    assert admin.can_edit()
    assert not viewer.can_edit()

Shared fixtures with conftest.py

Fixtures in a conftest.py file are automatically available to every test in that directory and below — no imports needed:

# tests/conftest.py
import pytest
from myapp.db import create_test_engine

@pytest.fixture(scope="session")
def db_engine():
    engine = create_test_engine("sqlite:///:memory:")
    yield engine
    engine.dispose()

@pytest.fixture
def db_session(db_engine):
    # fresh transaction per test, rolled back on teardown
    conn = db_engine.connect()
    trans = conn.begin()
    yield conn
    trans.rollback()
    conn.close()
# tests/orders/test_orders.py — uses conftest fixtures without importing them
def test_order_persists(db_session):
    db_session.execute("INSERT INTO orders VALUES (1, 'SKU-1', 2)")

For input variation, prefer @pytest.mark.parametrize on the test itself — it keeps the data next to the assertion:

@pytest.mark.parametrize("role,expected", [
    ("admin", True),
    ("editor", False),
    ("viewer", False),
])
def test_delete_permission(role, expected):
    assert User(id=1, name="x", email="x@x.com", role=role).can_delete() == expected

JavaScript (Jest)

// factories.js — shared helpers
let counter = 0;
export function createUser(overrides = {}) {
    counter += 1;  // deterministic IDs, not Math.random()
    return {
        id: counter,
        name: `user_${counter}`,
        email: `user_${counter}@test.com`,
        role: 'user',
        ...overrides
    };
}

// __tests__/auth.test.js
import { createUser } from './factories';

let dbConnection;

beforeAll(async () => {
    dbConnection = await createTestDatabase();
});

afterAll(async () => {
    await dbConnection.destroy();
});

beforeEach(() => {
    // reset mutable state before every test
    dbConnection.truncateAll();
});

describe('authentication', () => {
    test('admin can access admin panel', () => {
        const admin = createUser({ role: 'admin' });
        expect(canAccessAdmin(admin)).toBe(true);
    });

    test('viewer cannot access admin panel', () => {
        const viewer = createUser({ role: 'viewer' });
        expect(canAccessAdmin(viewer)).toBe(false);
    });
});

Java (JUnit 5)

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class OrderServiceTest {

    private DatabaseConnection db;
    private OrderService service;

    @BeforeAll
    void init() {
        db = DatabaseConnection.forTest("jdbc:h2:mem:test");
        service = new OrderService(db);
    }

    @AfterAll
    void cleanup() {
        db.close();
    }

    @BeforeEach
    void reset() {
        db.truncateTables("orders", "order_items");
    }

    // Factory method — deterministic builder per test
    private Order.Builder orderBuilder() {
        return Order.builder()
            .customerId(1L)
            .status(OrderStatus.PENDING);
    }

    @Test
    @DisplayName("Valid order can be placed")
    void placeValidOrder() {
        Order order = orderBuilder()
            .addItem(Item.of("SKU-001", 2, BigDecimal.valueOf(29.99)))
            .build();

        OrderResult result = service.place(order);

        assertTrue(result.isSuccess());
        assertEquals(OrderStatus.CONFIRMED, result.getOrder().getStatus());
    }

    @ParameterizedTest
    @CsvSource({
        "admin, true",
        "editor, false",
        "viewer, false"
    })
    void onlyAdminCanDelete(String role, boolean expected) {
        User user = User.builder().role(role).build();
        assertEquals(expected, service.canDelete(user));
    }
}

Fixture Lifecycle at a Glance

flowchart diagram: Suite starts

Setup and teardown hooks map directly across frameworks:

pytestJestJUnit 5
Once per suitescope="session"beforeAll / afterAll@BeforeAll / @AfterAll
Per testscope="function" (default)beforeEach / afterEach@BeforeEach / @AfterEach
Shared fileconftest.pysetupFiles / jest.setup.js@TestConfiguration / base class
Vary inputs@pytest.mark.parametrizetest.each@ParameterizedTest

Scope choice is the common source of flaky fixtures: a session fixture that returns mutable state leaks changes across tests. Keep mutable data at function scope — or make teardown restore it.

What Works

  • Use factory functions, not static data. createUser({ role: 'admin' }) is more flexible than a hardcoded adminUser and prevents copy-paste drift.
  • Reset state between tests: truncate tables, clear mocks, rebuild objects in beforeEach. Order-dependent failures are the usual symptom of skipped resets.
  • Keep fixtures close to the test. A fixture used by one class belongs in that class, not in a shared conftest.py.
  • Name fixtures for what they represent, not how they’re built: premium_customer over user_with_tier_gold_and_100_orders.
  • Use deterministic IDs (counters, sequences, hashed names) — a test that only fails on some values is a debugging trap.

Common Mistakes

  • Sharing mutable fixtures across tests: one test mutates it, the next fails mysteriously. Return fresh instances or reset in beforeEach.
  • Autouse everywhere: implicit fixtures that fire on every test hide why a test fails. Prefer explicit injection.
  • Fixtures that do too much: a fixture creating a user, logging them in, and seeding 10 orders can’t be reused. Compose small fixtures.
  • Depending on wall-clock time: tests using new Date() fail at midnight or in other timezones. Inject a fixed clock.
  • Leaking external resources: temp files, DB connections, and stubs left open cascade into unrelated failures.

See Also

Companion code: runnable fixture setups for the three frameworks live in the companion repo.

Frequently Asked Questions

What is the difference between a fixture and a mock?

A fixture provides the environment or data a test needs — a seeded database, a temp file, a known object. A mock replaces a real dependency to isolate the code under test. You usually need both: the fixture for state, the mock for behavior.

What are pytest fixture scopes?

function (default, fresh per test), class, module, package, and session (once per run). Use wider scopes only for expensive, immutable setup — a session fixture with mutable state leaks changes between tests.

What is conftest.py in pytest?

A special file whose fixtures are auto-discovered by every test in its directory and subdirectories. Put genuinely shared fixtures there (DB engine, app instance); keep single-test fixtures next to their test.

When should I clean up fixtures after a test?

Always, unless you deliberately share an expensive setup within a narrow scope. Cleanup prevents cross-test pollution — the classic source of "passes alone, fails in suite" bugs.

How do I share fixtures across test files?

pytest: conftest.py. Jest: setupFiles or a shared helpers module. JUnit: a base test class or @TestConfiguration. Keep shared fixtures minimal and deterministic.

What's the difference between fixture params and parametrize?

@pytest.fixture(params=[...]) runs every test using that fixture once per value — good for roles or configs. @pytest.mark.parametrize on the test varies inputs without touching the fixture — usually the clearer choice for input/output tables.

Can fixtures depend on other fixtures?

Yes — a fixture can request another by name, and pytest builds the graph (db_session depending on db_engine above). Teardown runs in reverse order automatically.