Testing object-oriented Java code is often an infrastructure problem. Before you write the first assertion you need to wire up a Spring context, stub a repository, mock a clock, inject a fake event publisher, and make sure the test database is in the right state. You are not testing your logic. You are testing your ability to reconstruct the environment your logic lives in.
Functional code eliminates most of that scaffolding — not by convention, not by discipline, but structurally. This post explains why, and shows what that looks like in practice with Java and the dmx-fun types.
The Root Cause of Test Complexity
Before looking at solutions, it is worth naming the problem precisely.
Imperative, object-oriented code is typically hard to test because functions have hidden inputs and outputs beyond their parameters and return value:
- They read from fields set by a constructor (hidden input).
- They call collaborators that call other collaborators (hidden side effects).
- They throw exceptions that bypass the return type (hidden output path).
- They depend on the current time, a random seed, or a system property (non-determinism).
A test for such a method must simulate all of those hidden channels. That is where mocks, spies, fakes, PowerMock, @MockBean, test containers, and three-page @BeforeEach blocks come from.
Functional programming solves this at the design level: make the inputs and outputs explicit, and make the function deterministic. When you do that, the test complexity disappears alongside the hidden channels.
Property 1: Pure Functions Need No Setup
A pure function takes its inputs, computes a result, and returns it. Nothing else. No database, no clock, no static field, no service locator.
// Pure function — every input is explicit, no hidden statepublic static Result<Email, String> validateEmail(String raw) { if (raw == null || raw.isBlank()) return Result.err("email must not be blank"); if (!raw.contains("@")) return Result.err("email is not valid"); return Result.ok(new Email(raw.trim().toLowerCase()));}The test for this function is as simple as calling it:
@Testvoid shouldRejectBlankEmail() { assertThat(validateEmail("")).isErr(); assertThat(validateEmail(" ")).isErr(); assertThat(validateEmail(null)).isErr();}
@Testvoid shouldRejectEmailWithoutAtSign() { Result<Email, String> result = validateEmail("notanemail"); assertThat(result).isErr(); assertThat(result.getError()).contains("not valid");}
@Testvoid shouldNormaliseAndAcceptValidEmail() { Result<Email, String> result = validateEmail(" Alice@Example.COM "); assertThat(result).isOk(); assertThat(result.get().value()).isEqualTo("alice@example.com");}No mocks. No @SpringBootTest. No @MockBean. No @BeforeEach. The function is a black box with explicit inputs and explicit outputs — exactly the shape a test needs.
Property 2: Typed Errors Make Test Cases Obvious
In traditional Java code, a method communicates failure through exceptions. The test must catch the exception, check the type, check the message — and hope it is the right exception out of a stack of wrapped ones.
// What could go wrong here? Hard to tell from the signature alone.public Order createOrder(String customerId, List<String> productIds) throws CustomerNotFoundException, ProductOutOfStockException, PaymentDeclinedException { ... }With typed errors, the test cases write themselves. Every variant of the error type is a test:
// The return type documents every failure modepublic sealed interface OrderError permits OrderError.CustomerNotFound, OrderError.ProductOutOfStock, OrderError.PaymentDeclined { ... }
public Result<Order, OrderError> createOrder( String customerId, List<String> productIds) { ... }@Testvoid shouldFailWhenCustomerDoesNotExist() { Result<Order, OrderError> result = service.createOrder("unknown-id", List.of("prod-1"));
assertThat(result).isErr(); assertThat(result.getError()).isInstanceOf(OrderError.CustomerNotFound.class);}
@Testvoid shouldFailWhenProductIsOutOfStock() { Result<Order, OrderError> result = service.createOrder("cust-1", List.of("out-of-stock-prod"));
assertThat(result).isErr(); assertThat(result.getError()).isInstanceOf(OrderError.ProductOutOfStock.class);}The sealed hierarchy acts as a test checklist. If you have not covered every permits variant, your coverage is incomplete — and the compiler tells you so in any switch expression that tries to exhaust it.
Property 3: Immutability Removes Shared-State Bugs
A common source of test fragility is test order dependency: test B passes only if test A has left some shared object in a certain state. This happens when production objects mutate their internal state, and tests drive them through sequences of calls.
Immutable data structures cannot have this problem. There is no state to pollute. Each function produces a new value; the input is unchanged.
// Immutable pipeline step — input unchanged, new value returnedpublic static Option<UserProfile> applyDiscount(UserProfile profile, Discount discount) { if (!discount.isApplicable(profile)) { return Option.none(); } return Option.some(profile.withDiscount(discount));}UserProfile base = new UserProfile("alice", Tier.STANDARD);Discount vip = new Discount("VIP50", Tier.PREMIUM);
// Each call is independent — base is never modifiedassertThat(applyDiscount(base, vip)).isNone();
Discount regular = new Discount("WELCOME10", Tier.STANDARD);assertThat(applyDiscount(base, regular)).isSome();Two tests, no @BeforeEach, no shared object to reset. The base profile is never touched. Every test starts from scratch by construction.
Property 4: Composition Tests Replace Integration Tests
A well-designed functional pipeline is a composition of pure steps. When each step is individually tested, the pipeline’s correctness follows from the tests of its parts plus a single composition test that verifies the wiring.
Consider an order enrichment pipeline:
// Each step tested independentlypublic static Result<Order, OrderError> parseOrder(String json) { ... }public static Result<Order, OrderError> validateOrder(Order order) { ... }public static Result<Order, OrderError> enrichOrder(Order order) { ... }public static Result<Order, OrderError> persistOrder(Order order) { ... }
// Composition is just wiringpublic Result<Order, OrderError> process(String rawJson) { return parseOrder(rawJson) .flatMap(OrderSteps::validateOrder) .flatMap(OrderSteps::enrichOrder) .flatMap(OrderSteps::persistOrder);}Testing process only needs to verify two things:
- Short-circuit behaviour — an error in any step propagates to the end without calling subsequent steps.
- Happy path — all steps succeed and produce the final order.
@Testvoid shouldShortCircuitOnParseFailure() { // Steps after parse are never called — no need to set them up Result<Order, OrderError> result = service.process("{ bad json }"); assertThat(result).isErr(); assertThat(result.getError()).isInstanceOf(OrderError.ParseFailure.class);}
@Testvoid happyPath_shouldProducePersistedOrder() { String validJson = """ {"customerId":"c1","productIds":["p1","p2"]} """; Result<Order, OrderError> result = service.process(validJson); assertThat(result).isOk(); assertThat(result.get().customerId()).isEqualTo("c1");}There are no mocks injected into process. The individual step tests cover all the edge cases. The composition test only verifies that the steps are connected correctly.
Property 5: Try Makes Exception-Throwing Code Trivially Testable
Not all code is written in a pure style from the start. When you must call a legacy API that throws checked exceptions, Try captures the exception as a value — and the test treats it like any other value.
// Wrapping a throwing legacy APIpublic Try<Report> generateReport(ReportRequest request) { return Try.of(() -> legacyReportEngine.generate(request));}@Testvoid shouldCaptureEngineException_asFailure() { ReportRequest badRequest = new ReportRequest(null); // triggers NPE in legacy code
Try<Report> result = service.generateReport(badRequest);
assertThat(result.isFailure()).isTrue(); assertThat(result.getCause()).isInstanceOf(NullPointerException.class);}
@Testvoid shouldReturnSuccess_forValidRequest() { ReportRequest request = new ReportRequest("2026-Q1");
Try<Report> result = service.generateReport(request);
assertThat(result.isSuccess()).isTrue(); assertThat(result.get().period()).isEqualTo("2026-Q1");}No assertThrows. No try/catch in the test. The exception is a value, and the test reads like every other value-based assertion.
Property 6: Validated Lets You Assert All Errors at Once
Form-validation logic written with Validated produces all errors in a single pass. The test can assert the complete set of errors without invoking the validator multiple times:
public Validated<List<String>, RegistrationForm> validate(RegistrationForm form) { Validated<List<String>, String> email = form.email().isBlank() ? Validated.invalid(List.of("email is required")) : Validated.valid(form.email());
Validated<List<String>, String> password = form.password().length() < 8 ? Validated.invalid(List.of("password must be at least 8 characters")) : Validated.valid(form.password());
Validated<List<String>, String> name = form.name().isBlank() ? Validated.invalid(List.of("name is required")) : Validated.valid(form.name());
BinaryOperator<List<String>> merge = (a, b) -> Stream.concat(a.stream(), b.stream()).toList();
return email .combine(password, merge, (e, p) -> e) .combine(name, merge, (ep, n) -> form);}@Testvoid shouldCollectAllErrors_whenAllFieldsAreInvalid() { RegistrationForm empty = new RegistrationForm("", "ab", "");
Validated<List<String>, RegistrationForm> result = validator.validate(empty);
assertThat(result.isInvalid()).isTrue(); assertThat(result.getError()).containsExactlyInAnyOrder( "email is required", "password must be at least 8 characters", "name is required" );}
@Testvoid shouldSucceed_whenAllFieldsAreValid() { RegistrationForm form = new RegistrationForm("alice@example.com", "secret123", "Alice");
assertThat(validator.validate(form).isValid()).isTrue();}A single call, a single assertion on the full error list. No loop, no incremental calls, no partial-state manipulation.
What About Side Effects?
Pure functions are easy to test precisely because they have no side effects. Real programs must eventually communicate with databases, message queues, and HTTP APIs. Does this mean FP helps only in the “pure core” and falls apart at the edges?
No — but it does shift the problem. The key is the functional core / imperative shell pattern:
- Core — domain logic is pure. All business rules, transformations, and validations live here as pure functions returning typed values. Zero mocks needed.
- Shell — infrastructure adapters (repositories, HTTP clients, event publishers) live here. They are thin, explicit, and tested with integration tests or test doubles applied at the interface boundary, not injected three layers deep.
┌─────────────────────────────────────┐│ Shell (side-effecting adapters) ││ ││ OrderRepository (DB) ││ PaymentGateway (HTTP) ││ EventBus (Kafka) ││ ││ ┌───────────────────────────────┐ ││ │ Core (pure domain logic) │ ││ │ │ ││ │ validateOrder() → Result │ ││ │ applyPricing() → Order │ ││ │ computeDiscount() → Discount │ ││ └───────────────────────────────┘ │└─────────────────────────────────────┘The core is tested with plain JUnit — no framework, no container. The shell is tested with integration tests that hit the real infrastructure. The two test suites remain small and focused because the boundary between them is explicit.
The Mock Count Heuristic
Here is a practical signal: count your mocks.
A test with three or more mocks is a sign that the code under test has three or more hidden dependencies. The mocks are not the problem — they are a symptom. The problem is that the function does not declare its dependencies in its signature.
With functional code, the trend runs in the opposite direction:
| Style | Typical mock count | What the mocks replace |
|---|---|---|
| Service + repo | 2–5 | DB, clock, event bus, config |
| Pure function | 0 | Nothing — all inputs are explicit |
| Functional + shell | 0–1 | One real adapter at the boundary |
The goal is not “zero mocks forever” — it is “mocks only at the true boundaries.” Pure functions at the core and thin adapters at the shell get you there.
Using dmx-fun Assertions in Tests
If you are using dmx-fun types in your production code, the companion fun-assertj module provides fluent assertions that eliminate the boilerplate of unwrapping:
// Without fun-assertjResult<User, String> result = service.register(form);assertThat(result.isOk()).isTrue();assertThat(result.get().email()).isEqualTo("alice@example.com");
// With fun-assertjassertThat(result).isOk().containsValue(expectedUser);
// For TryassertThat(Try.of(() -> parser.parse(input))) .isSuccess() .containsValue(expectedAst);
// For OptionassertThat(Option.some(42)) .isSome() .hasValueSatisfying(v -> assertThat(v).isGreaterThan(0));
// For Validated — full error listassertThat(validator.validate(invalidForm)) .isInvalid();The assertions follow the same fluent, chainable style as the rest of AssertJ, so they fit naturally into existing test suites.
Practical Checklist
When writing a new function in functional style, ask these questions before writing a single test:
- Are all inputs in the parameter list? If the function reads from a field, clock, or static, it is not pure — extract those dependencies to a parameter.
- Is every output in the return type? Exceptions, log entries, and mutations are hidden outputs. Replace them with typed return values.
- Is the function deterministic? Same arguments → same result, always. If not, isolate the non-determinism to the shell.
- Can failure happen? If yes, is it represented as
Result,Try, orValidated? Exceptions as control flow make tests fragile.
Answer “yes” to all four, and the test writes itself.
Conclusion
The reason testing functional code is often simpler is not a matter of style or testing philosophy — it is structural. Pure functions have explicit contracts. Immutable data has no state to corrupt. Typed errors document every outcome. Composition tests replace sprawling integration setups.
None of this prevents you from writing complex, hard-to-test functional code. But the functional style has a clear gravitational pull: if you follow it, the tests become easier; if the tests are hard, you have usually drifted back toward hidden state or hidden side effects.
The simplest test a function can have is assert f(input) == expected. Functional programming is, at its core, the discipline of making more of your functions look exactly like that.