Intermediate · Prove

Prove Domain Rules with JUnit 5

Write small tests around state transitions, invariants, and edge cases without starting Spring.

Primary source: Spring Boot 3.5 Reference: Testing

35 min

Lesson 1 of 4

1d streak

The Architectural Question

Domain rules tested only through Spring contexts take seconds per test, so teams write fewer tests and edge cases go uncovered.

What this guide proves

  • Construct the object graph directly
  • Name tests after behavior and outcome
  • Keep time, randomness, and IDs controllable

The Testing Pyramid: Pure Domain Tests Without Framework Weight

The Test Automation Pyramid dictates that the vast majority of tests should be fast, isolated unit tests. When business logic is tightly coupled to Spring annotations or requires the Spring ApplicationContext to run, test suites grind to a halt. A pure domain model should execute hundreds of tests in under 100 milliseconds using standard JUnit 5.

Architectural Boundary · Do Not Confuse

Concept A

Slow Framework-Coupled Unit Test: @SpringBootTest with full context loading. Takes 5 to 15 seconds per test file. Spins up Tomcat, Hibernate, and connection pools just to test a tax calculation rule.

Concept B

Fast Pure Domain Test: Plain JUnit 5 test with no Spring runner. Runs in 2 milliseconds. Directly instantiates domain entities and executes business logic in JVM memory.

Keep domain logic, calculation rules, and state machines inside plain Java objects and records. Reserve Spring testing annotations for integration and adapter boundaries.

Active Recall Checkpoint

Why should unit tests for business domain logic never use @SpringBootTest?

Hint: Consider execution speed and developer feedback loops during refactoring.

Show answer & explanation

@SpringBootTest bootstraps the complete Spring ApplicationContext, adding seconds of startup overhead per test. Pure domain unit tests run instantly in memory, providing sub-second feedback during development and TDD.

JUnit 5 Jupiter: Parameterized Tests & Nested Hierarchies

JUnit 5 Jupiter provides expressive testing constructs including @ParameterizedTest for combinatorial boundary testing and @Nested for organizing behavior specifications (BDD style).

OrderDiscountTest.java
package com.example.domain;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@DisplayName("Order Discount Engine Specification")
class OrderDiscountTest {

    private final DiscountCalculator calculator = new DiscountCalculator();

    @Nested
    @DisplayName("Tier-Based Discount Calculations")
    class TierDiscounts {

        @ParameterizedTest(name = "Customer tier {0} with spend {1} should receive {2}% discount")
        @CsvSource({
            "BRONZE, 100, 0",
            "SILVER, 100, 5",
            "GOLD,   100, 10",
            "GOLD,   500, 15",
            "PLATINUM, 100, 20"
        })
        void calculatesCorrectDiscount(CustomerTier tier, long spendCents, int expectedPercentage) {
            int discount = calculator.calculate(tier, spendCents);
            assertThat(discount).isEqualTo(expectedPercentage);
        }
    }

    @Nested
    @DisplayName("Boundary Validation Failures")
    class ValidationFailures {

        @Test
        void negativeSpend_ThrowsIllegalArgumentException() {
            assertThatThrownBy(() -> calculator.calculate(CustomerTier.BRONZE, -100))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("Spend cannot be negative");
        }
    }
}

Active Recall Checkpoint

What is the primary benefit of using @CsvSource with @ParameterizedTest?

Hint: How does it reduce code duplication across multiple input-output scenarios?

Show answer & explanation

It eliminates redundant test methods by running the identical test logic across multiple input-output permutations, making boundary testing clear, readable, and easy to extend.

Fluent Assertions with AssertJ: Readability & Diagnostic Clarity

JUnit's legacy assertEquals(expected, actual) has notorious drawbacks: developers frequently invert parameter order, failure messages are cryptic, and asserting on collections or object graphs requires clumsy boilerplate. AssertJ provides fluent, readable, type-safe assertions.

AssertJExamples.java
// AssertJ fluent chained assertions:
assertThat(order.getItems())
    .hasSize(2)
    .extracting(OrderItem::sku)
    .containsExactly("SKU-APPLE", "SKU-ORANGE");

