Writing Tests That Actually Catch Bugs
Most test suites give false confidence. A practical guide to writing tests that fail for real reasons: testing behavior over implementation, using realistic data, and building a meaningful suite.
A test suite that always passes is worse than no test suite: it gives your team confidence and then betrays it. The uncomfortable truth is that most test suites are structurally incapable of catching the bugs their authors fear most. This article is about the handful of practices that separate tests that protect you from tests that merely exist.
Test behavior, not implementation
The single most common mistake: tests that assert how code works instead of what it does. When a test checks internal calls, argument sequences, or implementation details, it breaks on every refactor — even correct ones — and it teaches the team that tests are a drag.
Compare these two tests of a discount calculator:
// Fragile: asserts implementation details
it("calls the discount service with the right args", () => {
const discounts = vi.fn();
const calc = new PriceCalculator(discounts);
calc.price(100);
expect(discounts).toHaveBeenCalledWith("standard", 100);
});
// Robust: asserts observable behavior
it("applies the standard discount", () => {
const calc = new PriceCalculator(realDiscountRules);
expect(calc.price(100)).toBe(80);
});
The second test survives a rewrite of PriceCalculator’s internals. The first one pins your team to the current implementation forever. If a test is coupled to structure, it’s testing structure.
Use realistic data
Tests that use fake data ("foo", 123, empty objects) pass even when real-world data would crash the code. The bug stories we tell — “it worked in tests!” — are almost always stories about unrealistic test data.
Use fixtures that mirror production shape and volume:
const user = {
id: "usr_01J2XYZ",
email: "ada.lovelace@example.com",
roles: ["admin", "billing"],
metadata: { plan: "enterprise", seats: 250, lastLogin: "2026-07-30T09:12:00Z" },
deletedAt: null,
};
it("returns the billing contact for an active admin", () => {
expect(billingContact(user)).toBe("ada.lovelace@example.com");
});
Empty strings, null, and missing fields deserve their own tests — deliberately. A test that passes null for every optional field, or a truncated string, is how you find out your parsing code assumed too much.
Test the failure modes
Happy-path tests are easy and nearly worthless alone. The tests that pay for themselves are the ones that simulate what actually goes wrong in production:
- Network timeouts and retries — assert the retry happens, then the error surfaces
- Partial failures — 2 of 3 batch writes fail; what does the user see?
- Race conditions — two requests mutate the same resource
- Concurrent access — two sessions claim the same seat
it("does not double-book a seat under concurrency", async () => {
const room = await createRoom(1);
const results = await Promise.all([
bookSeat(room.id, "alice"),
bookSeat(room.id, "bob"),
]);
expect(results.filter(Boolean)).toHaveLength(1); // exactly one winner
});
Structure your suite by risk
A test suite has a shape, and the shape should match your risk. Use the testing pyramid as a starting point, then adjust:
- Unit tests — many, fast, testing logic in isolation
- Integration tests — fewer, testing your code with real databases, files, and HTTP
- End-to-end tests — fewest, testing whole journeys through the UI
The failure of the modern era is the inverted pyramid: lots of slow, brittle end-to-end tests and no unit coverage. If a test takes 30 seconds and flakily fails on the CI machine, nobody runs it, and everybody ignores its failures.
Test names should be sentences
A test name is documentation. “should return false when the user is banned” tells the next engineer what contract you were protecting. “returns false” tells them nothing. When a test fails, its name is the first clue about what broke.
✓ returns false when the user is banned
✗ returns false
✓ applies a 20% discount to enterprise plans over $10k
✗ computes discount
Refactor toward meaningful coverage
Here’s the practical workflow:
- Find the test that would have caught your last production bug — if it wouldn’t exist, write it first
- Delete tests that pass for the wrong reasons — ones asserting mocks or implementation
- Add a mutation test occasionally — introduce a deliberate bug; if no test fails, your suite has a hole
Conclusion
The goal of testing isn’t a green badge. It’s a suite that catches bugs when they’re cheap — on the developer’s machine, minutes after they’re written. Write tests that assert behavior, feed them realistic data, exercise failure modes, and shape the suite around risk. When your tests fail for real reasons, you can trust the green ones. That’s the whole game.
Written by
Benmalek Zohir
Founder, AI Engineer & Full Stack Developer
Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.