Best practices for writing unit tests
Unit tests should work like a safety net, but they often end up becoming a problem. Fragile tests, tied to implementation details, break with simple refactors and create more noise than help. Writing these tests stops feeling like engineering work and turns into a boring task. Over time, the suite stops preventing bugs and becomes just another cost in every change, slowing everyone down.
The problem usually comes from misunderstanding what a unit test should verify. We test how the component works internally, not what it delivers externally. This creates a duplicated version of the logic inside the tests, which also needs to be maintained. When you refactor, you change the production code and then need to adjust the same thing in the tests. Here are some practices to keep tests useful and easy to maintain.
Stop testing implementation details
Tests coupled to implementation are one of the main reasons a suite becomes hard to maintain. They break when you rename a private function, change an internal structure, or swap a dependency, even when the component’s behavior stays the same. These false negatives in CI end up making the team stop trusting the alerts or simply remove tests that broke.
We get there because it is the easiest path. You write the function and then a test that calls that function and mocks its dependencies. In the end, the test just replicates the code’s structure. If the processOrder function calls an internal validateCart and a calculateTaxes helper, this kind of test will end up checking whether those two helpers were called.
The solution is to treat what you are testing as something independent from internal details. Tests should use only the public API and validate the result. A test does not need to know private methods or internal state. In unit testing, the unit is the behavior, not an isolated function or class.
Consider a simple order processing service. A test coupled to implementation would mock and verify calls to internal collaborators.
// Bad: the test knows the internal validator and calculator
test('processOrder calls the validator and calculator', () => {
const mockValidator = jest.fn().mockReturnValue(true);
const mockCalculator = jest.fn().mockReturnValue({ total: 110 });
const orderService = new OrderService(mockValidator, mockCalculator);
orderService.processOrder({ items: [{ price: 100 }] });
expect(mockValidator).toHaveBeenCalled();
expect(mockCalculator).toHaveBeenCalled();
});
This test tells you nothing about the real result. If you refactor OrderService to use a single validation and calculation step, the test breaks even if the final order is processed correctly. A behavior-focused test would be different.
// Good: the test only cares about the final state
test('processOrder returns a processed order with the correct total', () => {
// Use simple real collaborators or fakes, if needed
const validator = new RealValidator();
const calculator = new RealTaxCalculator();
const orderService = new OrderService(validator, calculator);
const result = orderService.processOrder({ items: [{ price: 100 }] });
expect(result.status).toBe('PROCESSED');
expect(result.total).toBe(110); // Assuming 10% tax
});
test('processOrder throws an error for an invalid order', () => {
const orderService = new OrderService(new RealValidator(), new RealTaxCalculator());
expect(() => {
orderService.processOrder({ items: [] }); // Empty invalid order
}).toThrow('Invalid order');
});
The second version checks the contract: give it a valid order, get a processed result. Give it an invalid one, get an error. It does not care how OrderService does the work. You can completely rewrite the internals of OrderService, and as long as the behavior is preserved, the tests keep passing.
Best practices for writing unit tests
Thinking in terms of behavior leads to a few good day-to-day habits.
Name tests by describing the scenario and the result
When a test fails, its name is the first thing a developer reads. A name like testProcessOrder is useless because it explains nothing. You need to read the whole test to understand what went wrong.
The name should describe the state, the action, and the expected result. You can use conventions like Given[Context]_When[Action]_Then[ExpectedResult] or simply write a clear sentence.
- Bad:
test_user_update - Good:
updateUser_with_invalid_email_throws_validation_error - Good:
returns_error_when_attempting_to_deactivate_last_admin_account
A good name makes the CI failure log understandable on its own. You know exactly which business rule broke without needing to open a file.
Keep assertions focused on a single logical outcome
A test should have only one reason to fail. If you put several unrelated assertions in a single test, it becomes a nightmare to debug. You will not know whether the second assertion failed on its own or because of the first one.
This is not a strict “one assertion per test” rule. It is fine to check multiple properties on a single result object. The point is to validate one logical concept per test.
// Bad: mixing two behaviors in one test
test('user creation and update flow', () => {
const user = userService.create({ name: 'test', email: 'test@test.com' });
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
const updatedUser = userService.update(user.id, { name: 'new name' });
expect(updatedUser.name).toBe('new name');
expect(updatedUser.updatedAt).toBeInstanceOf(Date);
});
// Good: two separate tests for two different behaviors
test('creates a user with an id and creation timestamp', () => {
const user = userService.create({ name: 'test', email: '[test@test.com](mailto:test@test.com)' });
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
});
test('updates the user name and sets the update timestamp', () => {
const user = createTestUser(); // Using a test helper
const updatedUser = userService.update(user.id, { name: 'new name' });
expect(updatedUser.name).toBe('new name');
expect(updatedUser.updatedAt).toBeInstanceOf(Date);
});
Avoid hardcoded values with no context
If you see expect(user.balance).toBe(115.75), where did that number come from? Is it 100 + 15% tax + 0.75 shipping? A test full of unexplained values is impossible to maintain. When the logic changes, nobody remembers how that number was calculated.
Use well-named constants or variables in the test setup to make the relationship between input and output clear.
// Bad: magic numbers with no explanation
test('calculates the final price with tax and shipping', () => {
const result = calculator.calculateFinalPrice(100, 'US');
expect(result).toBe(120);
});
// Good: variables explain the calculation
test('calculates the final price with tax and shipping', () => {
const initialPrice = 100;
const taxRate = 0.15;
const shippingCost = 5;
const expectedFinalPrice = initialPrice * (1 + taxRate) + shippingCost; // 120
const result = calculator.calculateFinalPrice(initialPrice, 'US');
expect(result).toBe(expectedFinalPrice);
});
The second example documents the business logic inside the test itself. If the tax calculation changes, the fix is obvious.
Be careful with mocks
Mocks are a common cause of tests that are hard to maintain. The more you use mocks, the more the test gets tied to how the code is organized internally. A test that mocks five different dependencies, in the end, only verifies the sequence of calls, not the result the code delivers.
Ideally, use mocks only at the system’s edges. Mock things that are slow, non-deterministic, or have side effects, such as databases, third-party APIs, file systems, or the clock. For internal collaborators, like a simple formatter or a calculation class, try using the real objects. This turns the test into a small integration, which checks that the classes work together and usually gives you more confidence. It is also worth preferring fakes or stubs over mocks. A mock usually verifies behavior (was this function called?), while a fake is a lightweight implementation of the real component, like an in-memory database. Fakes let your tests focus on state changes instead of interaction details.
Tests are code too: maintenance and trade-offs
Writing good tests involves making trade-offs. There is no single rule that works in every case.
Balancing isolation and realism
A pure unit test that mocks every dependency is fast and isolated, which helps identify failures. The problem is that it does not give much confidence that the system works as a whole. An integration test with a real database is slow and can be unstable, but it proves that the pieces fit together.
The sweet spot is usually somewhere in the middle. A “unit test” for a service can use real value objects and helpers, but a fake repository that stores data in memory. This way, you can validate how a small set of objects works together without the cost of external dependencies.
Coverage is a tool, not a goal
Chasing 100% line coverage is a trap. It encourages developers to write useless tests for simple getters and setters or cover obscure error conditions just to make a number go up. In the end, you have higher maintenance costs with almost no benefit.
Use coverage reports to find what you missed. If a central part of the business logic has no coverage, that is a problem. If an internal data structure with no logic is not covered, it does not matter. Focus on testing decision points and complex logic, not every line of code.
The role of AI assistants in writing tests
AI assistants can help write tests, especially for generating boilerplate or covering pure functions. You paste a function and get back a few tests covering the happy path and some common edge cases.
The problem is that AI does not understand intent. It tends to test the implementation. It will mock every dependency and write assertions that mirror the code’s structure, creating exactly the kind of fragile test you want to avoid. Generated code always needs a careful review. Think of AI as a junior programmer. It can give you a first draft, but it is your job to shape it into a test that validates the public contract and remove any assertion tied to internal details.