Checkout prices an Order: ten percent off an active cart, nothing for a cancelled one, and a discount that cannot leave the 0–100 range. That rule is arithmetic plus an active flag. It does not need a servlet, a JPA session, or an application context. Teams still paste @SpringBootTest on the class because the app happens to be Spring.
A domain rule is a JUnit 5 test — not a Spring context. This post owns that plain unit test: @Test, assertions, parameterized cases, @BeforeEach, and one @Nested grouping. When the assertion needs a Spring boundary, stop and slice. Strategy and SRP already show @Test as examples; they are not this tutorial.
Mental model
A JUnit 5 (Jupiter) test is a method that fails by throwing. Jupiter finds methods annotated @Test (or @ParameterizedTest), runs them, and reports the assertion error. There is no container, no bean graph, and no HTTP stack unless you put one there.
A plain unit test is allowed to prove a domain rule. Here that means: given this Order, payable is this amount; given an inactive order, it throws; given a bad percent, it throws. It is not allowed to prove that a controller mapped a JSON body or that Hibernate flushed a row. Those assertions belong at a framework boundary — and the slices post already says the fastest test is a plain unit test. This is that test.
| The assertion is about | Tool |
|---|---|
Pricing, totals, active, invariants | Plain JUnit 5 (this post) |
| An HTTP contract or a persistence mapping | A Spring slice — stop here |
| Cross-layer wiring of the whole app | Rare, expensive, not this post |
You write the production type as an ordinary class. You write the test as an ordinary class. Jupiter does the rest.
The rule under test
Same checkout carriers the rest of the Java series uses — records as data, a typed List<LineItem> in the header:
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
OrderPricer is the rule. Active orders get a percent off, rounded half-up to two places. Inactive orders do not price. A percent outside 0–100 is a caller bug:
public final class OrderPricer {
public BigDecimal payable(Order order, int percentOff) {
if (!order.active()) {
throw new IllegalStateException("inactive order: " + order.id());
}
if (percentOff < 0 || percentOff > 100) {
throw new IllegalArgumentException("percentOff out of range: " + percentOff);
}
BigDecimal factor = BigDecimal.ONE.subtract(
BigDecimal.valueOf(percentOff).movePointLeft(2));
return order.total().multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
}
No Spring types. No repository. A test that needs a DataSource to prove this method is testing the wrong thing.
A first @Test
assertEquals is the assertion you will write most. Expected value first, actual second — when it fails, the message reads as “wanted this, got that”:
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
class OrderPricerTest {
@Test
void tenPercentOffRoundsHalfUp() {
OrderPricer pricer = new OrderPricer();
Order cart = new Order(
"o-1",
"ada@example.com",
List.of(new LineItem("sku-1", 1, new BigDecimal("19.99"))),
new BigDecimal("19.99"),
true);
assertEquals(new BigDecimal("17.99"), pricer.payable(cart, 10));
}
}
19.99 × 0.90 is 17.991, which half-up at two places is 17.99. The record is the fixture; you do not need a builder, a JSON file, or a saved row. If records are new, that shape lives in the records post.
assertThrows and assertAll
Cancelled carts must not price. assertThrows takes the expected type and a lambda that should blow up. The return value is the exception, so you can assert the message too — exceptions owns checked vs unchecked; here we only care that the test saw the throw:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void inactiveOrderDoesNotPrice() {
OrderPricer pricer = new OrderPricer();
Order cancelled = new Order(
"o-9",
"ada@example.com",
List.of(),
new BigDecimal("19.99"),
false);
IllegalStateException ex = assertThrows(
IllegalStateException.class,
() -> pricer.payable(cancelled, 10));
assertEquals("inactive order: o-9", ex.getMessage());
}
A method that returns and a method that throws are different contracts. Do not try / fail() around the call; assertThrows is that helper.
assertAll groups several checks so one failure does not hide the others. Use it for several properties of one outcome — not as a way to cram three unrelated scenarios into a single method:
import static org.junit.jupiter.api.Assertions.assertAll;
@Test
void tenPercentLeavesTheCartUnchanged() {
OrderPricer pricer = new OrderPricer();
Order cart = new Order(
"o-1",
"ada@example.com",
List.of(new LineItem("sku-1", 1, new BigDecimal("19.99"))),
new BigDecimal("19.99"),
true);
BigDecimal payable = pricer.payable(cart, 10);
assertAll(
() -> assertEquals(new BigDecimal("17.99"), payable),
() -> assertEquals(2, payable.scale()),
() -> assertEquals(new BigDecimal("19.99"), cart.total()));
}
The third check is cheap insurance: a record cannot mutate total, so if payable ever aliased the field and scaled it in place you would see it. Grouping those three is why assertAll exists.
@BeforeEach — fixtures you reuse
Once two tests share the same cart, constructing it inline is noise. @BeforeEach runs before every @Test and every @ParameterizedTest in that class (and in nested classes below it):
import org.junit.jupiter.api.BeforeEach;
class OrderPricerTest {
private OrderPricer pricer;
private Order cart;
@BeforeEach
void newCart() {
pricer = new OrderPricer();
cart = new Order(
"o-1",
"ada@example.com",
List.of(new LineItem("sku-1", 1, new BigDecimal("19.99"))),
new BigDecimal("19.99"),
true);
}
@Test
void tenPercentOffRoundsHalfUp() {
assertEquals(new BigDecimal("17.99"), pricer.payable(cart, 10));
}
}
Records make this boring in a good way: nothing in newCart is leftover mutable state from the previous method. If the fixture were a mutable ArrayList you appended to, @BeforeEach would be the reset — and a leaked static list would still poison the next test. Prefer a new record per method.
Note: @BeforeEach is per-example, not per-class. A @ParameterizedTest with four rows runs newCart four times. That is what you want.
@ParameterizedTest — one rule, many rows
@ValueSource feeds a single argument. Out-of-range percents are the same assertion with different ints:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest
@ValueSource(ints = { -1, 101, 250 })
void rejectsPercentOutOfRange(int percentOff) {
assertThrows(
IllegalArgumentException.class,
() -> pricer.payable(cart, percentOff));
}
@CsvSource feeds several columns. Jupiter converts the cells to the parameter types — int and BigDecimal here — so each row is a named example of the same rounding rule:
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest
@CsvSource({
"0, 19.99, 19.99",
"10, 19.99, 17.99",
"30, 19.99, 13.99",
"100, 19.99, 0.00"
})
void appliesPercentOff(int percentOff, BigDecimal total, BigDecimal expected) {
Order order = new Order(
cart.id(), cart.customerEmail(), cart.items(), total, true);
assertEquals(expected, pricer.payable(order, percentOff));
}
30% of 19.99 is 13.993 → 13.99. 100% is zero, not a negative total. One method, four proofs. Keep the table small enough that a failing row is still readable in the report.
@Nested — group by scenario
When inactive carts and percent math start to crowd one class, nest. The outer @BeforeEach still runs; the inner one adds the scenario-specific fixture:
import org.junit.jupiter.api.Nested;
class OrderPricerTest {
private final OrderPricer pricer = new OrderPricer();
@Nested
class ActiveOrders {
private Order cart;
@BeforeEach
void activeCart() {
cart = new Order(
"o-1",
"ada@example.com",
List.of(new LineItem("sku-1", 1, new BigDecimal("19.99"))),
new BigDecimal("19.99"),
true);
}
@Test
void tenPercentOffRoundsHalfUp() {
assertEquals(new BigDecimal("17.99"), pricer.payable(cart, 10));
}
}
@Nested
class InactiveOrders {
@Test
void refuseToPrice() {
Order cancelled = new Order(
"o-9", "ada@example.com", List.of(),
new BigDecimal("19.99"), false);
assertThrows(
IllegalStateException.class,
() -> pricer.payable(cancelled, 10));
}
}
}
Nest when the names in the report should read as ActiveOrders → tenPercentOffRoundsHalfUp. Skip it for a class that still fits on one screen.
When the assertion needs Spring, stop
OrderPricer.payable is a Java method. The moment the thing you need to prove is “this @GetMapping returns that JSON” or “this repository query hits that column,” you have left the domain rule. Do not grow OrderPricerTest into an application context to chase that assertion. Stop. Open Testing Slices + Testcontainers and load the one Spring boundary the assertion belongs to — not the whole app.
This post will not teach those annotations. Pattern posts that already call assertEquals on PercentOff or a tax calculator stay examples of this shape, not a second JUnit tutorial.
Pitfalls / when not to
Booting Spring to test payable. If the class under test has no Spring type on it, @SpringBootTest is not a more thorough test. It is a slower test of the same arithmetic, plus whatever else the context decided to start.
BigDecimal equality is scale-sensitive. assertEquals(new BigDecimal("18.0"), new BigDecimal("18.00")) fails. Produce a scale-2 result in production (setScale(2, …)) and write the expected value with that scale. compareTo == 0 ignores scale and also throws away the assertion message; prefer matching BigDecimals.
assertTrue(payable.equals(expected)). When it fails you get expected true, was false. assertEquals prints both values. Use the assertion that talks.
One method, twelve scenarios. assertAll is for several properties of one call. Twelve percents belong in @CsvSource. A cancelled cart belongs in its own @Test or @Nested class.
Testing record accessors. assertEquals("o-1", cart.id()) after new Order("o-1", …) proves the compiler. Assert the rule — payable, the throw, the scale.
A parameterized row nobody can read. {0} / {1} in a display name is fine; a CSV of magic numbers with no header comment is how a failure becomes a puzzle. Keep the columns obvious: percent, total, expected.
Skip JUnit ceremony when:
- There is nothing to assert yet — a record with no invariant is not a test class.
- The behavior is MVC or JPA. That is a slice, not a nested class named
Controller. - You would only be wrapping
Order::activein a test that callsactive().
A green test that never called the production method is not coverage.
Cheat sheet
Finders @Test one example
@ParameterizedTest one rule, many rows
Providers @ValueSource one argument
@CsvSource several columns (Jupiter converts types)
Lifecycle @BeforeEach new fixture per example
Grouping @Nested report path / shared inner fixture
Assertions assertEquals(expected, actual)
assertThrows(Type.class, () -> …)
assertAll(() -> …, () -> …) all run; one outcome
Proves domain math, invariants, input tables
Does not HTTP, JPA mappings, full app wiring
Do:
- Put expected first in
assertEquals. - Use records as fixtures; keep
List<LineItem>typed. - Parameterize the rows; nest the scenarios;
@BeforeEachthe cart. - Stop and slice when the assertion needs a Spring boundary.
Don’t:
- Paste
@SpringBootTeston a class that only does arithmetic. - Compare
BigDecimals with mismatched scale. - Re-teach JUnit inside a pattern post that already has a
@Test.
Wrap-up
JUnit 5 finds a method, runs it, and reports the assertion. @Test is one example; @ParameterizedTest is one rule with a table; @BeforeEach rebuilds the cart; @Nested names the scenario. assertEquals, assertThrows, and assertAll are enough to prove that an active Order prices and an inactive one does not.
That is the whole job of a plain unit test. The previous fundamentals post walked paths, copies, and walks. This one closes the wave with the test you write before you ever boot Spring.