Post

Why Are Tests Essential?

Why Are Tests Essential?

Today no one doubts that tests are essential and are first-class citizens of our repositories. I hope so at least. But even though most teams write tests, it is worth stepping back and understanding why they matter so much. Let me walk through the key reasons, with code to back them up.

Related posts: For the business and ROI perspective on testing, see The Economics of Software Testing. For practical advice on writing good tests, see Best Practices for Writing Effective and Reliable Tests.

Tests as a Design Feedback Tool

Tests are the first consumer of your code. If a test is painful to set up, the production code is telling you something. Consider this constructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OrderService {

    public OrderService(InventoryRepository inventoryRepo,
                        PaymentGateway paymentGateway,
                        NotificationService notificationService,
                        PricingEngine pricingEngine,
                        DiscountService discountService,
                        TaxCalculator taxCalculator,
                        AuditLogger auditLogger,
                        FraudDetector fraudDetector,
                        ShippingService shippingService,
                        LoyaltyPointsService loyaltyPointsService) {
        // ...
    }
}

When I write a test for this class and find myself wiring ten dependencies, that is an immediate signal that the class violates the Single Responsibility Principle and should be split. Tests expose these design problems early, before the class grows even further. Good design and testability go hand in hand: if it is hard to test, it is probably hard to maintain.

Living Documentation

Documentation on a wiki or in a README tends to drift out of date. Tests never do, because they fail when behavior changes. A well-named test method communicates a business rule more reliably than any comment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
void shouldRejectOrderWhenInventoryIsInsufficient() {
    // Given
    Product product = new Product("LAPTOP-1", 0);
    inventoryRepo.save(product);

    // When
    OrderResult result = orderService.placeOrder("LAPTOP-1", 1);

    // Then
    assertThat(result.getStatus()).isEqualTo(REJECTED);
    assertThat(result.getReason()).isEqualTo("Insufficient inventory");
}

@Test
void shouldApplyWeekendSurchargeForSaturdayDelivery() { /* ... */ }

@Test
void shouldNotifyCustomerWhenOrderIsShipped() { /* ... */ }

A new developer joining the team can read these test names and understand the business rules without digging through layers of production code. Unlike wiki pages, tests stay current because they break when behavior changes.

Safety Net for Refactoring

I have personally refactored entire modules (renamed classes, restructured packages, replaced algorithms) knowing that a green test suite meant nothing was broken. Without that safety net, even a small rename can feel risky enough to postpone indefinitely. Tests also minimize regression when new team members unfamiliar with certain areas make changes. The suite catches unintentional errors and oversights, keeping the software consistent as the team evolves. With tests in place, teams can confidently practice Continuous Delivery and ship features more frequently.

Catching Edge Cases Early

Some of the nastiest bugs hide in edge cases that seem obvious in hindsight. Consider a utility method that calculates how many pages are needed to display items:

1
2
3
public int calculatePageCount(int totalItems, int itemsPerPage) {
    return totalItems / itemsPerPage;
}

The integer division silently drops the remainder. A test catches it immediately:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void shouldRoundUpWhenItemsDontFillLastPage() {
    // Given
    int totalItems = 7;
    int itemsPerPage = 3;

    // When
    int pages = calculatePageCount(totalItems, itemsPerPage);

    // Then - 7 items at 3 per page should be 3 pages, not 2
    assertThat(pages).isEqualTo(3);
}

This test would fail, forcing a fix to (totalItems + itemsPerPage - 1) / itemsPerPage. This is shift-left testing in action: catching the bug at the developer’s desk instead of in production, where it might require hours of debugging and a hotfix deployment.

img img

Faster Feedback Loop

Without tests, verifying a change often means starting the entire application, navigating to the right screen, and reproducing the scenario manually. With a unit test, I can verify an isolated method in milliseconds. This speeds up the development cycle dramatically and allows me to stay in flow.

Tests also facilitate better communication between developers. Instead of explaining a subtle business requirement or a tricky edge case, I can simply say: “Look at the test for this.”

Working with Legacy Code

When I face an unfamiliar legacy codebase, the first thing I do is write characterization tests: small tests that capture the current behavior of a method, regardless of whether that behavior is correct:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void characterize_applyDiscount_withExpiredCoupon() {
    // Given
    LegacyPricingEngine engine = new LegacyPricingEngine();
    Coupon expiredCoupon = new Coupon("SAVE20", LocalDate.of(2020, 1, 1));

    // When
    BigDecimal price = engine.applyDiscount(new BigDecimal("100.00"), expiredCoupon);

    // Then - capturing current behavior, not necessarily correct behavior
    assertThat(price).isEqualTo(new BigDecimal("80.00")); // applies discount despite expiration!
}

This test documents a likely bug: the system applies discounts even for expired coupons. But the value of the test is that it acts as a safety harness. Once characterization tests are in place, I can refactor or fix bugs knowing that any unintended change in behavior will immediately surface as a test failure. Without them, touching legacy code often feels like defusing a bomb blindfolded.

TDD - Test Driven Development

Test-Driven Development amplifies every benefit listed above. The cycle is simple: write a failing test, write just enough code to make it pass, then refactor.

img img

By writing tests first, I am forced to think about design upfront, which leads to cleaner interfaces. The tests themselves become living documentation of the intended behavior from the very start. Because TDD catches issues the moment they are introduced, it also maximizes the cost savings of shift-left testing. It is a great practice and I highly recommend trying it.

That said, I also believe that once you have internalized the lessons TDD teaches, writing testable code and avoiding design pitfalls becomes second nature. At that point, strictly following the red-green-refactor cycle for every piece of code becomes optional. This might sound controversial, but I think it is pragmatic. TDD is an excellent teacher, but the goal was never the ritual itself. The goal is clean, testable, well-designed code, and experienced developers can often get there without writing the test first every single time.

Conclusion

Tests are not overhead. They are a design tool, a documentation system, a safety net, and a debugging accelerator all at once. The cost of writing them is paid back many times over in confidence, speed, and maintainability. If you want to explore the financial case for testing, see The Economics of Software Testing. For practical advice on making your tests clean and reliable, see Best Practices for Writing Effective and Reliable Tests.

This post is licensed under CC BY 4.0 by the author.