Week 11 Lesson
Testing and Testable Design
If important behavior is hard to test, the design is probably telling you something.
Learning Objectives
- Distinguish unit tests, integration tests, and end-to-end tests.
- Design small testable units.
- Use fakes, stubs, or mocks appropriately.
- Refactor code that is hard to test.
Lecture Notes
Testing is not separate from design. The way code is structured determines how easy it is to verify. If a method reads the keyboard, calls the database, sends email, calculates pricing, and prints output all at once, testing one rule requires the whole environment. That is a design problem, not just a testing problem.
Unit tests focus on small pieces of behavior, usually without real external systems. Integration tests check that multiple parts work together, such as a service and database. End-to-end tests simulate real user flows. All three can be valuable, but unit tests are where design problems often become obvious first.
A test seam is a place where you can replace real behavior with controlled behavior. Interfaces and dependency injection create seams. If OrderService receives a PaymentGateway, a test can provide a fake gateway that succeeds, fails, or records calls. Without that seam, tests may require real payment credentials or network access.
Mocks, stubs, and fakes are test doubles. A stub returns controlled answers. A mock verifies that something was called. A fake is a working simplified implementation, such as an in-memory repository. Students should not overuse mocks to test implementation details. Tests should usually focus on observable behavior and important rules.
Testable design often looks like clean design: small methods, clear inputs and outputs, separated side effects, dependency inversion, and domain rules outside the UI. When tests are painful, ask why. The answer often points to tight coupling, hidden global state, or mixed responsibilities.
Worked Example: Testing with an in-memory repository
// Hard to test because storage is hard-coded.
class CourseService {
enroll(studentId: string, courseId: string) {
database.insert('enrollments', { studentId, courseId });
}
}interface EnrollmentRepository {
save(enrollment: Enrollment): void;
exists(studentId: string, courseId: string): boolean;
}
class CourseService {
constructor(private repo: EnrollmentRepository) {}
enroll(studentId: string, courseId: string) {
if (this.repo.exists(studentId, courseId)) {
throw new Error('Student already enrolled');
}
this.repo.save({ studentId, courseId });
}
}Lab: Make It Testable
Scenario: A service has hard-coded file access and sends real emails during tests.
Steps
- Identify all external side effects.
- Introduce interfaces for external services.
- Inject dependencies through constructors.
- Create fakes for tests.
- Write tests for at least three important rules.
Deliverables
- Refactored service
- Test doubles
- Test cases
- Design reflection
Assignment
Write at least five tests for your capstone. Explain one design change you made because testing exposed a problem.
Discussion Prompts
- What makes a test valuable?
- When should a test use a fake instead of a mock?
- Can code be over-tested?
Week 11 Quiz
Use these as quick checks after the lesson.
Question 1
A unit test usually checks:
Question 2
A fake repository is useful because:
Question 3
Hard-to-test code often has:
Question 4
A test seam is:
Question 5
Tests should usually focus on:
Student Notes
Saved in this browser only.