Best Practices for Writing Isolated Tests in TypeScript
Tests were passing on my machine. Failing in CI. Passing again when I ran them individually. Failing when I ran the full suite. The problem? One test was ...
19 Apr 2024

Tests were passing on my machine. Failing in CI. Passing again when I ran them individually. Failing when I ran the full suite. The problem? One test was setting a global variable that another test depended on. Run them out of order and everything broke.
That's a test isolation failure. Each test was reaching into shared state instead of standing on its own.
What isolated tests mean
An isolated test sets up its own world, runs its scenario, checks the result, and cleans up. It doesn't rely on another test running first. It doesn't leave behind state that another test needs.
If you can run any single test in your suite — in any order, at any time — and get the same result, your tests are isolated.
Three rules for test isolation
1. No test interdependencies
Each test creates its own context. Don't rely on state left behind by a previous test.
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator(); // fresh instance every time
});
it('should add two numbers correctly', () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
it('should subtract two numbers correctly', () => {
const result = calculator.subtract(5, 2);
expect(result).toBe(3);
});
});
Each test gets its own Calculator. The add test doesn't care if the subtract test ran first.
2. Use setup and teardown hooks
beforeEach creates a clean state. afterEach destroys it. Together they guarantee every test starts and ends in a known condition.
describe('UserService', () => {
let userService: UserService;
let databaseMock: Database;
beforeEach(() => {
databaseMock = createDatabaseMock();
userService = new UserService(databaseMock);
});
afterEach(() => {
databaseMock.close();
});
it('should retrieve a user by ID', () => {
databaseMock.seed({ id: '1', name: 'Alice' });
const user = userService.getUserById('1');
expect(user.name).toBe('Alice');
});
});
Without teardown, database connections leak. Mocks carry stale state. The next test starts in an unpredictable world.
3. Stub external dependencies
Real databases, APIs, and file systems make tests slow, flaky, and coupled to infrastructure. Replace them with stubs, mocks, or fakes.
describe('UserService', () => {
let userService: UserService;
let databaseStub: DatabaseStub;
beforeEach(() => {
databaseStub = createDatabaseStub();
userService = new UserService(databaseStub);
});
it('should retrieve a user by ID from the database', () => {
databaseStub.getUserById.returns({ id: '123', name: 'John' });
const user = userService.getUserById('123');
expect(user.name).toBe('John');
});
});
The stub lets you control exactly what the dependency returns. No network. No disk. No flakiness.
The trade-off
Isolated tests require more setup code. Each test rebuilds its world from scratch instead of sharing a convenient global state. That's more boilerplate.
But shared state is a trap. It works until someone adds a test, changes the order, or runs in parallel. Then you spend more time debugging tests than debugging code.
The upfront cost of isolation saves you from the ongoing cost of mysterious, order-dependent failures. Write tests that stand alone. Every single one.