
When you’re writing tests, the setup and teardown phases can either be a blessing or a curse depending on how you organize them. That’s where beforeEach and afterEach come into play. Their primary role is to keep your tests isolated and your code DRY (Don’t Repeat Yourself).
beforeEach runs before every single test in a given scope. This means if you have multiple it blocks, whatever you put in beforeEach will execute fresh for each test, ensuring consistency and eliminating cross-test pollution. It’s like resetting the stage so your actor can perform the same scene without any leftover props from previous acts.
On the flip side, afterEach runs after each test completes. This is your cleanup crew. It’s where you tear down anything you set up that isn’t automatically garbage collected or could interfere with subsequent tests. This might include clearing mocks, closing database connections, resetting DOM elements, or clearing timers.
Take a simple example where you want to test a function that manipulates DOM elements. If you create your DOM node inside every test, you’re repeating yourself and risking inconsistent setup. Instead, use beforeEach to create the DOM element fresh each time, and afterEach to remove it after the test finishes.
describe("DOM manipulation", () => {
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
it("adds a child node", () => {
const child = document.createElement("p");
container.appendChild(child);
expect(container.children.length).toBe(1);
});
it("starts empty", () => {
expect(container.children.length).toBe(0);
});
});
Notice how the container is guaranteed to be a fresh node every time. This prevents weird flaky tests where one test might fail because another test left some garbage behind. The clarity and reliability you get from this pattern are worth the minimal overhead.
Moreover, this approach scales well. When your tests grow in number and complexity, it’s easy to add more setup logic in beforeEach and more cleanup in afterEach without cluttering individual tests. This keeps the tests focused on the behavior they are verifying, not on preparing or cleaning the environment.
One last thing to keep in mind: these hooks are scoped. If you nest describe blocks, the inner beforeEach will run after the outer beforeEach, and the inner afterEach will run before the outer afterEach. This lets you compose setup and teardown logic modularly, which is incredibly powerful for complex test suites.
describe("outer scope", () => {
beforeEach(() => {
console.log("outer beforeEach");
});
afterEach(() => {
console.log("outer afterEach");
});
describe("inner scope", () => {
beforeEach(() => {
console.log("inner beforeEach");
});
afterEach(() => {
console.log("inner afterEach");
});
it("runs both beforeEach hooks", () => {
expect(true).toBe(true);
});
});
});
Running that will print the messages in this order for the test:
outer beforeEach → inner beforeEach → test → inner afterEach → outer afterEach
This predictable layering means you can build complex setups incrementally without losing control.
If you skip these hooks and do all setup manually inside each test, you’ll quickly find yourself copy-pasting and introducing subtle bugs because tests aren’t isolated anymore. Understanding and using beforeEach and afterEach is the foundation of writing maintainable, reliable tests that scale.
But it’s not just about running some code before and after tests – it’s about crafting a clean environment where your tests can run without side effects. That mindset is what separates flaky test suites from rock-solid ones. Now, once you get this, writing tests becomes less about fighting your tools and more about expressing intent clearly and succinctly.
Here’s a quick sanity check: if you find yourself repeating the same setup code inside multiple tests, extract it into beforeEach. If you need to reset or clean global states, use afterEach. And if your tests still feel flaky, double-check whether cleanup is happening correctly – leftover state is a silent killer.
Next up, we’ll talk about writing clean and maintainable tests with these hooks, including some patterns and anti-patterns I wish I had learned earlier… but for now, keep your eyes on the hooks and your tests will thank you.
iPhone 17 16 15 Charger Fast Charging 10FT - 20W USB C Charger Block with 10FT Type C to C Cable Compatible with iPhone 17/17 Pro/17 Pro Max/Air/16/16e/15, iPad Pro, iPad Air (White)
$9.90 (as of June 2, 2026 22:39 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Writing clean and maintainable tests with beforeEach hooks
One pattern I rely on heavily is using beforeEach to initialize test doubles or mocks that every test in a suite needs. Instead of sprinkling jest.fn() or mock implementations inside each test, define them once in beforeEach, then reset them as needed in afterEach. This keeps tests focused on behavior, not setup noise.
describe("API client", () => {
let fetchMock;
beforeEach(() => {
fetchMock = jest.fn(() => Promise.resolve({ json: () => ({ data: 123 }) }));
global.fetch = fetchMock;
});
afterEach(() => {
jest.clearAllMocks();
delete global.fetch;
});
it("calls fetch with the correct URL", async () => {
const result = await fetchData("/endpoint");
expect(fetchMock).toHaveBeenCalledWith("/endpoint");
expect(result.data).toBe(123);
});
it("handles fetch errors gracefully", async () => {
fetchMock.mockImplementationOnce(() => Promise.reject(new Error("Network error")));
await expect(fetchData("/fail")).rejects.toThrow("Network error");
});
});
Here, fetchMock is reset before each test, ensuring no leftover call history or altered behavior leaks between tests. This pattern scales well with more complex mocks and spies, and it keeps your tests crisp and intention-revealing.
Another common use case is setting up a shared piece of state or context that many tests rely on. For example, if you’re testing a function that depends on a user object, create that user in beforeEach so each test starts with a pristine user instance.
describe("user permissions", () => {
let user;
beforeEach(() => {
user = {
name: "Alice",
roles: [],
can: (permission) => user.roles.includes(permission),
};
});
it("denies access when no roles", () => {
expect(user.can("admin")).toBe(false);
});
it("grants access when role added", () => {
user.roles.push("admin");
expect(user.can("admin")).toBe(true);
});
});
By setting up user fresh each time, you avoid the classic pitfall where one test mutates the object and causes a cascade of failures in others.
Finally, don’t shy away from nesting beforeEach hooks when your test structure requires it. For example, if you have a general setup and then more specific setup for a subset of tests, layering beforeEach calls keeps things modular and readable.
describe("database tests", () => {
let db;
beforeEach(() => {
db = new Database();
db.connect();
});
describe("with seeded data", () => {
beforeEach(() => {
db.seed([{ id: 1, name: "test" }]);
});
it("finds seeded item", () => {
const item = db.findById(1);
expect(item.name).toBe("test");
});
});
afterEach(() => {
db.disconnect();
});
});
This style keeps your test setup aligned with the test scope, making it easier to reason about what exactly is prepared for each test.
Keep in mind that beforeEach hooks should do just enough setup to get your test running. If you find yourself stuffing a beforeEach with too many responsibilities, consider splitting your tests into smaller, more focused suites. This prevents your setup code from becoming a tangled mess that is hard to debug or extend.
Remember, the goal is maintainability. Tests should be easy to read and understand at a glance. When you open a test file, the beforeEach hooks should clearly communicate the environment each test runs in, so you can focus on what the test is asserting rather than how it got there.
In short, beforeEach is your friend for clean, DRY, and reliable tests. Use it wisely, keep it focused, and pair it with afterEach for proper cleanup. That combo is the foundation of a test suite that won’t drive you crazy as it grows.
Next, we’ll tackle some of the common pitfalls with afterEach cleanup and how to avoid the subtle bugs that can sneak in when you forget to tidy up after your tests…
Avoiding pitfalls and common mistakes with afterEach cleanup
When using afterEach, the goal is to ensure that your testing environment is reset and clean before the next test runs. However, there are several common pitfalls that can lead to unexpected failures or flaky tests if not handled correctly.
One common mistake is failing to account for asynchronous operations. If your test involves promises or callbacks, and you don’t wait for them to resolve before running cleanup code, you may end up trying to manipulate DOM elements or state that are still in use. Always ensure your afterEach block waits for any pending asynchronous operations to finish.
afterEach(async () => {
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate async cleanup
});
Another issue arises when dealing with global state. If you modify a global variable or a singleton service in a test and forget to reset it in afterEach, subsequent tests may fail unpredictably due to the modified state persisting. Always restore global state to its original value in your cleanup logic.
afterEach(() => {
global.someGlobalVar = originalValue; // Restore original state
});
It’s also crucial to clean up any event listeners or subscriptions that your tests may create. Leaving these in place can lead to memory leaks or unexpected behavior in tests that run after. Make sure to unsubscribe or remove event listeners in your afterEach.
afterEach(() => {
document.removeEventListener("click", handleClick);
});
Additionally, avoid using afterEach for assertions. Assertions should be contained within your test cases. Using afterEach to assert that certain state or conditions are met can lead to confusion and make it harder to understand which test is failing. Keep your assertions within the it blocks to maintain clarity.
Lastly, be cautious when using afterEach to reset mocks or spies. If you clear or reset mocks in afterEach, ensure that they’re correctly set up in beforeEach to avoid false positives in your tests. This is particularly important if you are relying on the outcome of the mocks for your test assertions.
afterEach(() => {
jest.clearAllMocks(); // Clear mock history
});
In summary, while afterEach is a powerful tool for maintaining a clean testing environment, it’s essential to use it judiciously. Ensure that you handle asynchronous operations, restore global state, remove event listeners, and keep assertions confined to the appropriate context. By avoiding these common mistakes, you can maintain a robust test suite that performs reliably over time.
As your test suite expands, the importance of a well-structured afterEach becomes even more critical. Keeping tests independent and ensuring that each test runs in isolation will save you countless hours of debugging and frustration.
