Skip to main content
Software Maintenance
Software Maintenance
IS IA
2h

Testing and Test-Driven Development

Testing is the primary safety net for software under change. In maintenance contexts — where you modify existing code without always understanding it fully — automated tests are what allow you to move with confidence.

Motivation: Errors, Defects, and Failures

Error: An inappropriate or erroneous decision made by a developer that introduces a defect. Errors are human mistakes — misunderstanding requirements, logic mistakes, off-by-one bugs. Errors live in the developer’s mind; they cannot be directly detected by any tool.

Defect: An imperfection in the system that may contribute to one or more failures. A defect is the artifact left in the code by an error. Note that sometimes several defects must combine to trigger a failure — a single defect may be dormant for years.

Failure: An unacceptable behaviour observed during execution. The frequency of failures reflects system reliability. A failure is what users and operators actually experience.

Understanding the chain Error → Defect → Failure is key: testing cannot find errors (human intent), but it can detect defects by triggering failures.

What is Testing?

The IEEE-STD 729 standard (1983) defines testing as follows:

Testing is a manual or automated process that aims to check that a system satisfies properties requested by its specifications, or to detect differences between results produced by the system and those expected by the specifications. — IEEE-STD 729, 1983

Testing is fundamentally about two complementary activities: verification — does the system do what it should? — and detection — does it behave differently from what was expected? These two goals drive every testing strategy, from the simplest unit test to the most elaborate acceptance campaign.

What Are We Testing?

Properties a system may need to satisfy:

  • Functionality: does it do what it is supposed to do?
  • Security and integrity: is data safe from corruption or unauthorized access?
  • Usability: can users interact with it effectively?
  • Robustness: does it handle unexpected inputs gracefully?
  • Maintainability: is the code structured well enough to be modified safely?
  • Efficiency: does it use resources (CPU, memory, network) appropriately?
  • Coherence: are internal data states always consistent?

Static vs. Dynamic Testing

There are two broad approaches to testing, and a rigorous process uses both.

Static testing examines the code without executing it:

  • Code reviews and inspections
  • Automated rule checkers (style, security patterns)
  • Formal analysis tools
  • Advantage: catches issues before the program runs, at no execution cost

Dynamic testing runs the program with specific inputs and observes outputs:

  • Requires executable code
  • Can detect runtime failures that static analysis misses
  • The focus of most automated test frameworks (JUnit, pytest, etc.)

Neither approach is sufficient alone. Static testing cannot observe runtime behaviour; dynamic testing cannot guarantee coverage of all possible paths.

Black Box vs. White Box Testing

Black box (functional) testing is based on the specification, not the implementation. The tester knows what the system should do, but not how it does it. Inputs and expected outputs are derived from requirements documents, user stories, or contracts. This approach is well-suited to acceptance tests and system-level tests, and can be conducted without access to the source code.

White box (structural) testing is based on the internal structure of the program. The tester has access to the source code and designs tests to exercise specific code paths, branches, and conditions. The goal is to ensure that every meaningful path through the logic is executed at least once. White box testing is the basis of code coverage analysis and is used heavily in unit testing.

Test Hierarchy

Different test levels validate different stages of development. In practice, test execution flows bottom-up: you validate units first, then their integration, then the full system.

LevelWhat is ValidatedCorresponds to
Unit testsIndividual classes and methodsDetailed design
Integration testsInteractions between modulesGlobal design
System testsThe full system as a wholeTechnical specifications
Acceptance testsUser requirements and use casesRequirement definition

Types of Testing

Unit Testing validates individual methods or classes in isolation. Unit tests are white-box, typically written by the same developer who wrote the code. They are the most granular level and the fastest to run, making them the foundation of any continuous integration pipeline.

Integration Testing validates the interactions between modules. Finding the right testing order matters — if dependencies form a tree, test from leaves up to the root. Cycles in dependencies require stubs or mocks to break them artificially. Integration defects are often invisible to unit tests because they live in the interfaces, not the internals.

