Capstone: A Real Java Application

Testing the Application

15 min Lesson 8 of 13

Testing the Application

A capstone project without a test suite is unfinished, regardless of how well the production code is written. Tests are the living specification of your application: they prove each unit behaves correctly in isolation, prove that units cooperate correctly together, and guard against regressions as the codebase evolves. This lesson covers the complete testing strategy for the capstone — unit tests for domain logic and the service layer, integration tests that exercise the data layer against a real (or embedded) database, and the key trade-offs you must consciously make.

Testing Pyramid Recap

The classic pyramid has three bands:

  • Unit tests — fast, isolated, no I/O. Test a single class with dependencies mocked. These form the wide base.
  • Integration tests — moderate speed, test two or more real components (e.g., service + repository + database). The middle band.
  • End-to-end / system tests — slowest, test the whole application from the outside. Keep these few and targeted.

For the capstone the target ratio is roughly 70 % unit, 25 % integration, 5 % end-to-end. Writing too many integration tests produces a slow, brittle suite; too few leaves the wiring untested.

Project Dependencies

Add JUnit 5, Mockito, and AssertJ to pom.xml (or build.gradle). AssertJ gives a fluent assertion API that produces far better failure messages than raw JUnit assertions.

<!-- pom.xml excerpt --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.25.3</version> <scope>test</scope> </dependency>

Unit Testing Domain Logic

Domain classes — entities, value objects, domain services — must be pure and easy to instantiate with new. A well-designed domain has no external dependencies, so unit tests are trivial to write.

Suppose the capstone models an Order entity with a totalPrice() method:

// src/test/java/.../domain/OrderTest.java import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; class OrderTest { @Test void totalPrice_sumsLineItemsWithQuantity() { Order order = new Order(); order.addItem(new Product("Widget", new Money(10_00)), 3); order.addItem(new Product("Gadget", new Money(5_00)), 2); assertThat(order.totalPrice()).isEqualTo(new Money(40_00)); } @Test void addItem_withZeroQuantity_throwsDomainException() { Order order = new Order(); assertThatThrownBy(() -> order.addItem(new Product("X", new Money(1_00)), 0)) .isInstanceOf(DomainException.class) .hasMessageContaining("quantity"); } }
Store money as integer cents, never as double. Floating-point arithmetic on currency produces subtle rounding errors that are almost impossible to reproduce in tests. Use a Money value object backed by long cents, or use java.math.BigDecimal.

Unit Testing the Service Layer with Mockito

Service classes coordinate domain objects and repositories. The repository is a dependency — mock it so the service test stays fast and in-memory. Use the @ExtendWith(MockitoExtension.class) JUnit 5 integration to wire mocks automatically.

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository orderRepository; @Mock InventoryService inventoryService; @InjectMocks OrderService orderService; @Test void placeOrder_savesAndReturnsOrder() { PlaceOrderCommand cmd = new PlaceOrderCommand(42L, List.of( new LineItemDto(10L, 2) )); when(inventoryService.hasStock(10L, 2)).thenReturn(true); Order saved = new Order(1L, 42L); when(orderRepository.save(any(Order.class))).thenReturn(saved); Order result = orderService.placeOrder(cmd); assertThat(result.getId()).isEqualTo(1L); verify(orderRepository).save(any(Order.class)); verify(inventoryService).reserveStock(10L, 2); } @Test void placeOrder_whenOutOfStock_throwsException() { PlaceOrderCommand cmd = new PlaceOrderCommand(42L, List.of(new LineItemDto(10L, 99))); when(inventoryService.hasStock(10L, 99)).thenReturn(false); assertThatThrownBy(() -> orderService.placeOrder(cmd)) .isInstanceOf(OutOfStockException.class); verifyNoInteractions(orderRepository); } }
Verify interactions deliberately. Only call verify() on side-effects that matter — saving, sending email, publishing events. Verifying every mock call makes tests brittle and couples them to implementation details rather than observable behaviour.

Parameterised Tests

When the same logic must hold for multiple inputs, @ParameterizedTest eliminates copy-paste test methods:

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; class DiscountCalculatorTest { DiscountCalculator calculator = new DiscountCalculator(); @ParameterizedTest @CsvSource({ "0, 0.00", "99, 0.00", "100, 5.00", "500, 15.00", "1000,25.00" }) void discount_returnsCorrectPercentage(int orderCents, double expectedPercent) { double actual = calculator.discountPercent(new Money(orderCents)); assertThat(actual).isEqualTo(expectedPercent); } }

Integration Testing the Data Layer

Integration tests prove that your repository actually translates between the Java object model and the database correctly. Use an embedded H2 database (configured for MySQL compatibility mode) so the suite runs offline and fast, without touching production data.

// src/test/resources/application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop // Or if using plain JDBC with a connection pool: // JdbcOrderRepositoryIntegrationTest.java class JdbcOrderRepositoryIntegrationTest { private static DataSource dataSource; private JdbcOrderRepository repository; @BeforeAll static void initDataSource() { dataSource = new EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) .addScript("classpath:schema.sql") .build(); } @BeforeEach void setUp() { repository = new JdbcOrderRepository(dataSource); } @AfterEach void tearDown() throws Exception { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { stmt.execute("DELETE FROM order_items"); stmt.execute("DELETE FROM orders"); } } @Test void saveAndFindById_roundTripsSuccessfully() { Order order = new Order(null, 7L); order.addItem(new Product("Widget", new Money(10_00)), 2); Order saved = repository.save(order); Order loaded = repository.findById(saved.getId()).orElseThrow(); assertThat(loaded.getCustomerId()).isEqualTo(7L); assertThat(loaded.getItems()).hasSize(1); assertThat(loaded.totalPrice()).isEqualTo(new Money(20_00)); } }
Never share database state between integration tests. Each test must either clean up after itself in @AfterEach or run inside a transaction that is rolled back. Shared state causes intermittent failures that are notoriously hard to diagnose — they only appear when tests run in a specific order.

Testing with @Nested for Readable Structure

JUnit 5's @Nested class lets you group related scenarios inside a single test class, giving clear, hierarchical output in your IDE and CI logs:

class OrderServiceTest { @Nested class WhenPlacingAnOrder { @Test void succeeds_withValidItemsInStock() { /* ... */ } @Test void fails_whenProductNotFound() { /* ... */ } @Test void fails_whenQuantityExceedsStock() { /* ... */ } } @Nested class WhenCancellingAnOrder { @Test void succeeds_forPendingOrder() { /* ... */ } @Test void fails_forAlreadyShippedOrder() { /* ... */ } } }

Measuring Coverage

Run JaCoCo to measure line and branch coverage. A common project target is 80 % line coverage on the domain and service packages; the data layer is covered by integration tests. Coverage is a tool, not a goal — 100 % line coverage with weak assertions is worse than 70 % coverage with strong assertions. Focus on covering every business-rule branch, especially error paths.

# Maven — generates HTML report under target/site/jacoco/ mvn test jacoco:report

Summary

Effective testing of the capstone requires three complementary layers: fast unit tests for domain logic (no mocks needed) and service logic (repository mocked), integration tests that exercise the real JDBC/JPA code against an in-memory database, and deliberate use of JUnit 5 features — @ParameterizedTest, @Nested, lifecycle annotations — to keep the suite readable and maintainable. Always clean up database state between integration tests, verify only the side-effects that matter, and use coverage as a guide to gaps rather than a metric to optimise.