Testing File Upload and Download Endpoints in Spring Boot
File upload and download endpoints sit quietly in most Spring Boot services until something goes wrong in production. A misplaced Content-Type header, a truncated byte stream, or a multipart boundary that fails under load can turn a routine integration into an incident worth waking the on-call engineer. For teams running customer-facing systems, getting these paths verified before merge is not a luxury. It is the cheapest place to catch regressions, especially when files travel through CDNs, virus scanners, or downstream archival queues.
Across the Australian tech scene, this kind of testing tends to surface early in any project that touches government integrations. The myGov platform and Services Australia handle sensitive attachments daily, and the ATO requires that any document exchange respects strict retention and audit rules. Sydney and Melbourne based teams often converge on the same patterns: a robust contract test for the endpoint, a separate end-to-end check, and a clean ownership boundary around the storage layer. These expectations are baked into how Australian banks like ANZ and NAB treat file flows, where a failed upload is rarely just a failed upload.
Most engineers reach for MockMvc first because it stays inside the servlet container and runs fast. Others prefer TestRestTemplate because it exercises the actual port and bypasses some mock layers. A smaller crowd swears by RestAssured when the surface area starts to look like a real client. The right answer depends on what you trust, what you have already wired up, and how close to production you need to stay. Each approach has trade-offs around multipart parsing, binary comparisons, and the realism of the request body.
This piece walks through the practical setup for testing multipart uploads, verifies download responses byte-for-byte, and covers the patterns that keep these tests stable across pipelines. Along the way, there are notes on what tends to break in CI, how to make assertions on large payloads without bloating the repository, and where flaky behaviour tends to creep in if you are not careful.
Wiring Up Spring Boot for Multipart Endpoints
A typical upload endpoint in Spring Boot uses @RequestPart or @RequestParam to bind incoming data, and a corresponding download method streams from a service-layer handle. The controller is usually thin, with the heavy lifting delegated to a storage abstraction that might point at S3, an on-prem share, or an internal object store. Testing starts with the controller, but the most valuable coverage lives one level down where the bytes meet the storage boundary.
For Australian teams building in the financial sector, this separation is often enforced through APRA-aligned architecture reviews. The controller never reads from disk directly; it receives a stream, hands it to a service, and returns a descriptor. When testing, that boundary becomes the seam you can mock cleanly. You verify the controller binds the parts correctly, then verify the service calls the storage adapter with the expected metadata.
A minimal setup in a @SpringBootTest slice uses MultipartFile or a MockMultipartFile to drive the upload. The test spins up the full application context, points the storage bean at an in-memory implementation, and posts a payload through TestRestTemplate. This gives you realistic HTTP behaviour without spinning up Tomcat in a way that surprises you later. For unit-only verification, MockMvc with MockMvcRequestBuilders.multipart is faster and avoids the port binding dance.
When the endpoint accepts metadata alongside the file, the test fixture should mimic that shape exactly. A real-world case might involve a customer uploading a payslip to a superannuation portal, where the multipart request includes a file part and a JSON part with a claim identifier. Treating these parts as one logical request, rather than two independent objects, makes the test resilient to refactors.
Verifying Upload Behaviour with MockMvc and TestRestTemplate
MockMvc is excellent for verifying that the controller correctly rejects oversized files, accepts allowed content types, and returns the expected status codes. Its strength is in the precise inspection of binding errors and validation messages, which matters when you want to assert that a 400 came from a missing part rather than a malformed header. The downside is that multipart parsing happens inside the mock layer, which can hide boundary issues that only surface when a real client posts the request.
TestRestTemplate closes that gap. It boots the embedded server on a random port, posts through the standard Servlet API, and exercises Jackson, validation, and resource handling exactly as production does. This is the layer where teams running AUSTRAC-aligned reporting often catch the issues that MockMvc misses, because the actual file becomes a stream with real headers and real boundaries.
A balanced approach uses both. MockMvc handles the dense logic checks: rejection of empty files, enforcement of MIME type, validation of metadata. TestRestTemplate handles the integration checks: confirming the controller correctly negotiates a 200 OK response, returns a stable download URL, and survives concurrent uploads. The two styles complement each other rather than compete, and most mature codebases keep a few tests in each camp.
For assertions, prefer checking the descriptor returned by the upload, then performing a separate download call to confirm the bytes survived the round trip. Avoid asserting against the raw MultipartFile inside the test, since that couples the test to the internal API of the controller. Use the public response shape and any downstream service calls as your contract.
| Approach | Best for | Trade-offs |
|---|---|---|
| MockMvc | Binding errors, validation, fast unit checks | May hide multipart boundary issues |
| TestRestTemplate | Real HTTP behaviour, port binding, full Spring stack | Slightly slower, requires embedded server |
| RestAssured | BDD-style specs, fluent assertions, external clients | Extra dependency, learning curve |
| WebTestClient (reactive) | WebFlux endpoints, streaming, backpressure | Different DSL, limited servlet usage |
Checking Download Responses and Binary Content
Download endpoints are deceptively simple. The controller looks up a descriptor, opens a stream, and writes bytes to the response with the right headers. The tests have to verify three things: that the headers are correct, that the body matches expectations, and that the resource was released cleanly. Skipping any of these gives you a green test and a production leak.
Header checks should confirm Content-Type, Content-Length or Content-Disposition, and any cache-control directives. For systems governed by the Privacy Act and the Notifiable Data Breaches scheme, Content-Disposition with a sanitised filename is not optional. The test should pin these values and fail if a refactor changes them silently. Australian government teams in particular tend to treat filename sanitisation as a contract, because a stray header can leak internal paths or user identifiers.
Body assertions are where most teams get tripped up. Comparing the downloaded stream against the uploaded fixture is the gold standard, but it requires the fixture to live somewhere stable. Some teams commit small reference files to the repository; others generate them on the fly with deterministic content. For larger payloads, hashing both sides with SHA-256 and comparing the digests is faster and avoids memory pressure. A Perth-based team I worked with kept a 5MB fixture in src/test/resources and verified it through SHA-256 to keep test times under a second.
Resource cleanup is the silent failure mode. If your download method opens a file handle and a test exception happens mid-response, the handle can leak. The simplest defence is to wrap the stream in a try-with-resources inside the controller, and the test should verify that the response is fully consumed even when the assertion fails. Using getResponseBodyAsByteArray() from TestRestTemplate, or andExpect(content().bytes(...)) in MockMvc, both consume the body and trigger close hooks.
Stable Patterns for File-Based Pipelines
Flaky file tests usually have one of three causes: nondeterministic content, oversize fixtures, or shared state between tests. Each one has a clean fix once you recognise the pattern.
Nondeterministic content shows up when tests write a File with System.currentTimeMillis() in the name, or generate a payload from a Random without a seed. Pin both. Use fixed seeds for randomness, and use a UUID only when the value flows through the system and the test asserts on the descriptor, not the underlying file name.
Oversize fixtures are the usual culprit behind slow CI. A 50MB reference file committed to Git will balloon clone times across a team spread between Brisbane, Sydney, and Melbourne, and will stress the pipeline runner. Generate larger fixtures in a @BeforeAll block using deterministic content, then assert against hashes rather than full bodies. The runner only carries the small generator and the hashes, not the payload itself.
Shared state between tests is the sneakiest. A test that writes to a shared in-memory store and forgets to clean up will fail on the second run in the same JVM, then pass in isolation. Use @DirtiesContext sparingly, prefer per-test fixtures, and reset any in-memory state through a @BeforeEach hook. When the storage abstraction is injected, swap it for a fresh in-memory implementation per test and let Spring rebuild the wiring.
A final pattern worth borrowing from Australian engineering culture is the blameless post-mortem applied to test suites. When a file test flakes, treat it the same way you would treat a production incident: capture the artefact, note the environmental difference, and decide on a fix in the open. This is exactly the discipline behind flaky test detection in CI pipelines, and it pays off the moment a real incident hits.
Recommendations for a Maintainable Test Suite
- Keep upload and download tests separate. Each one owns its own fixture, its own assertions, and its own cleanup.
- Pin
Content-TypeandContent-Dispositionto known values. Treat them as part of the public contract. - Use SHA-256 digests for large file comparisons, and assert against byte arrays only when the size is bounded.
- Combine MockMvc with TestRestTemplate rather than choosing one. They cover different layers.
- Generate oversize fixtures at runtime rather than committing them to the repository.
The fastest way to make progress is to pick one existing upload endpoint in your codebase, write the MockMvc and TestRestTemplate pair against it today, and run both in your local CI before merging the next change.