System Testing validates the full system end-to-end, including GUI, performance, and security. It is typically black-box — testers exercise the system as a user would, without knowledge of internal structure. (“End-to-end tests,” a term you’ll also see elsewhere, refers to this same idea — exercising the whole system through its real interfaces — and is often used loosely to cover both System and Acceptance testing.)

The next two categories are different in kind from the four levels above: rather than being another rung on the Unit → Integration → System → Acceptance ladder, they’re cross-cutting concerns that apply at any of those levels.

Non-Regression Testing ensures that after any change — a bug fix, a refactoring, a new feature — previously working behaviour still works. This is especially critical in maintenance: every change risks breaking something else. A comprehensive non-regression suite is the single most important tool for safe maintenance.

Stress / Load / Performance Testing answers the question: how many users, transactions, or events can the system handle before degrading? These tests expose scalability limits and are essential before major deployments or architectural changes.

Acceptance Testing validates that the system does what the customer actually wants. It is conducted by end-users, not developers, and it is the final gate before release.

Test-Driven Development

The TDD Cycle

TDD is built around one short loop, repeated continuously:

  1. Red — Write a failing test. The test must fail because the feature does not exist yet.
  2. Green — Write the minimum code necessary to make the test pass. No more.
  3. Refactor — Clean up both production code and test code without changing behaviour.
TDD is about design, not testing

“TDD is the craft of producing automated tests for production code, and using that process to drive design and programming. For every tiny bit of functionality in the production code, you first develop a test that specifies and validates what the code will do.”

Automated tests are a valuable side-effect — not the primary goal.

Advantages of TDD

  • Writing the test first means the program is used (called) before it exists — this forces good API design
  • Keeps design decisions small and reversible — you only build what the test demands
  • Builds a non-regression suite automatically as a by-product
  • Increases confidence when refactoring: if tests still pass, behaviour is preserved
  • Provides a measurable velocity indicator: passing tests = done features

What TDD Is NOT

Common misconceptions:

  • Not “write all tests first, then build the system” — tests and code must alternate, one increment at a time
  • Not “do automated testing” — automated tests can exist without TDD; TDD is a design discipline
  • Not a process (like Scrum or Waterfall) — it is a practice (like pair programming or code reviews)
  • Not about writing lots of tests — it is about writing the right test at the right time

TDD Example: PasswordValidator (Java + JUnit 5)

Requirements: Passwords must be 6–10 characters long, contain at least one digit, and contain at least one uppercase letter.

Step 1 — Write the Failing Test (Red)

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class PasswordValidatorTest {

    @Test
    void validPasswordShouldPass() {
        assertTrue(PasswordValidator.isValid("Abc123"));
    }
}

This does not compile yet — PasswordValidator does not exist. That is intentional: the test defines the interface before the implementation.

Step 2 — Write Minimal Code (Green)

public class PasswordValidator {
    public static boolean isValid(String password) {
        return true; // stub — just enough to compile and make the test pass
    }
}

The test now passes. But one passing test is not a specification.

Step 3 — Add More Tests, Trigger Red Again

Add these tests inside the existing PasswordValidatorTest class:

@Test
void tooShortPasswordShouldFail() {
    assertFalse(PasswordValidator.isValid("Ab1"));
}

@Test
void tooLongPasswordShouldFail() {
    assertFalse(PasswordValidator.isValid("Abc123456789"));
}

@Test
void noDigitShouldFail() {
    assertFalse(PasswordValidator.isValid("Abcdef"));
}

@Test
void noUppercaseShouldFail() {
    assertFalse(PasswordValidator.isValid("abc123"));
}

The stub return true now fails all four of these. Back to Red.

Step 4 — Implement Properly (Green)

import java.util.regex.Pattern;

public class PasswordValidator {

    private static final int MIN_LENGTH = 6;
    private static final int MAX_LENGTH = 10;

    private static boolean isValidLength(String password) {
        return password.length() >= MIN_LENGTH && password.length() <= MAX_LENGTH;
    }

