-
Notifications
You must be signed in to change notification settings - Fork 135
SE 09 Testing Principles
Part of the Software Engineering Principles series
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 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.
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.
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);
}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 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 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.
TDD is a development practice where tests are written before the implementation code. The cycle is:
Red → Green → Refactor
- Red: Write a test for the next small piece of behaviour. It fails because the code does not exist yet.
- Green: Write the minimum code needed to make the test pass.
- Refactor: Clean up the code — improve names, remove duplication — without breaking the test.
- 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
// 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 passesGood 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) |
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());
}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());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 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