A Supposedly Safe Thing I’ll Never Do Again
I have a confession that I’m not sure precisely how to begin, a biggish confession about something considered an accepted best practice in software development. It’s the sort of practice people talk about in hushed, confident tones in the company cafeteria1, while nibbling on free snacks meant to keep the workforce docile and happy. I am referring, of course, to the “religion of the unit test.”
You, my gentle hypothetical reader, may now be wondering: “What? The holy writ says always write your tests—faster, better, smaller, more granular. Are you messing with me?” That’s exactly the reaction I’m prepared for. It was my reaction, too, when I first started getting real about how I actually develop software. And to be absolutely clear, I don’t propose eliminating all tests, nor am I endorsing a devil-may-care stance2. I’m merely saying: in a world with typed languages that can do an impressive chunk of error-checking for you, the mania for micro-level unit tests sometimes feels akin to building elaborate belt-and-suspenders systems for pants that are in no real danger of falling down.
Now, let me disclaim: I don’t come by this viewpoint lightly. In the earlier part of my dev career, I toiled in the labyrinth of TDD (Test-Driven Development) euphoria—writing test after test, diligently covering all my “units,” often with a fervor that rivaled or maybe even outstripped the actual application code. If you read enough blog posts proclaiming that your test coverage is directly correlated to your moral standing as a developer, you start to believe it. So I believed. We all believed. And it’s not wrong so much as incomplete, or let’s say sometimes misguided.
For instance, in a type-strong language3, your code can’t compile if you pass the wrong structure, shape, or type where it’s not expected. A decent chunk—like the biggest chunk, some might argue—of potential runtime errors never even get to the “runtime” part, because the compiler won’t stand for it. This is a wonderful feature, a gift, a prophylactic measure so robust it can stand up to a significant share of the stupidity (mine included) that creeps into code as we human beings attempt to form syntactically valid incantations that accomplish tasks in the real world. So you already have a safety net. Of course, that’s not to say type systems catch everything. But they do alleviate a wide swath of potential errors.
But oh, the unit test. The single function, singled out from the comfortable womb of the codebase, placed under a microscope, and forced to answer questions about its internal intentions. The sense of control this provides can be heady. “Look, I can poke and prod and see what your method does with a negative number! Or an empty string! Doesn’t that prove something?” Sure, maybe, but sometimes the proof feels far too local—like verifying that each individual grain of sand in a sandbox is indeed sand, while ignoring the giant raccoon rummaging around behind you4.
And I’ll just say it: the overhead can get ridiculous. Tests break if you rename a single parameter. You must keep them in sync with method signatures, which can be as ephemeral as the morning dew. And we never speak of the shameful experience of refactoring a function and spending untold hours rewriting or updating the test just so it can pass again. Meanwhile, bigger flaws in architecture (e.g., monolithic design, ill-conceived concurrency strategies, or you know, that raccoon lurking around the edges) may go unnoticed because we’re too busy micro-focusing.
This is why, in a typed language, so many trivially testable mistakes are already weeded out at compile time. The code can’t proceed if the type system sees something obviously amiss. Meaning the unit test might catch an even more esoteric scenario—but is that scenario likely enough to occur that it warrants building an entire pipeline of micro-tests? Or is it, more often than not, the sort of bug that proper integration testing or real-life usage would suss out in a fraction of the time?
Let me be uncomfortably direct: am I saying “Don’t test your code at all”? Absolutely not. That’d be borderline professional malpractice, something you might do only if your line of code is hobby-level or if you have a deep yearning for sabotage. Instead, I’m simply nudging you, dear reader, to see that a lot of unit tests can be replaced—or at least augmented—by the rigor of a good type system. If your code is strongly typed, and you combine that with robust integration tests focusing on real-world usage, you often get more coverage (both conceptual and practical) with less overhead.
And overhead is, let’s be honest, one of the prime banes of the software developer’s existence—especially in the era of “move fast and break things”5. Except we’re not exactly comfortable with the “break things” part, so we’ve replaced it with an ocean of tests meant to catch every subtle error. But sometimes that ocean can drown you just as effectively. There’s a less frantic middle ground, a place where type-checking and thoughtful integration tests provide coverage that’s both wide and deep enough.
Hence the epiphany: I realized, after a particularly harrowing sprint6, that I was the one writing reams of code to fix the test suites, instead of actually shipping anything valuable. So the next time someone says, “But do we have 100% coverage on these tiny helper functions we’ll likely retire in two sprints?” I’ll do my best to gently wave them off, or at least steer them to the shoreline where a robust type system does the heavy lifting. Because endless micro-level unit tests may seem safe, but they also carry a subtle tyranny that can constrict our creativity and slow real progress. If that sounds like exaggeration, well, maybe it is. But in the grand scheme, trusting the compiler and focusing our testing energies on more integrative or end-to-end concerns is the best method for me—and a supposedly safe thing I’ll never do again.
Footnotes
- In nicer workplaces, these snacks include more than just stale cookies from the discount aisle. Though, in all fairness, I’ve devoured my share of stale cookies, especially during deadlines.
- What might be called the “YOLO” approach to software: piling code on code with zero regard for stable builds, maintainability, or collective sanity.
- See: Rust, Haskell, Scala, or even TypeScript (to some extent). Each with its own quirks, but all committed to strong typing.
- Or worse: an entire den of raccoons living under your source control.
- The ironically chanted motto that, in some contexts, implies “Move carefully, and break nothing, but be sure we appear to move fast.”
- The metaphorical kind, though sometimes it feels literal as we run circles around to-do lists.
© Alexander Cannon – All disclaimers, disclaimers. Opinions are subject to change if, ironically, I break everything.
← Read more articles
Comments
No comments yet.