    private static final Pattern DIGIT_PATTERN    = Pattern.compile(".*\\p{Digit}.*");
    private static final Pattern UPPERCASE_PATTERN = Pattern.compile(".*\\p{Upper}.*");

    private static boolean containsDigit(String password) {
        return DIGIT_PATTERN.matcher(password).matches();
    }

    private static boolean containsUppercase(String password) {
        return UPPERCASE_PATTERN.matcher(password).matches();
    }

    public static boolean isValid(String password) {
        return isValidLength(password)
            && containsDigit(password)
            && containsUppercase(password);
    }
}

All tests pass. Note the naming: each private method expresses a single rule, making isValid read like a specification.

Step 5 — Refactor

The magic numbers 6 and 10 are now named constants. The method chain in isValid is readable. Could we simplify further? Yes — but YAGNI (“You Aren’t Gonna Need It” — covered in more depth in the next chapter) applies: refactor only what improves clarity, not speculatively.

Step 6 — Integration Tests

Unit tests validate PasswordValidator in isolation. But in a real application, password validation is used by a UserRegistrationService — and that service interacts with a user repository, sends confirmation emails, and checks for duplicate accounts. An integration test validates those component interactions together:

The following example assumes a UserRegistrationService and a RegistrationResult value object. A real service would be backed by an actual database; to keep this example runnable, here’s a minimal in-memory stand-in — swap it for a real database-backed implementation later without changing a single test, which is itself a nice demonstration of testing against an interface:

enum RegistrationStatus { SUCCESS, INVALID_PASSWORD, EMAIL_ALREADY_EXISTS }

record RegistrationResult(RegistrationStatus status, String message) {}

class UserRegistrationService {
    private final java.util.Map<String, String> usersByEmail = new java.util.HashMap<>();

    RegistrationResult register(String email, String password) {
        if (!PasswordValidator.isValid(password)) {
            return new RegistrationResult(RegistrationStatus.INVALID_PASSWORD,
                "Password does not meet requirements");
        }
        if (usersByEmail.containsKey(email)) {
            return new RegistrationResult(RegistrationStatus.EMAIL_ALREADY_EXISTS, null);
        }
        usersByEmail.put(email, password);
        return new RegistrationResult(RegistrationStatus.SUCCESS, null);
    }
}
class UserRegistrationServiceTest {

    private final UserRegistrationService service = new UserRegistrationService();

    @Test
    void registeringWithValidPasswordSucceeds() {
        RegistrationResult result = service.register("alice@example.com", "Secure1x");
        assertEquals(RegistrationStatus.SUCCESS, result.status());
    }

    @Test
    void registeringWithWeakPasswordFails() {
        RegistrationResult result = service.register("alice@example.com", "weak");
        assertEquals(RegistrationStatus.INVALID_PASSWORD, result.status());
        assertEquals("Password does not meet requirements", result.message());
    }

    @Test
    void registeringWithDuplicateEmailFails() {
        service.register("alice@example.com", "Secure1x");
        RegistrationResult result = service.register("alice@example.com", "Secure2y");
        assertEquals(RegistrationStatus.EMAIL_ALREADY_EXISTS, result.status());
    }
}

Key distinctions from unit tests:

  • Integration tests exercise real component interactions — not stubs
  • They may involve a database, file system, or HTTP layer
  • They are slower and more brittle than unit tests, but catch integration bugs that unit tests cannot
  • In TDD, they are written at a higher level and serve as the acceptance criteria for a feature
The Test Pyramid

A healthy test suite follows the pyramid model: many unit tests (fast, cheap, isolated), fewer integration tests, and even fewer end-to-end tests. Inverting this — many slow integration tests, few unit tests — leads to slow feedback and fragile test suites.

A Ladder of Unit Tests

The PasswordValidator walkthrough showed the TDD loop end to end. This section is a companion catalogue: one small class, tested five ways, each rung introducing exactly one new JUnit technique. Working through progressively harder cases is the fastest way to build unit-testing intuition, and the same ladder applies to almost any class you will meet in the practical.

