1. Multipart as a separate contract
Multipart endpoints often break not because “Spring is bad”, but because their contract literally consists of several independent parts. It is like sending a package where one parcel contains a file and another contains a note with the description, and the courier (Spring MVC) very strictly waits for both parcels with the correct labels. If you mix up part names or types, everything falls apart even before the business logic.
With a regular JSON endpoint, everything is relatively straightforward: one Content-Type: application/json, one body, one @RequestBody. Multipart is multipart/form-data and a set of parts. Each part has a name (for example, "file" or "metadata"), and it also has its own Content-Type. And if you think, “well, it is just one request”, then Spring thinks differently: “these are two (or more) independent fragments of data that need to be parsed and bound to method parameters in different ways”.
Another reason why multipart needs to be tested separately: it is very easy to accidentally “fix” an endpoint in Postman and then realize that the tests cannot reproduce the same request. Or vice versa: the test somehow assembled multipart, but the client (Postman/Frontend) sends it differently, and you get a surprise in the form of 400 Bad Request because a required part is missing.
So the logic here is simple and fairly mature: if multipart is a separate contract form, then we must have separate web-layer tests that verify exactly the multipart contract, not just “something about attachments in general”.
Before this, we were running regular JSON endpoints through the same MVC slice. File scenarios do not require a different test mode: only the contract form changes. Instead of one body, parts appear; instead of plain JSON, bytes and headers sometimes travel; but we still verify the same external HTTP response that the client sees.
2. The upload endpoint contract: parts and response
Before writing the test, it is useful to pause for a minute and honestly state the upload endpoint contract in words. In the Task Tracker API, the attachment is a task subresource, and upload happens through POST /api/v1/tasks/{taskId}/attachments. The multipart request contains at least two parts: a binary file (file) and JSON metadata (metadata). And that “at least” is not a stylistic phrase, but part of the agreement with the client.
Imagine the request as a “pseudo-contract” (not RFC, just a clear picture):
POST /api/v1/tasks/{taskId}/attachments
Content-Type: multipart/form-data
Part "metadata" (Content-Type: application/json):
{"description":"Sprint note"}
Part "file" (Content-Type: text/plain):
<bytes of note.txt>
At the Spring MVC level, this is usually expressed like this: @RequestPart("metadata") for the JSON part and @RequestPart("file") for the file. Note: we use @RequestPart specifically, not @RequestParam, because a “multipart part” is not a query parameter and not just a “string in a form”, but a separate chunk of the body.
Mini example of a controller method signature (the logic inside is intentionally as simple as possible):
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
class AttachmentController {
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
AttachmentResponse upload(@PathVariable String taskId,
// Multipart JSON part: Spring reads the bytes and converts them into a DTO via Jackson
@RequestPart("metadata") @Valid AttachmentUploadMetadataRequest metadata,
// Multipart file part: binary content + file name + content type arrive here
@RequestPart("file") MultipartFile file) {
// In a web-layer test, we verify that the controller correctly accepts parts and returns an HTTP response.
return attachmentService.upload(taskId, metadata, file);
}
}
In the response, we expect 201 Created and JSON with attachment metadata (AttachmentResponse). The client usually needs at least the attachment identifier and the original file name, and yes, this is one of those cases where the “file name” is truly part of the contract, not just aesthetics.
If you are thinking right now: “Seriously? We are testing the part names "metadata" and "file"?” — yes, seriously. For the client, this is roughly the same as a JSON field name. Renaming a part is almost like renaming title to taskTitle: it is not hard for the server, but it is pain for the client.
3. Building a multipart request in MockMvc
Now to the practical part: how to reproduce a multipart request in MockMvc. There is a small engineering joy here: you do not need a real file on disk and you do not need a real browser. Spring provides MockMultipartFile, which can be created from a byte array by setting the part name, original file name, and Content-Type. It is a kind of “test sandwich”: not a real lunch, but very similar in composition, and the controller’s stomach will digest it.
Important thought: the metadata part is also a MockMultipartFile. Yes, it sounds a little strange (“JSON as a file?”), but technically a multipart part is a set of bytes. If you want Spring to read it through Jackson and build your AttachmentUploadMetadataRequest, you must give the part the correct Content-Type: application/json and valid JSON inside.
In practice it is convenient to have an ObjectMapper in the test, so you do not have to glue JSON manually and do not create a “quote-mark quest” for yourself. Mini example of creating parts:
import static java.nio.charset.StandardCharsets.UTF_8;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.mock.web.MockMultipartFile;
// File part: part name = "file" (must match @RequestPart("file"))
MockMultipartFile file = new MockMultipartFile(
"file",
"note.txt", // original file name (part of the contract)
"text/plain", // file content type
"hello".getBytes(UTF_8)
);
// JSON part: technically this is also a set of bytes, just with content type application/json
MockMultipartFile metadata = new MockMultipartFile(
"metadata",
"", // original file name for the JSON part is usually not needed
"application/json",
// Generate JSON via ObjectMapper to avoid mistakes with quotes/escaping
objectMapper.writeValueAsBytes(new AttachmentUploadMetadataRequest("Sprint note"))
);
Notice a couple of details. The part name "file" and "metadata" must match what the controller expects. The second argument (original file name) for metadata can be left empty — it is not a file in the user sense. And one more thing: multipart(...) in MockMvc will set the overall Content-Type: multipart/form-data with a boundary by itself, you do not need to invent boundaries by hand like in student networking labs.
If you want to show very briefly what is happening, you can think about it like this:
flowchart LR
T[Test] --> M[MockMvc multipart request]
M --> D[DispatcherServlet]
D --> C[AttachmentController]
C --> S["AttachmentService (mock)"]
S --> C --> M --> A[Assertions]
That is why in a multipart test we focus on what comes into the controller and what goes out of the controller, not on how the file is “actually saved to disk”.
4. Happy-path test: upload and 201 Created
A happy-path test for an upload endpoint is your contract “anchor”. It should show the future reader (and your future “me-from-the-past”) exactly what we consider a correct multipart request and what a successful response should look like. And yes, it is useful to be a little picky here: if you do not verify at least the key response fields, the test turns into a “status check for the sake of status”.
Let us start with the test skeleton. In a real project it looks roughly like this: @WebMvcTest(AttachmentController.class), MockMvc, ObjectMapper, and a mocked service. Example scaffold (very minimal):
@WebMvcTest(AttachmentController.class)
class AttachmentControllerWebMvcTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
// The service is mocked: the web-layer test checks the HTTP contract, not the business logic
@MockitoBean AttachmentService attachmentService;
}
Now the happy path. We preconfigure the service (stubbing) so that it returns a previously known AttachmentResponse. Then we assemble multipart and verify the response contract.
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
@Test
void uploadAttachment_returns201AndMetadata() throws Exception {
// Configure the service mock: the controller should simply pass through the input data and return a response
when(attachmentService.upload(eq("task-1"), any(), any()))
.thenReturn(new AttachmentResponse("att-1", "note.txt")); // fields shortened
// Multipart file part
MockMultipartFile file = new MockMultipartFile(
"file",
"note.txt",
"text/plain",
"hello".getBytes(UTF_8)
);
// Multipart JSON part: content type is crucial, otherwise Spring/Jackson may not parse the DTO
String metadataJson = """
{"description":"Sprint note"}
""";
MockMultipartFile metadata = new MockMultipartFile(
"metadata",
"",
"application/json",
metadataJson.getBytes(UTF_8)
);
mockMvc.perform(
multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
// Important: part name must match @RequestPart("file") / @RequestPart("metadata")
.file(file)
.file(metadata)
)
// Verify the basic contract: status + JSON
.andExpect(status().isCreated())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
// Verify the key response field relied on by the client
.andExpect(jsonPath("$.originalFileName").value("note.txt"));
}
Here it is important that the test does not check “everything at once”. It fixes the endpoint’s basic promise: a successful upload returns 201, the response is JSON, and the JSON contains at least originalFileName, matching what we sent. In a real project you would of course add checks for id, contentType, size, uploadedAt, but the approach remains the same: we verify the public contract, not the internal implementation.
If your upload endpoint adds Location, that is an excellent candidate for verification: andExpect(header(...)). But do not turn the test into a 40-page novel: better several short tests than one “epic about everything”.
5. Negative-path tests: missing part, 415, 409
In the multipart world, negative-path scenarios are especially important because some errors happen before the service, and some happen inside the business logic. And if you throw all of this into one pile, you will get “sometimes it is 400, sometimes 415, and nobody knows why”. A mature API must be predictable: the same class of error should produce the same status and the same ProblemDetail format.
The most mandatory negative multipart case is the absence of a required part. For example, the client sent file, but forgot metadata. For us, this is not “okay, let us upload without a description”, because according to the contract the part must be present. And the test must record that.
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
@Test
void uploadAttachment_withoutMetadata_returns400Problem() throws Exception {
// Send only the file part: intentionally violate the contract (missing required metadata)
MockMultipartFile file = new MockMultipartFile(
"file",
"note.txt",
"text/plain",
"hi".getBytes(UTF_8)
);
mockMvc.perform(
multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
.file(file)
)
// Expect a predictable web-layer error
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
// Verify the machine-readable code (not the text detail)
.andExpect(jsonPath("$.code").value("INVALID_INPUT"));
}
Such a request breaks at the web-layer level: the required part is missing, so the business logic is never reached. And this is the same style of errors as with regular JSON endpoints: application/problem+json, machine-readable code, and no separate “file-format” for failures.
That is why it is logical to verify code, not the entire detail text. code is the machine-readable part of the contract. The text may change, be localized, or be clarified, and that is normal.
For the Task Tracker API, it is convenient to keep the following canon:
if the required metadata part is missing or the JSON metadata cannot be bound into a DTO, expect 400 INVALID_INPUT;
if the file type is not supported by the API policy, expect 415 UNSUPPORTED_FILE_TYPE;
if upload is forbidden by a domain rule, for example for an archived task, expect 409 FILE_UPLOAD_NOT_ALLOWED;
if the parent task or the attachment itself is not found, expect 404 TASK_NOT_FOUND / ATTACHMENT_NOT_FOUND.
That way, the negative-path for attachments remains predictable: some errors are born in the MVC pipeline, some in the service, but from the outside the client still sees the same ProblemDetail format.
The key point of this section: a multipart endpoint should be just as “grown-up” in its errors as a regular JSON endpoint. It simply has more reasons to fail, but the error contract must still remain unified and machine-readable.
6. Download endpoint: headers and bytes
A download endpoint is almost the opposite of an upload endpoint. If upload is a “complex input”, then download is a “strict output”. The client comes for binary data, and if you forget the headers, the browser may start showing garbage, download a file named download without an extension, or not understand what you returned at all. So here you need to test more than just the status.
For GET /api/v1/tasks/{taskId}/attachments/{attachmentId}/download, the basic contract usually includes 200 OK, a correct Content-Type (or fallback application/octet-stream) and Content-Disposition, so the browser/client downloads the file with the right name. And, of course, the response body must contain the file bytes.
In @WebMvcTest we do not read a real file. We do it more simply and more honestly: the service (mock) returns a known set of bytes and metadata, and the controller forms the correct HTTP response. Mini example:
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
@Test
void downloadAttachment_setsHeadersAndBody() throws Exception {
// Mock the service: it returns the file name, content type, and bytes
when(attachmentService.download("task-1", "att-1"))
.thenReturn(new AttachmentContent(
"note.txt",
"text/plain",
"hello".getBytes(UTF_8)
)); // hypothetical DTO
mockMvc.perform(get("/api/v1/tasks/{t}/attachments/{a}/download", "task-1", "att-1"))
// Status: successful file delivery
.andExpect(status().isOk())
// Header that determines browser/client behavior when downloading
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"note.txt\""))
// Content: the bytes must match exactly
.andExpect(content().bytes("hello".getBytes(UTF_8)));
}
Yes, here we have a “hypothetical DTO” AttachmentContent — it is just an illustration that the service must return three things to the controller: file name, content type, and bytes. In your real project, this may be a record, a domain model, or a separate response object, but the controller still needs to assemble the HTTP response from it.
Pay attention to Content-Disposition. It is easy to make a mistake with quotes and spaces. And this is exactly the case where the test saves you from “well, it seems to work”. Because “seems to work” usually ends at the first client that downloads a file named note.txt"; rm -rf /… Okay, joke. (Almost.) But headers really do need to be formed carefully.
7. Final web-layer suite audit: coverage map
A final test audit is not a count of methods with @Test. If you simply stuffed in 50 tests, the API did not become better. The quality of a web-layer suite is determined by whether it protects public HTTP behavior: statuses, headers, JSON shape, and error shape. In other words, if tomorrow someone “slightly” changes the controller, your tests should raise an alarm exactly where the contract actually breaks.
It is convenient to do the audit through a “coverage map”. For example, in the form of a table where each API area has at least one happy-path and at least one representative negative-path. It is not necessary to test every microscopic branch of logic — that is already the service layer. But the web contract must be protected.
Below is an example of such a map for the canonical Task Tracker API (as a reference, not as a bureaucratic document):
| API area | What we consider the basic happy path | What we consider the basic negative path | What we look at in the response |
|---|---|---|---|
| tasks (list/detail/create/update/delete) | GET /tasks returns the PagedResponse envelope, POST /tasks returns 201 | validation 400, malformed JSON 400, TASK_NOT_FOUND 404, conflict 409 | status, Content-Type, key JSON fields, Location for create (if present), ProblemDetail.code |
| comments as a subresource | POST /tasks/{id}/comments returns 201 | TASK_NOT_FOUND 404, validation 400 | status, JSON shape, ProblemDetail on error |
| attachments (upload/list/detail/download/delete) | upload 201, download 200 with headers | missing part 400, upload forbidden 409, unsupported type 415, not found 404 | status, Content-Type, Content-Disposition for download, ProblemDetail.code |
And now the main point of the audit: you can see that multipart and download are on equal footing with the other areas here. This is not an “optional feature”. It is part of your public contract. If you do not test them, you leave a large piece of the API without insurance.
There is another important rule for a good final set: the tests must be readable. If your multipart tests have turned into 60 lines of builder chains and JSON copy-paste, you will start to fear them. And when tests are feared, they are not fixed — they are disabled. So keep tests small, scenario-based, and as “client-like” as possible in style: I read the URI, I see the method, I see the expected status, and I see the key response fields.
8. Typical mistakes when testing multipart and file endpoints
Finally, let us record the rakes almost everyone steps on, including people who have “seen everything already”. Multipart and download tests look simple, but these are exactly where the most annoying bugs live, the kind that make you say, “well, why is it 400, I sent everything”.
Mistake #1: mixing up part names and testing the “wrong contract”.
A multipart request stands on part names. If the controller expects @RequestPart("metadata"), but in the test you sent "meta", Spring will not “guess” what you meant. You will get 400. This is not a whim, this is the contract. Therefore, in tests, part names should be as visible as the URI and the method.
Mistake #2: sending JSON metadata without Content-Type: application/json.
If the metadata part has text/plain, Jackson is not required to try to read it as JSON. As a result, instead of a nice AttachmentUploadMetadataRequest, you get a conversion error or a missing-part scenario. In the test, always set application/json explicitly for the JSON part, otherwise you are testing not your API but randomness.
Mistake #3: trying to test the real file system through @WebMvcTest.
@WebMvcTest is a web-contract test, not a storage integration test. As soon as you start creating real files, you drag in environment instability and side effects. The file system (if you really need it) is tested at another level. Here, the service is mocked — and that is normal, even if your soul asks for “realness”.
Mistake #4: checking download only by status and ignoring the headers.
A download endpoint without Content-Disposition is like “pizza without a box”: formally the food is there, but after that it is inconvenient and messy. Client behavior depends on headers, so the test must verify Content-Type and Content-Disposition. Otherwise you can easily “break” downloading without noticing.
Mistake #5: in negative-path tests, tying yourself to the detail text instead of code and status.
Error text can be improved and changed, that is normal. If your tests check “exactly this phrase”, you are cutting off your own oxygen for message improvements. It is much more stable to verify status, contentTypeCompatibleWith(application/problem+json), and $.code, and for validation — also the shape of fieldErrors.
GO TO FULL VERSION