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
Lesson 1 of 4
1d streakThe 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
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.
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).
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.
// 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.
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
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.
@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));
}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
Key Takeaway
Pure domain unit tests execute in milliseconds using JUnit 5 parameterized tests and AssertJ without Spring framework overhead, providing instant developer feedback on business rules.
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
Lesson Primary References
Supplementary Module Resources