Skip to main content
Jimmy Jansen

A Pragmatic Testing Strategy

Coverage numbers are easy to hit and easy to trust too much. Testing is really how caring about quality becomes concrete, and the craft is not testing everything but knowing what is worth your confidence and what is just theater.

Testing is a sensitive topic, and it gets more sensitive the moment you treat the quality of what you ship as non-negotiable. At that point it stops being a technical preference and becomes a standard you’re holding people to.

I’ve had the testing conversation for years, with developers, product owners, management, and leadership, each coming at it from their own angle. I used to dictate testing strategy. Now I mostly try to guide teams toward what works for their context. I’ve had this conversation many times and gotten plenty of it wrong along the way, so what follows is less a verdict than a set of findings, the patterns I keep coming back to.

Underneath the tooling and the coverage arguments, testing is how caring about quality becomes concrete. Anyone can say they care about what they ship. A test suite is that care, written down and enforced. It’s one of the most overlooked parts of the craft, and I think it’s a big part of what separates a decent engineer from a merely fast one.

So this isn’t another testing-pyramid or TDD-evangelism piece. The craft isn’t testing everything. It’s knowing what’s worth your confidence and what’s just theater. That line runs through everything below.

One caveat before I start: I’ve seen teams succeed with completely different approaches, and consistency across a team matters more than any single method. This is what I’ve found works across dozens of teams. Take what resonates, leave the rest.

Link to section: What testing is actually forWhat testing is actually for

Plenty of teams treat tests as a compliance exercise. “We have 80% coverage!” gets announced with a straight face while the application breaks in production every other deploy. Coverage is the easiest thing to measure and the easiest to game, which is most of why it makes such a poor target.

What I’m actually after is confidence. Can I deploy on a Friday afternoon? Can I refactor a nasty piece of code and trust I didn’t break something three modules away? When someone newer to the codebase opens a PR, will the suite catch the mistake I would have caught by hand? Those are the questions a test suite exists to answer. When mine can’t, I’m usually just writing code to feel productive, and that kind of fake confidence has bitten me more times than I would like to admit.

The honest version is that I test so I can sleep. Not with 100% coverage or a test for every branch, but with coverage on the things that would actually wake me at 3 AM. Most applications have a path that matters more than the rest, the one that sends customers away when it breaks, and that is where the bulk of my attention goes. I have caused my share of outages, a few too embarrassing to write down, and the common thread was always the same: I was certain the work was fine. Tests are how I turn that certainty into something I can actually check.

TL;DR:
Why test
  • The metric is confidence, not coverage
  • Test so you can sleep: focus on what would wake you at 3 AM
  • Most apps have a path that matters most, guard that one first

Link to section: The mistakes I keep seeingThe mistakes I keep seeing

If confidence is the point, most testing failures are really ways of losing it while the dashboard stays green. A handful I run into again and again.

Testing the mocks. That 80%-coverage team had tests, technically. They checked getters and setters. They asserted that mocks returned exactly what they had been told to return. They had snapshot tests that rubber-stamped every screen. Every one of them passed, and not one could tell you whether the product worked. A test that cannot fail in a way you would care about isn’t protecting anything, it is just moving the number. If a test only exercises the mock, delete it and save yourself the maintenance.

“We’ll add tests later.” This is the “I’ll do it after dinner” of software. We know it is a lie as we say it. The untested function becomes an untested module, becomes an untested feature, becomes the part of the codebase nobody will touch without holding their breath. I once helped a team on a codebase north of ten million lines with essentially no tests. It kept taking the whole system down for hours at a stretch, and every small change rippled through undocumented behaviour in ways nobody could predict. The first thing we did was not fix the bug, it was write enough tests to know when we had broken something else. You cannot move fast on a codebase you are afraid of.

The excuses. “We’re moving too fast for tests.” “It’s just an MVP.” “The deadline is too tight.” The catch is that testing rarely competes with shipping. It competes with the other way you spend that time later: debugging production, reproducing bug reports, context-switching back into code you had mentally filed away, explaining to stakeholders why the system is down again. The extra hour up front is almost always cheaper than the day it saves.

