1. The Importance of Boundaries in Testing
- Boundaries separate different components or layers of a system, such as the business logic, UI, database, and external APIs.
- Well-defined boundaries make it possible to:
- Test components in isolation.
- Mock or stub external systems and dependencies.
- Write more focused and reliable tests.
2. Types of Tests and Boundaries
a. Unit Tests
- Unit tests focus on individual components (e.g., functions, classes) and ensure their correctness in isolation.
- Boundaries allow you to mock or replace external dependencies (e.g., database, API) in unit tests.
Example: Testing a Use Case
@Test
public void testCalculateTotalPrice() {
Order order = new Order();
order.addProduct(new Product("Item1", 10.0));
order.addProduct(new Product("Item2", 15.0));
double total = order.calculateTotal();
assertEquals(25.0, total, 0.01);
}
b. Integration Tests
- Test interactions between components across boundaries, such as:
- Use cases interacting with repositories.
- Controllers calling use cases.
- These tests ensure that different parts of the system work together as expected.
Example: Testing a Repository
@Test
public void testSaveOrder() {
OrderRepository repository = new SqlOrderRepository();
Order order = new Order();
repository.save(order);
Order retrieved = repository.findById(order.getId());
assertNotNull(retrieved);
}
c. Acceptance Tests
- End-to-end tests that validate the entire system from the user’s perspective.
- These tests cross all boundaries, including the UI, use cases, and database.
Example: Testing an API Endpoint
@Test
public void testPlaceOrderEndpoint() {
Response response = httpClient.post("/orders", orderJson);
assertEquals(200, response.getStatusCode());
assertTrue(response.getBody().contains("Order placed successfully"));
}
3. Mocking and Stubbing Across Boundaries
Mocking External Systems:
- Use mocks to simulate the behavior of external systems like APIs, databases, or third-party services.
- This ensures that tests remain fast and reliable without depending on external factors.
Example: Mocking a Payment Gateway
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);
Mockito.when(mockGateway.processPayment(order)).thenReturn(true);
Stubbing Repositories:
- Replace actual database calls with in-memory implementations for testing business logic.
- Example: Use an in-memory repository for testing a use case.
public class InMemoryOrderRepository implements OrderRepository {
private Map<String, Order> orders = new HashMap<>();
public void save(Order order) {
orders.put(order.getId(), order);
}
public Order findById(String id) {
return orders.get(id);
}
}
4. Benefits of Testing at Boundaries
- Isolation: Boundaries isolate the system into testable units, enabling focused and reliable tests.
- Speed: By mocking or stubbing dependencies, tests run faster.
- Reliability: Reduces flakiness caused by external systems like APIs or databases.
- Clarity: Tests can focus on a single responsibility, improving readability and maintainability.
5. Challenges in Testing Boundaries
Boundary Leaks:
- Poorly defined boundaries can lead to dependencies between layers, making tests brittle or hard to write.
- Example: Business logic depending directly on a database schema.
Over-Mocking:
- Excessive mocking can lead to tests that pass even when the system is broken because they don’t validate real-world scenarios.
- Balance mocks with integration and acceptance tests.
6. Testing the Layers in Clean Architecture
Entities:
- Entities contain core business rules and are the easiest to test.
@Test
public void testOrderDiscount() {
Order order = new Order();
order.addProduct(new Product("Item1", 100.0));
order.applyDiscount(10);
assertEquals(90.0, order.calculateTotal(), 0.01);
}
Use Cases:
- Use cases involve workflows and coordinate between entities and boundaries.
- Test them by mocking repositories and other dependencies.
Example:
@Test
public void testPlaceOrderUseCase() {
OrderRepository mockRepository = Mockito.mock(OrderRepository.class);
PlaceOrderUseCase useCase = new PlaceOrderUseCase(mockRepository);
Order order = new Order();
useCase.execute(order);
Mockito.verify(mockRepository).save(order);
}
Interface Adapters:
- Controllers, presenters, and gateways are tested by verifying their interactions with the use case layer.
Example:
@Test
public void testOrderController() {
PlaceOrderUseCase mockUseCase = Mockito.mock(PlaceOrderUseCase.class);
OrderController controller = new OrderController(mockUseCase);
ResponseEntity<String> response = controller.placeOrder(order);
assertEquals(HttpStatus.OK, response.getStatusCode());
Mockito.verify(mockUseCase).execute(order);
}
Frameworks and Drivers:
- Framework-specific code (e.g., database drivers, web frameworks) is tested with integration or acceptance tests.
Key Takeaways
- Boundaries Simplify Testing:
- Clear boundaries allow you to mock, stub, or isolate dependencies for focused tests.
- Layer-Specific Testing:
- Test each layer (entities, use cases, interface adapters) with appropriate strategies (unit, integration, acceptance).
- Use Mocks and Stubs Judiciously:
- Avoid over-mocking and balance with real-world integration tests.
- Focus on Core Business Logic:
- Ensure business rules are isolated and tested independently of external systems.