The class under test is a minimal bank account:

class BankAccount {
    private int balance = 0;

    void deposit(int amount) {
        balance += amount;
    }

    void withdraw(int amount) {
        if (amount > balance) {
            throw new IllegalArgumentException("Insufficient funds");
        }
        balance -= amount;
    }

    int balance() {
        return balance;
    }
}

Level 1 — A single assertion

Every unit test has the same three-part shape, often called Arrange–Act–Assert: build the objects, perform the one action under test, then check exactly one outcome. Start with the happy path.

@Test
void depositIncreasesBalance() {
    BankAccount account = new BankAccount();   // Arrange
    account.deposit(50);                        // Act
    assertEquals(50, account.balance());        // Assert
}

A good test name reads like a sentence describing the behaviour, not the method — depositIncreasesBalance, not testDeposit.

Level 2 — Edge cases and boundaries

Defects cluster at boundaries. For any range, test the exact edge, one step inside, and one step outside — not just a comfortable value in the middle. Withdrawing exactly the balance is where an off-by-one in the comparison would surface.

@Test
void withdrawLessThanBalance() {
    BankAccount account = new BankAccount();
    account.deposit(100);
    account.withdraw(30);
    assertEquals(70, account.balance());
}

@Test
void withdrawExactBalanceLeavesZero() {   // the boundary
    BankAccount account = new BankAccount();
    account.deposit(100);
    account.withdraw(100);
    assertEquals(0, account.balance());
}

Level 3 — Exceptions

Sometimes the correct behaviour is to refuse. assertThrows runs a lambda, catches the exception it throws, and returns it so you can assert on its type and message. The test fails if no exception is thrown, or if the wrong type is thrown.

@Test
void overdraftIsRejected() {
    BankAccount account = new BankAccount();
    account.deposit(50);

    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> account.withdraw(100));

    assertEquals("Insufficient funds", ex.getMessage());
}

Testing the unhappy path is as important as testing the happy one — a surprising amount of production behaviour lives in how a system rejects bad input.

Level 4 — Shared setup and state

When several tests need the same starting object, a @BeforeEach method runs before each one and hands over a fresh instance. This removes duplication and — more importantly — guarantees every test starts from a known, clean state, so tests never leak state into one another and can run in any order.

class BankAccountTest {

    private BankAccount account;

    @BeforeEach
    void freshAccount() {
        account = new BankAccount();
    }

    @Test
    void balanceTracksASequenceOfOperations() {
        account.deposit(100);
        account.withdraw(30);
        account.deposit(10);
        assertEquals(80, account.balance());
    }
}

Level 5 — Data-driven tests

When many cases share the same shape, don’t copy-paste the test body. A @ParameterizedTest runs once per row of data, and @CsvSource is the simplest source: each comma-separated column becomes a method parameter. Adding a case becomes adding a line, and the table itself documents the behaviour.

@ParameterizedTest
@CsvSource({
    "100, 30,  70",    // deposited, withdrawn, expected
    "100, 100, 0",
    "150, 50,  100"
})
void balanceAfterDepositThenWithdraw(int deposited, int withdrawn, int expected) {
    BankAccount account = new BankAccount();
    account.deposit(deposited);
    account.withdraw(withdrawn);
    assertEquals(expected, account.balance());
}

@ParameterizedTest needs the org.junit.jupiter.params.ParameterizedTest import and a source annotation such as @CsvSource (org.junit.jupiter.params.provider.CsvSource).

One class, five techniques

The BankAccount never changed — only the tests around it grew more thorough: a single assertion, boundary values, exception behaviour, shared stateful setup, and finally a data-driven table. This is the ladder to climb for any class you need to cover: start at the happy path and add rungs until the important behaviour is pinned down.

Testing Legacy Code