Treating QA as beneath you. This one I will defend hard. Every time I was sure my code was bulletproof, a good QA engineer needed about five minutes to prove otherwise. They used the back button. They opened it in two tabs at once. They put an emoji in the name field. In other words, they did what real users do. That is not a lesser kind of testing than mine, it is a different one: I test what I thought I built, they test what I actually built. The teams that treat QA as a rung below development tend to be the ones whose code needs it most.

TL;DR:
Common mistakes
  • Testing your mocks moves the number and protects nothing
  • "Add tests later" is debt that compounds
  • The hour of tests beats the day of production firefighting
  • QA is a different kind of testing, not a lesser one

Link to section: What’s actually worth testingWhat’s actually worth testing

Deciding what to test is most of the skill. Good examples are hard to give because the obvious ones are so dull. The classic calc(a, b) test is trivial to write and even more trivial to get right. It nudges the coverage number and it will never once save you. Trivial tests feel productive and protect nothing.

I aim my effort at the things I don’t expect to get right the first time. I’m confident I can write a “sliding window,” but it is fiddly enough that I will write tests around it, because that is where my own mistakes live. Over time you develop a nose for it. Fairly early in a piece of work you can feel which parts are going to bite, and those are the ones worth the tests.

There is a related signal worth watching. When code is genuinely hard to test, the test is often not the problem, the design is. Can’t inject a dependency? It is probably too tightly coupled. Can’t test without a database? Business logic and infrastructure are likely tangled together. Need to stand up fifteen mocks to exercise one function? That function is doing too much. I don’t treat this as a law, some things really are just awkward to test, but often enough “this is hard to test” is the code trying to tell me something about its design. Writing the test early is how I hear it sooner.

TL;DR:
What to test
  • Skip the trivial, it feels productive and protects nothing
  • Test what you won't get right the first time
  • Hard-to-test code is usually a design signal

Link to section: Every test trades speed for fidelityEvery test trades speed for fidelity

Once you stop chasing coverage, the interesting question is what kind of test to reach for. I find it easier to think about this as a single dial than as a set of rigid tiers. Every test buys you confidence and pays for it in speed and upkeep, and the knob that sets the price is how much of the real system you let into the test.

Turn it all the way down and you mock everything around the code under test: blazing fast, cheap to run, but you are only ever as right as your mocks. Turn it all the way up and you run against real databases, real services, a real browser: slower and more work to maintain, but what passes is close to the truth. The named test types are just points along that dial, and people honestly draw the lines between them in different places.

Note:
What I mean by each label

Everyone uses these words a little differently, so here is what I mean by them in this post:

  • Unit test: one piece of logic in isolation, with no real collaborators.
  • Component (or service) test: several units wired together with their boundaries mocked or stubbed. Fast, and still entirely in-process. This is what a lot of people, me included for years, loosely call an “integration test.”
  • Integration test: a test that crosses a real boundary, a real database, a real queue, a real HTTP call. The moment something outside your process is genuinely involved, you are integration testing.
  • End-to-end test: the whole system exercised the way a user would, through a real interface.

Link to section: The car analogyThe car analogy

I still think about it like testing a car:

Unit is bench-testing one part on its own. Does the brake pedal depress? Does the pump build pressure? Each piece, in isolation, off the car.

Component is checking that your brake module behaves when you feed it a simulated wheel-speed signal, without a real wheel bolted on. Fast, and you can run it constantly, but you are trusting your simulation of the rest of the car.

Integration is the moment you connect the real thing: does pressing the pedal actually stop a real wheel through the real hydraulics? You are not driving yet, but nothing is simulated anymore.

End-to-end is taking the car for a drive. Can you start it, get to the shop, park, and come back? It is the only test that tells you the car works as a car.

I wouldn’t test everything by driving around; that is slow and expensive. But I would never sell a car I had never driven either. You want tests at every point on the dial, weighted toward the fast end.

Link to section: Unit testsUnit tests

A unit test exercises one thing in isolation. One function, one class, one piece of logic. They are fast, focused, and deterministic, and they shine for logic: business rules, calculations, the things that go wrong in subtle ways. I like to keep them running in the background while I work, because that immediate feedback loop keeps me honest about whether I’m heading in the right direction.

If you find yourself mocking a lot to write a unit test, that is usually a sign the unit is doing too much, though not always, sometimes the seams just fall that way. When it happens to me, I treat it as a nudge to look at the design before I reach for another mock.

