Writing Tests That Don't Suck
Everyone agrees testing is important. But somehow most codebases end up with tests that are slow, flaky, hard to understand, and don't catch real bugs. What gives?
The problem isn't testing itself. It's how we approach it. Let's talk about writing tests that actually help instead of just checking a box.
Test Behavior, Not Implementation
The biggest mistake is testing implementation details instead of behavior. If your tests break every time you refactor, they're testing the wrong things.
Bad test: "When I call processOrder, it should call the validateOrder function, then call calculateTax, then call saveToDatabase."
Good test: "When I submit a valid order, it should be saved with the correct tax calculated."
The bad test will break if you rename a function or change the internal flow, even if the behavior is still correct. The good test only breaks if the actual behavior changes, which is what you want.
The Testing Pyramid
You've probably seen the testing pyramid: lots of unit tests at the bottom, fewer integration tests in the middle, and a handful of end-to-end tests at the top. This ratio exists for good reasons.
Unit tests are fast and isolated. They test individual functions or components. You can run thousands of them in seconds. Most of your tests should be here.
Integration tests verify that pieces work together. They're slower but catch issues unit tests miss, like database queries that don't return what you expect.
End-to-end tests simulate real user flows through the whole system. They're slow and sometimes flaky, so use them sparingly for critical paths.
The mistake is inverting this pyramid. Too many E2E tests make your suite slow and brittle. Too few unit tests mean you miss bugs early.
One Assertion Per Test? Not Really
You'll hear "one assertion per test" as a rule. It's more nuanced than that. A better rule: one concept per test.
If you're testing that a function returns the right user object, checking that the name, email, and ID are all correct is fine in one test. They're all verifying the same behavior.
But testing user creation AND user deletion in the same test is confusing. If it fails, which part broke? Split those into separate tests.
Good Test Names Are Documentation
Test names should describe the behavior being tested, not the implementation. When a test fails, the name should tell you what's broken without reading the code.
Bad: testProcessOrder
Better: processOrder_validOrder_savesOrderWithTax
Best: should save order with calculated tax when order is valid
Some people use sentence-style names. Others use the given_when_then format. Pick a convention and stick with it. Consistency matters more than the specific style.
Arrange, Act, Assert
Structure every test the same way:
Arrange: Set up the test data and preconditions.
Act: Execute the thing you're testing.
Assert: Verify the results.
This pattern makes tests easy to read. You always know where to look for setup, execution, and verification. When tests get confusing, they usually have these sections jumbled together.
Test Data Should Be Obvious
Don't make readers hunt for where test data comes from. Avoid setup methods that configure a bunch of shared state. It's hard to understand what a test is doing when half the context is defined somewhere else.
Use factory functions or builders to create test data right in the test. Make it obvious what values matter for this specific test.
If you need a user with a specific email for this test, create that user inline. Don't rely on a testUser defined in some setup block that might change.
Dealing with Flaky Tests
Flaky tests are tests that sometimes pass and sometimes fail without code changes. They destroy trust in your test suite. If people can't trust the tests, they start ignoring failures.
Common causes of flakiness:
- Tests depending on execution order
- Race conditions with async code
- Tests sharing state that should be isolated
- Hardcoded dates or times
- Network calls to external services
When you find a flaky test, fix it immediately or delete it. A flaky test is worse than no test because it wastes everyone's time and erodes confidence.
Mock Wisely
Mocking lets you isolate units by replacing dependencies with fakes. But over-mocking creates tests that don't reflect reality.
Mock things that are slow (network calls, databases) or non-deterministic (current time, random numbers). Don't mock the thing you're actually testing or its core logic.
If you're mocking so much that your test is just verifying mock calls, you're not testing anything useful. The test will pass even if the real implementation is broken.
Run Tests Frequently
Tests only help if you run them. If your test suite takes 30 minutes, nobody runs it locally. They push and hope CI passes.
Keep your unit tests fast enough to run on every save. Use watch mode. The tighter the feedback loop, the faster you catch mistakes.
For slow integration tests, run them less frequently but always before merging. CI should be your safety net, not your only testing environment.
Delete Bad Tests
A test that doesn't provide value is actively harmful. It takes time to maintain, slows down the suite, and might give false confidence.
If a test is flaky, fixes it or delete it. If a test never catches bugs and just breaks during refactors, maybe it's not testing anything useful. If a test is so complex nobody understands it, simplify it or remove it.
Good tests should be an asset, not a burden. Treat your test suite with the same care you give production code.