Have you ever wondered what exactly to verify with MockMvc after implementing a file download API? Unlike APIs that return JSON, there are many things to check — Content-Disposition, binary bodies, asynchronous processing, Range requests, and more.

This article organizes verification approaches with MockMvc using examples like downloading after MultipartFile storage, StreamingResponseBody, and Excel/PDF/CSV export. It assumes you already understand the basics of using MockMvc. Code examples consistently use JUnit 5’s assertArrayEquals / assertEquals / assertTrue.

4 points to check when testing a file download API

Download-related tests boil down to these four points:

  • Response headers (Content-Disposition, Content-Type)
  • Response body (binary match)
  • Status code (usually 200, or 206 for partial retrieval)
  • Differences in assertions depending on the endpoint type (synchronous/asynchronous)

Verifying the Content-Disposition header

Whether it’s attachment or inline, and the encoding of the filename, are the points most prone to mistakes.

mockMvc.perform(get("/api/files/{id}", fileId))
    .andExpect(header().string(
        HttpHeaders.CONTENT_DISPOSITION,
        "attachment; filename=\"report.pdf\""
    ));

Japanese filenames take the RFC 5987 form (filename*=UTF-8''...), so you compare against the URL-encoded string.

String encoded = URLEncoder.encode("請求書.pdf", StandardCharsets.UTF_8).replace("+", "%20");

mockMvc.perform(get("/api/files/{id}", fileId))
    .andExpect(header().string(
        HttpHeaders.CONTENT_DISPOSITION,
        "attachment; filename*=UTF-8''" + encoded
    ));

This use of URLEncoder is a simplified approximation and doesn’t fully match the attr-char set defined in RFC 5987. If the implementation builds the header with ContentDisposition.builder(...), generating the expected value with the same API will save you from being tripped up by spec discrepancies. For endpoints that specify inline, also check that it isn’t actually returning attachment.

Verifying the Content-Type header

The Content-Type is fixed per file type.

.andExpect(content().contentType(MediaType.APPLICATION_PDF));

Many APIs return CSV with the character encoding included, like text/csv;charset=UTF-8, so using contentTypeCompatibleWith() instead of an exact match avoids being thrown off by notational variations.

Verifying the binary body with byte[]

The response body can be retrieved as a byte array. Place an expected-value file under src/test/resources and compare the entire content.

MvcResult result = mockMvc.perform(get("/api/files/{id}/download", fileId))
    .andExpect(status().isOk())
    .andReturn();

byte[] actual = result.getResponse().getContentAsByteArray();
byte[] expected = Files.readAllBytes(Path.of("src/test/resources/expected/sample.pdf"));

assertArrayEquals(expected, actual);

If you don’t want to keep an expected-value file around, another option is to store only a hash value and compare it against a SHA-256 computed with MessageDigest. However, this only works for files that always produce the same byte sequence from the same input.

Choosing between exact match, hash, and content-based verification

  • Exact match is for files that always produce the same byte sequence from the same input
  • Hash comparison is an alternative under the same conditions as exact match, when you don’t want to keep an expected-value file around
  • Content-based verification is for files like Excel/PDF that include timestamps and whose byte sequence changes on every run

Since a hash always changes whenever the byte sequence changes, it isn’t a solution for files containing timestamps. Use the content-based verification described in the Excel/PDF/CSV section below instead.

Testing downloads after MultipartFile storage

For endpoints that retrieve already-uploaded files, exclude the upload process itself from the test and instead set up an already-stored state before verifying.

@BeforeEach
void setUp() {
    given(fileStorageService.load("file-1"))
        .willReturn(new StoredFile("report.pdf", MediaType.APPLICATION_PDF_VALUE, sampleBytes));
}

@Test
void ダウンロードできる() throws Exception {
    mockMvc.perform(get("/api/files/file-1"))
        .andExpect(status().isOk())
        .andExpect(content().bytes(sampleBytes));
}

For a nonexistent file, confirm a 404 with status().isNotFound(). See this article on MultipartFile upload/download implementation for more details.

StreamingResponseBody requires accounting for asynchronous processing

StreamingResponseBody is processed asynchronously, so a plain assertion ends up with an empty body. Receive an MvcResult, then use asyncDispatch() to switch to the request after asynchronous processing completes.

MvcResult result = mockMvc.perform(get("/api/export/large-csv"))
    .andExpect(request().asyncStarted())
    .andReturn();

mockMvc.perform(asyncDispatch(result))
    .andExpect(status().isOk())
    .andExpect(content().contentTypeCompatibleWith(MediaType.valueOf("text/csv")));

Forgetting asyncDispatch() tends to result in “200 but an empty body,” so be sure to write this two-stage perform() call.

Excel/PDF/CSV exports each call for a different verification approach

Export-type APIs often can’t rely on exact-match verification, so choose the approach that fits each format.

For Excel, reload the response body with Apache POI and check the cell values. See also the Excel export implementation using Apache POI.

byte[] bytes = result.getResponse().getContentAsByteArray();
try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
    Sheet sheet = workbook.getSheetAt(0);
    assertEquals("氏名", sheet.getRow(0).getCell(0).getStringCellValue());
}

PDF generation implementations often embed timestamps, so limit verification to checking the magic bytes (%PDF). CSV export implementations can be verified directly as strings.

assertEquals("%PDF", new String(bytes, 0, 4, StandardCharsets.US_ASCII));

String csv = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertTrue(csv.startsWith("id,name,email\n"));

With UTF-8 that includes a BOM, the BOM remains at the start, so if startsWith fails, suspect this first.

Verifying Range requests and 206 Partial Content

If partial retrieval of large files is supported, also test requests with a Range header. Spring Boot doesn’t automatically support Range handling, so this test assumes it’s implemented on the Controller side.

MvcResult result = mockMvc.perform(
        get("/api/files/{id}/download", fileId).header(HttpHeaders.RANGE, "bytes=0-99")
    )
    .andExpect(status().isPartialContent())
    .andExpect(header().string(HttpHeaders.CONTENT_RANGE, "bytes 0-99/1024"))
    .andReturn();

byte[] partial = result.getResponse().getContentAsByteArray();
assertArrayEquals(Arrays.copyOfRange(fullBytes, 0, 100), partial);

It’s also a good idea to confirm a 416 with status().isRequestedRangeNotSatisfiable() when an invalid range is specified.

Summary

Download-related tests have more aspects to consider, which can feel confusing at first. But if you master these four patterns — exact match on Content-Disposition, byte[] comparison, asyncDispatch(), and the Range header — you can cover most cases.

Since asynchronous handling for StreamingResponseBody is easy to forget, it’s recommended to write a test for it as soon as you implement it.