Link to section: Component testsComponent tests

This is the middle of the dial, and for me the backbone of a suite. Several units wired together, boundaries mocked or stubbed, everything still in-process. I don’t hit real databases or external services here, on purpose, because every real dependency is one more thing that can fail for reasons that have nothing to do with my code. When one of these tests goes red, I want to know it is my logic that broke, not that the test database was down or the network was slow.

So I test at the seams: that my services call each other correctly, that the business logic orchestrates the pieces properly, that the layers fit together the way I expect. I run hundreds of these and they finish in seconds. That speed is the whole point, they run on every change without me waiting, so I catch mistakes while the context is still fresh. This is the tier I lean on hardest.

These are the tests I used to call “integration tests,” and plenty of teams still do. The label matters less than being honest about what they actually verify: my code against my assumptions, not my code against the real world.

Link to section: Integration testsIntegration tests

For that last part, checking my code against the real world, I keep a smaller set of true integration tests: real database, real services, exercised the way the app will actually talk to them. They are slower and more fragile, so I don’t run them on every keystroke. I run them on a schedule, usually nightly, to catch the things component tests structurally cannot: migration and ORM drift, a schema that no longer matches the code, an external contract that changed under me. Fewer of them, higher fidelity, further up the dial.

Link to section: E2E testsE2E tests

End-to-end tests take the user’s point of view. “What happens when I click this?” “Can I log in?” “Do I actually have access to the thing I paid for?” Everything here should be as close to production as you can make it: a real browser, real services, a realistic database.

During development I love these for automating the feature I’m building, but I’m selective about which ones I commit. They are expensive to maintain and slow to run, so only the genuinely important flows earn a place in CI. I’ve watched teams try to test everything through visual E2E and it never ends well: the suite takes forever, it tells you nothing about where something broke, and debugging it feels like solving a murder mystery where all the witnesses are lying. Keep E2E to the critical paths and it stays something you take seriously, when it goes red, you know something that matters is broken.

Link to section: On methodologiesOn methodologies

You’ll hear a lot about shapes. The classic pyramid: many unit tests, fewer in the middle, a handful of E2E. In the wild I’ve seen the ice-cream cone: a few unit tests, a bit in the middle, and a mountain of manual testing balanced on top. The hourglass: plenty of unit and E2E, nothing in between. The testing trophy leans hard on the middle tier, which can work well but tips into slow, coupled suites if you let too much of the real world in.

I don’t follow any of them religiously. I reach for whichever test fits the work: unit tests for logic and algorithms, component tests when I’m wiring systems together, a few real integration tests to keep myself honest about the outside world, and E2E for the paths I can’t afford to break. The only shape that matters is the one that stays fast and reliable enough that you actually run it non-stop.

TL;DR:
The dial
  • Every test trades speed for fidelity; pick the point that fits the work
  • Unit tests for logic, and keep the mocking low
  • Component tests with mocked boundaries are the fast backbone
  • True integration tests cross a real boundary; run fewer, less often
  • E2E only for critical paths
  • Name your tests honestly, and please don't just test your mocks

Link to section: Where I Stand TodayWhere I Stand Today

After all these years, all these arguments, all these successes and failures, here’s where I’ve landed:

Testing is a tool, not a religion. Like any tool, it can be used well or poorly. The goal isn’t to test everything. It’s to test what matters. The metric isn’t coverage, it’s confidence.

I’ve learned to meet teams where they are. No tests? Let’s start with one. Flaky test suite? Let’s fix the worst offenders. Too many tests? Let’s identify which ones actually provide value.

I’ve learned that the best testing strategy is the one that actually gets followed. A pragmatic approach that the team believes in beats a perfect approach that they ignore.

Most importantly, I’ve learned that testing isn’t about being right. It’s about shipping better software, sleeping better at night, and making our future selves hate our present selves a little less.

So I test what scares me. I skip what doesn’t matter. I’m pragmatic, not dogmatic. And I remember: the best test is the one that catches a bug before my users do.

That’s my testing philosophy, shaped mostly by getting it wrong and slowly getting it less wrong. It might not be yours, and that’s fine, as long as your team is aligned on whatever approach you pick. But whatever you do, please stop testing your mocks.