Skip to content

SE 09 Testing Principles

Dr M H B Ariyaratne edited this page Jun 8, 2026 · 1 revision

SE-09: Testing Principles

Part of the Software Engineering Principles series


Why Testing Matters

Testing is the systematic activity of verifying that software behaves as intended. Without it, every change carries an unknown risk of breaking existing functionality. With a good test suite, teams can change code confidently, knowing that regressions will be caught immediately.

Testing is not a phase at the end of development — it is a discipline woven throughout.


The Testing Pyramid

The testing pyramid is a model for balancing the types of tests in a system.

          /\
         /  \
        / E2E \          Few — slow, fragile, expensive to maintain
       /────────\
      /Integration\      Some — medium speed, test component boundaries
     /─────────────\
    /   Unit Tests   \   Many — fast, reliable, cheap to run
   /─────────────────\

Unit tests form the large base — they are fast (milliseconds each), highly targeted, and easy to maintain. Write many of them.

Integration tests verify that components work together correctly — a service calling a repository against a real database, for example.

End-to-end (E2E) tests simulate a full user journey through the system. They are the most realistic but also the slowest and most brittle. Keep these to the critical paths only.


Unit Testing

A unit test verifies a single unit of logic — typically one class or one method — in isolation. External dependencies (databases, file systems, other services) are replaced with test doubles.

Anatomy of a Test

Every unit test has three parts, often called Arrange-Act-Assert (AAA):

@Test
public void calculateDiscount_forStaffMember_appliesTwentyPercent() {
    // Arrange — set up the state
    Patient staff = new Patient();
    staff.setCategory(PatientCategory.STAFF);
    DiscountCalculator calculator = new DiscountCalculator();

    // Act — perform the operation
    double discounted = calculator.calculate(staff, 100.0);

    // Assert — verify the result
    assertEquals(80.0, discounted, 0.001);
}

Naming Tests

Test names should describe the scenario and expected outcome, not just the method name:

// Bad
testCalculate()

// Good
calculate_forStaffPatient_appliesTwentyPercentDiscount()
calculate_forNegativeAmount_throwsIllegalArgumentException()
calculate_forNullPatient_throwsNullPointerException()

The pattern methodName_scenario_expectedBehaviour makes failing tests immediately informative.


Test Doubles

Test doubles are substitutes for real dependencies in unit tests.

Type Description When to use
Stub Returns preset values; no verification Provide indirect inputs
Mock Verifies that methods were called with expected arguments Verify interactions
Fake A working, simplified implementation When a stub is too simple
Spy A real object that also records calls When partial real behaviour is needed
// Stub — provides a pre-set patient to the service under test
PatientRepository stubRepo = mock(PatientRepository.class);
when(stubRepo.findById(42L)).thenReturn(Optional.of(testPatient));

// Mock — verifies the audit service was called
AuditService mockAudit = mock(AuditService.class);
service.processAdmission(patient, ward);
verify(mockAudit).log(eq("PATIENT_ADMITTED"), anyLong());

Integration Testing

Integration tests verify that two or more components work together. The most important integration tests in a data-driven application are those that test the data access layer against a real database.

@RunWith(Arquillian.class)
public class PatientRepositoryIT {

    @Inject
    PatientRepository repository;

    @Test
    public void save_thenFind_returnsSamePatient() {
        Patient patient = new Patient("Alice", LocalDate.of(1985, 1, 1));
        repository.save(patient);

        Optional<Patient> found = repository.findByName("Alice");

        assertTrue(found.isPresent());
        assertEquals("Alice", found.get().getName());
    }
}

Integration tests are slower than unit tests but catch problems that mocking can hide — wrong SQL, JPA mapping errors, constraint violations.


Test-Driven Development (TDD)

TDD is a development practice where tests are written before the implementation code. The cycle is:

Red → Green → Refactor
  1. Red: Write a test for the next small piece of behaviour. It fails because the code does not exist yet.
  2. Green: Write the minimum code needed to make the test pass.
  3. Refactor: Clean up the code — improve names, remove duplication — without breaking the test.

Why TDD Works

  • Forces you to think about the interface before the implementation
  • Tests are guaranteed to exist (they came first)
  • Drives small, testable units of code
  • Regression protection grows naturally as the feature is built

Example TDD Cycle

// Red: test first
@Test
public void dispense_reducesStockByIssuedQuantity() {
    StockItem item = new StockItem(medicine, 100);
    DispensingService service = new DispensingService(new InMemoryStockRepository());

    service.dispense(item, 30);

    assertEquals(70, item.getQuantity());
}

// Green: minimum code to pass
public class DispensingService {
    public void dispense(StockItem item, int quantity) {
        item.deduct(quantity);
    }
}

// Refactor: add validation, improve names, extract constants — test still passes

Key Testing Properties

FIRST Principles

Good tests are:

Property Meaning
Fast Run in milliseconds; slow tests get skipped
Independent Do not depend on other tests or shared state
Repeatable Produce the same result every run, anywhere
Self-validating Pass or fail automatically — no manual inspection
Timely Written close to the code they test (ideally before)

Test Independence

Each test must set up its own state and clean up after itself. Tests that depend on execution order are fragile and produce confusing failures.

// Bad — test depends on data left by another test
@Test
public void findByName_returnsPatient() {
    // Assumes a previous test saved "Alice" — breaks if run in isolation
    Optional<Patient> p = repo.findByName("Alice");
    assertTrue(p.isPresent());
}

// Good — each test creates its own data
@Test
public void findByName_returnsPatient() {
    repo.save(new Patient("Alice", LocalDate.of(1985, 1, 1)));
    Optional<Patient> p = repo.findByName("Alice");
    assertTrue(p.isPresent());
}

What to Test

Test Behaviour, Not Implementation

Tests should verify what the code does, not how it does it. Tests tied to implementation details break whenever the internal structure changes, even when behaviour is correct.

// Bad — tests implementation detail (a private field)
assertEquals(BillStatus.FINALIZED, bill.status);

// Good — tests observable behaviour
assertTrue(bill.isFinalized());
assertFalse(bill.isEditable());

Test Edge Cases

For any function, consider:

  • Normal inputs (the happy path)
  • Boundary values (zero, empty, maximum)
  • Invalid inputs (null, negative, wrong type)
  • Error paths (service unavailable, database error)

Code Coverage

Code coverage measures what percentage of lines, branches, or paths are exercised by tests. It is a useful diagnostic but not a quality target.

  • 100% coverage does not mean all behaviour is tested — a test can execute a line without asserting its result
  • A test suite with 70% coverage and meaningful assertions is more valuable than one with 100% coverage of trivial assertions
  • Focus on coverage of business-critical paths, not on the number itself

Previous: SE-08: Refactoring
Next: SE-10: Version Control and Git Workflows

Back to Software Engineering Principles

Clone this wiki locally