Testing Composed Functions
Composing an endpoint from small functions has a direct payoff for testing. Each function is a plain object with one method, so most of the logic is tested by calling that method. No server, no Spring context, no HTTP. A thin layer of integration tests then proves the wiring.
This is a testing pyramid. Many fast unit tests cover the logic inside the functions. A few slower integration tests cover the parts only the running application can show: validation, escalation routing, and function order.
This tutorial reuses the update pipeline from the CRUD Orchestration tutorial: validate, load, apply, save, respond.
Unit testing a function
A function takes its inputs as method parameters and its dependencies by injection. A unit test supplies those directly. Two small helpers from OfficeFloor stand in for the framework pieces:
- MockVar is a variable. Pass it where a function expects an
Out, then read what was published withget(). It ships with the OfficeFloor compiler, already on the test classpath. - MockObjectResponse captures the response. Pass it where a function expects an
ObjectResponse, then read what was sent withgetObject(). Add thewoof_testdependency for it:<!-- Provides MockObjectResponse for unit testing responder functions. MockVar (for Out/@Val) ships with the OfficeFloor compiler already on the classpath. --> <dependency> <groupId>net.officefloor.web</groupId> <artifactId>woof_test</artifactId> <scope>test</scope> </dependency>
The functions differ in what they need, and the tests show the range.
ApplyArticleis pure. It takes two values and mutates one. No helpers, no doubles.RespondWithArticletakes anObjectResponse. Capture it withMockObjectResponse.LoadArticletakes a repository and anOut. Stub the repository with Mockito and capture the published entity withMockVar. Its 404 branch is a thrown exception, asserted directly.SaveArticletakes a repository. Verify it persisted the entity./** * Unit tests for individual functions. No Spring context, no server, no HTTP. * Each function is a plain object: construct it, call the one method, assert. * These run in milliseconds. */ public class ArticleFunctionUnitTest { // A pure function: no dependencies at all. Just logic over two values. @Test public void applyArticle_copiesFieldsOntoTheEntity() { Article article = new Article(1L, "old", "old body"); ArticleRequest request = new ArticleRequest("new", "new body"); new ApplyArticle().service(article, request); assertEquals("new", article.getTitle()); assertEquals("new body", article.getContent()); } // A responder: capture what it sends with the framework's MockObjectResponse. @Test public void respondWithArticle_mapsEntityToDto() { Article article = new Article(7L, "Title", "Body"); MockObjectResponse<ResponseEntity<ArticleResponse>> response = new MockObjectResponse<>(); new RespondWithArticle().service(article, response); ArticleResponse dto = response.getObject().getBody(); assertEquals(7L, dto.getId()); assertEquals("Title", dto.getTitle()); } // A producer: MockVar captures the published variable; a stubbed repository // stands in for the collaborator. @Test public void loadArticle_publishesTheEntity() throws Exception { Article stored = new Article(3L, "Stored", "body"); ArticleRepository repository = mock(ArticleRepository.class); when(repository.findById(3L)).thenReturn(Optional.of(stored)); MockVar<Article> loaded = new MockVar<>(); new LoadArticle().service(3L, repository, loaded); assertSame(stored, loaded.get()); } // The 404 branch is just a thrown exception. Assert it directly. @Test public void loadArticle_throwsWhenMissing() { ArticleRepository repository = mock(ArticleRepository.class); when(repository.findById(99L)).thenReturn(Optional.empty()); assertThrows(NotFoundException.class, () -> new LoadArticle().service(99L, repository, new MockVar<>())); } // An action: verify it persisted the entity it was given. @Test public void saveArticle_persists() { Article article = new Article(5L, "T", "B"); ArticleRepository repository = mock(ArticleRepository.class); new SaveArticle().service(article, repository); verify(repository).save(article); } }
None of these start a server. The whole class runs in a few milliseconds. A function's inputs and outputs are explicit in its signature, so there is nothing to stand up and nothing to tear down.
What a unit test cannot prove
Some behaviour is not inside any one function. It is in how the framework runs them.
@Validis applied by the framework when it binds the request body, not by calling the method. CallingValidateArticle.service(...)directly does not validate anything.- The order of the functions is declared in the YAML. A unit test calls one function, so it cannot show that
validateruns beforeload. - An escalation is routed to its handler by the framework. A unit test sees the thrown exception, not the 404 response.
These are integration concerns. One test class covers them by driving the running application with MockMvc, exactly as the other tutorials do.
/**
* One integration test for what the unit tests cannot cover: the wiring.
* The order of the functions, the @Valid binding, and the escalation routing
* are framework behaviour, so they are proven through the running application.
*/
@SpringBootTest
@AutoConfigureMockMvc
public class ArticleEndpointIntegrationTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private ArticleRepository repository;
@AfterEach
public void clearData() {
repository.deleteAll();
}
// The functions run in the order the YAML declares: validate, load, apply, save, respond.
@Test
public void update() throws Exception {
Article saved = repository.save(new Article(null, "Before", "old"));
mvc.perform(put("/article/" + saved.getId())
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("After", "new"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("After"));
}
// Validate runs before Load. An invalid body against a missing id is a 400,
// not a 404. A unit test cannot show this: @Valid is applied by the framework,
// not by calling the method.
@Test
public void invalidBodyIsRejectedBeforeTheLoad() throws Exception {
mvc.perform(put("/article/99999")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("", "new"))))
.andExpect(status().isBadRequest());
}
// A missing article escalates NotFoundException to the 404 handler.
@Test
public void missingArticleIsNotFound() throws Exception {
mvc.perform(put("/article/99999")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("Valid", "body"))))
.andExpect(status().isNotFound());
}
}
invalidBodyIsRejectedBeforeTheLoad is the case that justifies the split. An invalid body against a missing id returns 400, not 404, which proves both the validation binding and the function order. No unit test can show it, and it is one small integration test.
The payoff
The logic lives in functions that are tested in isolation, fast, with no framework to start. The wiring is tested once, through the running application. Adding a function adds a unit test for its logic, not another slow end-to-end test. Small composed functions make the common case cheap to test.
Next
The Orchestration Patterns and Naming reference collects the function naming conventions and the data-flow rules used across these tutorials.