assertThat(order.getTotalAmount())
    .isNotNull()
    .isPositive()
    .isGreaterThan(Money.of(10, Currency.USD));

// Clean exception verification:
assertThatThrownBy(() -> inventory.reserve("SKU-OUT-OF-STOCK", 10))
    .isInstanceOf(InsufficientInventoryException.class)
    .hasMessage("Insufficient stock for SKU-OUT-OF-STOCK: requested=10 available=0");

Active Recall Checkpoint

Why is assertThat(actual).isEqualTo(expected) superior to assertEquals(expected, actual) when a test fails?

Hint: What happens if a developer flips the arguments in assertEquals?

Show answer & explanation

In assertEquals, flipping arguments causes confusing failure messages stating "expected [foo] but was [bar]" backwards. In AssertJ, assertThat(actual) guarantees the actual object is always tested against the expected value, with clear, colored failure diffs.

Deliberate Failure Lab: Catching Off-by-One Boundary Bugs

A common production bug occurs in inventory reservation when stock hits exact zero. If an engineer mistakenly uses availableStock > requested instead of availableStock >= requested, customers cannot purchase the very last available item.

InventoryItem.java
public void reserve(int quantity) {
    // BUG: Prevents purchasing the exact remaining item when quantity == availableStock!
    if (quantity > availableStock) {
        throw new InsufficientInventoryException();
    }
    this.availableStock -= quantity;
}

The Three Essential Boundary Test Cases

Every inventory or quota test must verify three distinct boundaries: 1. requested < available (typical success), 2. requested == available (exact boundary condition), and 3. requested > available (rejection).

Active Recall Checkpoint

Why are boundary values (e.g. 0, 1, exact stock count) the most critical values to include in unit test suites?

Hint: Where do comparison operator bugs (< vs <=) invariably manifest?

Show answer & explanation

Comparison operator bugs, rounding errors, and array indexing defects almost always occur at the boundaries rather than in the middle of standard ranges.

Working Implementation

Code Walkthrough

Test inventory reservation at zero, exact, and insufficient stock.

DiscountPolicyTest.java
@ParameterizedTest
@CsvSource({"0,0", "9999,0", "10000,500"})
void discount_changes_at_the_threshold(long subtotal, long expected) {
    var policy = new DiscountPolicy(Money.cents(10_000));

    Money discount = policy.forSubtotal(Money.cents(subtotal));

    assertThat(discount).isEqualTo(Money.cents(expected));
}
DiscountPolicy is a plain object, so this test needs no application context and runs in milliseconds.
The three rows sit at zero, one cent below the threshold, and exactly on it: the values where the rule actually changes.

Synthesis & Production Verification

Production Acceptance Criteria

  • Construct the object graph directly without starting Spring
  • Cover zero, exact, and insufficient stock paths in one parameterized test
  • Keep clock, randomness, and identifiers under test control

Architectural & Technical Review

Core Interview Questions

Q01Why should domain logic be tested with pure JUnit 5 rather than @SpringBootTest?Show answer

@SpringBootTest loads the entire container, taking seconds per class. Pure unit tests run directly in JVM memory in milliseconds, enabling rapid test-driven development.

Q02How do JUnit 5 @ParameterizedTest and @CsvSource improve test coverage?Show answer

They allow executing the same assertion logic across multiple input-output permutations without code duplication, making boundary and edge-case testing clear and concise.

Q03What advantages does AssertJ provide over JUnit assertEquals?Show answer

AssertJ provides fluent chained assertions, eliminates argument inversion bugs, supports deep object and collection inspections, and prints detailed diagnostic failure messages.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Booting @SpringBootTest for pure business rule calculations — Adds unnecessary container startup overhead, dramatically slowing CI builds and developer feedback loops.
  • Pitfall: Skipping boundary tests for zero, negative, and exact inventory limits — Comparison operator defects (< vs <=) almost always hide at boundary values.

Primary Documentation & Specifications

Supplementary Module Resources