Stop Fighting Tests. Fix the Code Instead.
Many developers think that writing unit tests is tedious because of how tricky mocks, frameworks, or test setup can be.
Many developers think that writing unit tests is tedious because of how tricky mocks, frameworks, or test setup can be.
But often, the real reason is simpler — and more brutal:
Writing tests feels hard because the code you are trying to test is not well-structured.
When code is full of scattered special cases, magic numbers, and primitive soup, you have no choice but to write equally messy, repetitive tests.
Conversely, well-factored code makes testing naturally simple — because it treats many inputs the same way, and one test can stand in for a whole slice of behavior.
In this post, we’ll walk through a realistic example to see exactly why messy code demands messy tests — and how better structure transforms the testing experience.
The Original Code
Here’s a real world example of a messy, special-cased version of the logic for assigning loyalty tiers based on customer spending:
public String determineLoyaltyTier(double totalSpend) {
if (totalSpend >= 100 && totalSpend < 500) {
return "Bronze";
} else if (totalSpend >= 500 && totalSpend < 1000) {
return "Silver";
} else if (totalSpend == 1200) {
return "Special Silver Plus";
} else if (totalSpend >= 1000 && totalSpend < 2000) {
return "Gold";
} else if (totalSpend == 2500) {
return "Special Gold Elite";
} else {
return "Platinum";
}
}Notice:
- Every case is manually coded.
- “Special” cases like 1200 and 2500 interrupt the normal flow.
- Raw double and String primitives are flying everywhere.
Testing this?
You can’t just test “one bronze customer,” “one silver customer,” and so on.
You must explicitly test:
- 1200 → Special Silver Plus
- 2500 → Special Gold Elite
- Normal thresholds (100, 500, 1000, etc.)
- Boundaries around each range
Each special case and range must be manually verified. Missing one = bugs leaking into production.
A Better Version
Now let’s see how proper design — using meaningful types and range rules — changes the game.
First, introduce real domain types:
public record TotalSpend(double amount) {
public TotalSpend {
if (amount < 0) {
throw new IllegalArgumentException("Total spend must be non-negative");
}
}
}public enum LoyaltyTier {
BRONZE,
SILVER,
GOLD,
PLATINUM
}Now cleanly express rules as ranges:
public record SpendRange(double minInclusive, double maxExclusive) {
public boolean contains(TotalSpend spend) {
return spend.amount() >= minInclusive && spend.amount() < maxExclusive;
}
}import java.util.List;
public class LoyaltyService {
private static final List<TierRule> rules = List.of(
new TierRule(new SpendRange(0, 500), LoyaltyTier.BRONZE),
new TierRule(new SpendRange(500, 1000), LoyaltyTier.SILVER),
new TierRule(new SpendRange(1000, 2000), LoyaltyTier.GOLD)
);
public LoyaltyTier determineLoyaltyTier(TotalSpend totalSpend) {
for (TierRule rule : rules) {
if (rule.range().contains(totalSpend)) {
return rule.tier();
}
}
return LoyaltyTier.PLATINUM;
}
private static record TierRule(SpendRange range, LoyaltyTier tier) {}
}🎯 Why This Structure is Better
- Domain types (TotalSpend, LoyaltyTier, SpendRange) remove primitive clutter.
- Business rules are expressed as data, not hardcoded into control flow.
- Adding tiers or adjusting spend thresholds becomes a simple data change.
- Testing becomes simple and systematic.
How Tests Change
@Test
void testBronzeTier() {
LoyaltyService service = new LoyaltyService();
assertEquals(LoyaltyTier.BRONZE, service.determineLoyaltyTier(new TotalSpend(150)));
}
@Test
void testSilverTier() {
LoyaltyService service = new LoyaltyService();
assertEquals(LoyaltyTier.SILVER, service.determineLoyaltyTier(new TotalSpend(700)));
}
@Test
void testGoldTier() {
LoyaltyService service = new LoyaltyService();
assertEquals(LoyaltyTier.GOLD, service.determineLoyaltyTier(new TotalSpend(1500)));
}
@Test
void testPlatinumTier() {
LoyaltyService service = new LoyaltyService();
assertEquals(LoyaltyTier.PLATINUM, service.determineLoyaltyTier(new TotalSpend(2500)));
}✅ Clear, simple tests.
✅ Each range is covered with just one test.
✅ No explosion of special cases to babysit.
Why Testing Pain is a Design Problem
Testing isn’t painful because testing is hard.
It’s painful because bad code and design makes it hard.

Key Takeaways
- Good design shrinks the number of tests you need.
- Domain modeling matters: TotalSpend, SpendRange, LoyaltyTier made our code precise and extensible.
- Generalizing behavior early saves you mountains of pain later — both in code and in tests.
- When writing tests feels clumsy, it’s usually a sign that the code needs a rethink, not just more test code.
Conclusion
Pain in testing is often just pain in design, wearing a different mask.
If you find yourself struggling to test a piece of code:
- Pause.
- Ask yourself if the code is modeling the real-world concepts cleanly.
- Refactor before trying to brute-force more tests.
Great code and great tests go hand-in-hand — because both are symptoms of great design.