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.

Tutorial Source

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 with get(). 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 with getObject(). Add the woof_test dependency 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.

  • ApplyArticle is pure. It takes two values and mutates one. No helpers, no doubles.
  • RespondWithArticle takes an ObjectResponse. Capture it with MockObjectResponse.
  • LoadArticle takes a repository and an Out. Stub the repository with Mockito and capture the published entity with MockVar. Its 404 branch is a thrown exception, asserted directly.
  • SaveArticle takes 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.

  • @Valid is applied by the framework when it binds the request body, not by calling the method. Calling ValidateArticle.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 validate runs before load.
  • 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.