Everything above assumed a blank slate: you write a failing test, then write code to satisfy it. That’s TDD’s natural habitat — new code, written by you, right now. Maintenance work rarely offers that luxury. You inherit a class with no tests at all, written by someone who left the company three years ago, and you need to change it without breaking whatever it currently does — even the parts you don’t fully understand yet.

This is a different problem from TDD, and it needs a different first move.

Characterization Tests

A characterization test doesn’t test what the code is supposed to do — it tests what the code actually does, right now, bugs and all. The goal isn’t correctness; it’s a safety net. You write a test, run it against the existing behavior, and use whatever it currently returns as the expected value — even if that value looks wrong. Once that net exists, you can refactor or fix the code with confidence, because any unintended change in behavior will now fail a test.

// You've inherited this method. No tests exist. You don't fully trust it.
// (Maybe it looks buggy — negative discounts shouldn't happen — but you don't
// know yet whether some caller depends on that "bug".)
class PricingEngine {
    double finalPrice(double basePrice, int loyaltyYears) {
        double discount = loyaltyYears * 0.02;
        return basePrice * (1 - discount); // no cap — 60 loyalty years = negative price!
    }
}
class PricingEngineCharacterizationTest {
    private final PricingEngine engine = new PricingEngine();

    @Test
    void currentBehaviorForTypicalCustomer() {
        // Ran the method, observed 90.0, wrote it down as the expected value —
        // NOT because 90.0 is "correct" by some spec, but because it's what
        // happens today, and today's behavior is what must not silently change.
        assertEquals(90.0, engine.finalPrice(100.0, 5));
    }

    @Test
    void currentBehaviorForLongTenureCustomer_evenThoughThisLooksWrong() {
        // Deliberately pins down the suspicious case too. If this is a real bug,
        // fixing it becomes a conscious, tested decision later — not an
        // accidental side effect of an unrelated refactor next month.
        assertEquals(-20.0, engine.finalPrice(100.0, 60));
    }
}

The key discipline: write the characterization test before touching the implementation, and let the current output — not your intuition about what’s “right” — become the assertion. Once real coverage exists, negative prices can be fixed deliberately, as a reviewed change with its own test update, rather than discovered by a customer.

Seams: Where to Cut Untestable Code

Legacy code often resists testing structurally — a constructor reaches out to a real database, a static call hits the filesystem, a method is buried fifteen calls deep with no way to observe its result. Michael Feathers’ term for a workable entry point is a seam: a place in the code where you can alter behavior without editing the line itself — typically by introducing a parameter, an interface, or a constructor argument where a hardcoded dependency used to be.

// Before: no seam. Every test of process() also hits a real file on disk.
class ReportGenerator {
    void process() {
        String data = new FileReader("report.csv").readAll(); // hardcoded dependency
        // ...
    }
}

// After: a constructor seam. Production code passes the real reader;
// a test can pass a fake one instead, with no filesystem involved.
class ReportGenerator {
    private final Reader reader;

    ReportGenerator(Reader reader) {
        this.reader = reader;
    }

    void process() {
        String data = reader.readAll();
        // ...
    }
}

Nothing about process()’s logic changed — only where its dependency comes from. That one-line seam is often the entire difference between “this class cannot be unit tested” and “this class can.”

A closely related move is the sprout method: rather than editing risky, untested logic in place, write the new behavior as a brand-new, fully-tested method, and call it from one line inside the old code. The old code you didn’t dare touch stays untouched; the new code you just wrote is covered from day one.

class InvoiceProcessor {
    void process(Invoice invoice) {
        // ... 80 lines of untested legacy logic you don't want to touch yet ...

        // NEW requirement: log high-value invoices for fraud review.
        // Instead of weaving this into the untested block above, sprout it:
        logIfHighValue(invoice);

        // ... the rest of the untested legacy logic ...
    }

    // Sprouted method — brand new, fully covered by its own unit tests,
    // zero risk added to the 80 untested lines above it.
    void logIfHighValue(Invoice invoice) {
        if (invoice.total() > 10_000) {
            System.out.println("Review required: " + invoice.id());
        }
    }
}

