Why Developers Who Skip Tests Always Regret It Eventually
by Arif Ikhsanudin, Backend Developer
It Does Not Hurt Until It Does
The first few months without tests feel fine. Features ship fast. Refactors happen in an afternoon. The codebase is small enough that one developer holds most of it in their head, so the absence of a safety net is not noticeable — because the developer is the safety net.
Then the team grows. Or the codebase crosses some threshold of complexity. Or the original developer leaves. And now every change carries a question nobody can confidently answer: did that break anything?
At this point, the regret sets in. Not in a single moment — it accumulates. Slowly, then all at once.
What the Debt Actually Looks Like
Untested codebases do not fail dramatically. They develop a particular kind of friction that compounds over time.
Refactoring stops happening. A developer identifies a function that is doing three things it should not be doing. They know how to fix it. But fixing it means touching five other files, and there are no tests to tell them if those files still work after the change. So they leave it. This happens dozens of times, across dozens of decisions, and the code slowly calcifies into something nobody wants to touch.
Bug fixes introduce new bugs. Without tests, the only way to verify a fix is to run the application manually and check the specific path you changed. Regression testing — checking that everything you did not touch still works — becomes a manual process that nobody has time to do thoroughly. So regressions ship.
Deployments become events. Instead of a routine part of the workday, a deployment becomes something that requires everyone to be available, monitoring dashboards, ready to roll back. The test suite is supposed to be the thing that makes deployments boring. Without it, they never become boring.
Onboarding slows down. A new developer cannot learn how a function is supposed to behave by reading tests, because there are none. They have to read the implementation, read the callers, and ask someone. That someone has to stop what they are doing to explain. The knowledge is locked in people, not in the codebase.
The Specific Inflection Point
This is not a gradual decline. There is usually a specific point where the lack of tests becomes acute.
For most teams, it happens when the codebase reaches somewhere between 20,000 and 50,000 lines, or when the team grows beyond four or five people. Before that point, coverage-by-familiarity works. After it, you need the tests.
By the time most teams realize this, writing tests retroactively is painful. The code was not designed with testability in mind. Dependencies are hard-wired. Side effects are scattered. Functions accept and return complex objects that require significant setup. Writing a single test can take longer than writing the feature did.
// Code written without testability in mind — hard to test retroactively
public class OrderProcessor {
public void processOrder(int orderId) {
// Directly instantiates dependencies — cannot be swapped in tests
Database db = new Database("prod-connection-string");
EmailService email = new EmailService();
PaymentGateway gateway = new PaymentGateway(System.getenv("PAYMENT_KEY"));
Order order = db.findOrder(orderId);
gateway.charge(order.getTotal());
db.markComplete(orderId);
email.sendConfirmation(order.getCustomerEmail());
}
}
// Code written with testability in mind — injectable dependencies
public class OrderProcessor {
private final OrderRepository repository;
private final PaymentGateway gateway;
private final EmailService email;
public OrderProcessor(OrderRepository repository, PaymentGateway gateway, EmailService email) {
this.repository = repository;
this.gateway = gateway;
this.email = email;
}
public void processOrder(int orderId) {
Order order = repository.findOrder(orderId);
gateway.charge(order.getTotal());
repository.markComplete(orderId);
email.sendConfirmation(order.getCustomerEmail());
}
}
The second version is testable from day one. The first version requires refactoring before you can even write a test. And nobody wants to refactor untested code.
The Way Out
If you are already in this situation, the path forward is incremental. You do not write tests for the entire codebase at once. You write tests for the next bug you fix, the next feature you add, the next function you have to touch anyway.
Specifically: whenever you are about to change a piece of code, write a test that characterizes its current behavior first. Not a test for what it should do — a test that documents what it does right now. This is called a characterization test (the technique comes from Michael Feathers' Working Effectively with Legacy Code), and it gives you a regression baseline before you make any changes.
Over time, the coverage grows organically, in the places that matter most. It is slow. But it is the only approach that works without stopping all feature development to write tests nobody asked for.
The developers who regret skipping tests are not the ones who made a bad decision in a vacuum. They were moving fast with good intentions. The regret comes from discovering that "fast now" and "slow later" were never actually in opposition — they were the same choice, just experienced at different times.