Verdict
Yes
Test a value object that checks rules or computes new values with plain unit tests: each rule at its boundary, each operation, and equality between two spellings of the same value.
Why
- Blast radiususers
- Change frequencyrarely
- Detectabilityeventually
- Reversibilitywith-effort
- Test costtrivial
I test value objects that check rules or compute new values, such as EmailAddress or DateRange, with plain unit tests. Blast radius is users, because a wrong rule lets bad values into customer records. Change frequency is rarely: a value object gains a rule a few times a year. Detectability is eventually, since a missing rule creates a plausible value and nothing crashes. Reversibility is with-effort, because stored bad values need a repair script. Test cost is trivial: with no dependencies, a test is one constructor call and one assertion.
| When | Decision | Why |
|---|---|---|
| The value object wraps one value with no rules, such as a `CustomerId` record around a UUID | Do not test the wrapper; let the compiler reject a `CustomerId` passed as an `OrderId` | Detectability rises to immediately and Reversibility falls to trivial: mixing two ID types fails the build |
| The aggregate root's tests pass a valid and an invalid value at the boundary of every rule of the value object, and run each of its operations with values that give each a different result | Do not add separate value object tests; keep one case per rule in the aggregate tests | Detectability rises to immediately and Reversibility falls to trivial: a broken rule fails the aggregate root's tests in CI |
| A database CHECK constraint enforces the same rule, such as a length above zero, and the value object only carries the value to that column | Test minimally: one test that the first invalid value is rejected | Detectability rises to same-day and Reversibility falls to trivial: the insert fails and stores nothing |
| The value object has no rules, and other services read its JSON from a message queue | Test minimally: one serialization test that asserts the field names | Test cost rises to moderate: the test needs the service's JSON mapper |
| The value object is `Money`: it adds amounts in one currency and rounds to cents | Test mandatory: rounding at half a cent, a currency mismatch, and a sum of many small amounts | Blast radius rises to money and Reversibility to costly: a wrong invoice total ends in refunds |
| The value object holds a national ID number, and its `toString()` is the only mask before log lines | Test mandatory: assert that `toString()` output does not contain the full number | Blast radius rises to safety-or-legal, Detectability to never and Reversibility to impossible: a number in log storage cannot be taken back |
What breaks if you don't test
A DateRange that accepts an end before its start stores a booking that no date search returns, and the customer reports it days later. An EmailAddress that keeps the case of its input treats Ana@Example.com and ana@example.com as two values, so the duplicate check passes and one customer gets two accounts.
What you lose if you over-test
A test that reads each field back after construction repeats the constructor and fails only on an intended rename. An equality test on a record or a Kotlin data class checks code the compiler wrote, unless a component compares in its own way.
How to test
Write unit tests without mocks or framework setup:
- For each rule, the boundary value that passes and the first that fails: a one-day
DateRangepasses, an end one day before the start fails. - For each operation, one case per branch:
overlapswith ranges that touch, overlap, and stay apart. - For equality, two objects built from different spellings of one value.
- For a hand-written
equalsandhashCode, one call to EqualsVerifier.
The JUnit 5 user guide covers parameterized tests, one row per boundary.
When the answer changes
- The value object starts to hold an amount of money or personal data.
- The value object loses its rules and becomes a typed wrapper around one value.
- Another service or a stored JSON column reads its serialized form.
Code example + Counterexample
The record that says 2.5 and 2.50 differ
The common advice says a Java record generates equals, so equality needs no test. The advice fails for a BigDecimal component:
record Length(BigDecimal meters) {
Length {
if (meters.signum() <= 0) throw new IllegalArgumentException("length must be positive");
meters = meters.stripTrailingZeros(); // the fix; remove it and the first test fails
}
}
class LengthTest {
@Test
void sameLengthWrittenTwoWaysIsEqual() {
assertEquals(new Length(new BigDecimal("2.5")), new Length(new BigDecimal("2.50")));
}
@Test
void rejectsZero() {
assertThrows(IllegalArgumentException.class, () -> new Length(BigDecimal.ZERO));
}
}
The record compares components with their own equals, and the BigDecimal documentation states that BigDecimal.equals requires the same value and scale. A form sends 2.5, a NUMERIC(10,2) column returns 2.50, and a catalog search that matches stock by Length shows a board in stock as sold out. Detectability stays eventually, because the page looks normal. A compact constructor may reassign its parameter, as JEP 395 shows, and the equality test keeps that fix in place.
Related questions
FAQ
- Should I unit test value objects or only the aggregate root?
Unit test a value object directly when it holds rules, because it is cheaper to build than a whole aggregate. Test only through the aggregate root when its tests already cover every rule at its boundary and every operation.
- Should I test equals and hashCode of a value object?
Test a hand-written
equalsandhashCodewith EqualsVerifier, which checks the equality contract in one call. For a generatedequals, test equality only when a component compares in its own way, such as aBigDecimalor an array.- Should I test value objects written as Java records?
Test the parts of a Java record that you wrote: the checks in the compact constructor and methods that compute new values. Do not test the accessors, or a generated
equalsoverStringorLocalDatecomponents.- Should I mock value objects in unit tests?
Do not mock value objects; create real instances, because they have no dependencies. A mock hides a broken rule in the real object from every test that uses it.