Tinkerer

Code and Climate Change. Blog about software development in ClimateTech RSS Icon


You Should Run Cleanup Code at the Start of Your Tests

I recently came across an integration test that demonstrates what I believe is an antipattern.
This particular test was consistently failing at the start with a “Unique constraint violation” in the database when calling the createUser function.

The test, with irrelevant details omitted, looked like this:

it(() => {
    // Setup
    someOtherSetupCode();
    createUser({id: "TEST_ID"});
    
    // Actual test code

    // Cleanup
    someOtherTeardownCode();
    deleteUser({id: "TEST_ID"});
})

This approach to organizing your test with some setup, some test code, and some cleanup at the end might seem logical.
However, there’s a significant flaw: The cleanup code might never run.
When we put the cleanup code at the end of the test, it means it won’t run if the test fails.

In this case, that meant we ended up in a particularly problematic loop: if the test failed once, it could never pass without human intervention. This was because createUser would throw a unique constraint error if the id had already been used, which meant the test would not proceed, preventing the cleanup code from ever running.

First Alternative: beforeEach and afterEach

A better alternative, if your testing framework supports it, is to use hooks for running code before and after each test, such as beforeEach and afterEach. This would make the test code look like this:

beforeEach(() => {
    someOtherSetupCode();
    createUser({id: "TEST_ID"});
})

it(() => {
    // Actual test code
})

afterEach(() => {
    someOtherTeardownCode();
    deleteUser({id: "TEST_ID"});
})

However, this still isn’t quite optimal for a few reasons:

Best Alternative: Clean Up First

The best approach, in my opinion, is for each test to ensure that any data that should not exist is deleted at the start of the test, leaving it in the database at the end. The code would look like this:

it(() => {
    // Clean up any potential leftover state from previous test runs
    someOtherTeardownCode();
    deleteUser({id: "TEST_ID"});
    
    // Ensure consistent state
    someOtherSetupCode();
    createUser({id: "TEST_ID"});
    
    // Actual test code
})

This way, you guarantee that the cleanup always runs before the test, and if you need to debug anything in the database after a test failure, all relevant data is still available for inspection.
The only caveat is that your teardown code must handle both cases: when there is something to clean up and when there isn’t.

Did you enjoy this post? Please share it!