Test Doubles

Once a seam exists, you need something to put in its place during a test — a stand-in for the real database, network call, or clock. These stand-ins are collectively called test doubles, and the term covers several distinct shapes:

KindWhat it doesExample use
DummyPassed in only to satisfy a parameter list; never actually usedA Logger parameter the method under test never calls
StubReturns canned answers to calls made during the testA fake Reader that always returns a fixed CSV string
FakeA real, working implementation — just a simplified oneAn in-memory Map-backed repository instead of a real database
SpyA real (or partial) implementation that also records what was calledA wrapper around EmailSender that remembers every message sent
MockPre-programmed with expectations; fails the test if the expected calls don’t happen as specifiedVerifying paymentGateway.charge(...) was called exactly once

Writing every stub and fake by hand is tedious for anything beyond a couple of methods, which is why Mockito is the standard Java library for this. It generates mocks/stubs on the fly, without you writing an implementing class at all:

import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class OrderServiceTest {

    @Test
    void chargesCustomerExactlyOnceOnCheckout() {
        PaymentGateway gateway = mock(PaymentGateway.class); // fake object, no real network call
        when(gateway.charge(100.0)).thenReturn(true);        // stub: program its response

        OrderService service = new OrderService(gateway);    // inject via a constructor seam
        boolean result = service.checkout(100.0);

        assertTrue(result);
        verify(gateway, times(1)).charge(100.0);              // mock: verify the interaction happened
    }
}

mock(PaymentGateway.class) creates a fake implementation of the interface with no real behavior until you tell it what to return (when(...).thenReturn(...)). verify(...) then checks that a specific call actually happened — this is what turns a stub into a true mock: the test fails if charge(100.0) was never called, not just if it returned the wrong thing. This depends entirely on the seam from the previous section — OrderService must accept its PaymentGateway as a constructor parameter rather than constructing one itself, or there would be nothing to substitute.

JUnit 5 Reference

Annotations

AnnotationDescription
@TestMarks a method as a test case
@BeforeEachRuns before each test method
@AfterEachRuns after each test method
@BeforeAllRuns once before all tests in the class (must be static)
@AfterAllRuns once after all tests in the class (must be static)
@DisabledSkips the test (with optional reason)
@DisplayName("...")Sets a human-readable test name
@ParameterizedTestRuns the test with multiple sets of arguments

Assertions

MethodPurpose
assertEquals(expected, actual)Checks equality
assertNotEquals(a, b)Checks inequality
assertTrue(condition)Checks the condition is true
assertFalse(condition)Checks the condition is false
assertNull(object)Checks the reference is null
assertNotNull(object)Checks the reference is not null
assertThrows(ExType.class, () -> ...)Expects an exception of the given type
assertAll(executables...)Groups assertions — all are checked even if one fails

@ParameterizedTest and assertAll are worth seeing in action rather than just naming. Revisiting PasswordValidator from earlier, instead of four separate @Test methods for four invalid passwords, one parameterized test covers them all — and assertAll lets you check several properties of a single case without stopping at the first failure:

@ParameterizedTest
@ValueSource(strings = {"short1", "nouppercase1", "NOLOWERCASE1", "NoDigitsHere"})
void invalidPasswordsShouldFail(String password) {
    assertFalse(PasswordValidator.isValid(password));
}

@Test
void validPasswordSatisfiesAllRules() {
    String password = "Secure1x";
    assertAll("password properties",
        () -> assertTrue(password.length() >= 8),
        () -> assertTrue(password.chars().anyMatch(Character::isDigit)),
        () -> assertTrue(password.chars().anyMatch(Character::isUpperCase))
    );
}

@ParameterizedTest needs an extra import (org.junit.jupiter.params.ParameterizedTest) and a source annotation like @ValueSource (org.junit.jupiter.params.provider.ValueSource) telling JUnit where the arguments come from — here, a fixed list of strings, one test run per value.