ファイルダウンロードAPIを実装したあと、MockMvcで何をどう検証すればいいか迷ったことはありませんか。JSONを返すAPIと違い、Content-Disposition やバイナリボディ、非同期処理、Rangeリクエストなど確認点が多いからです。

この記事では、MultipartFile保存後のダウンロード、StreamingResponseBody、Excel/PDF/CSVエクスポートを例にMockMvcでの検証方法を整理します。MockMvcの基本的な使い方は理解している前提で進めます。コード例はJUnit 5の assertArrayEquals / assertEquals / assertTrue で統一しています。

ファイルダウンロードAPIのテストで確認すべき4つのポイント

ダウンロード系のテストは次の4点に集約されます。

  • レスポンスヘッダー(Content-Disposition, Content-Type)
  • レスポンスボディ(バイナリの一致)
  • ステータスコード(通常200、部分取得なら206)
  • エンドポイントの種類(同期/非同期)によるアサーションの違い

Content-Dispositionヘッダーを検証する

attachment か inline か、ファイル名のエンコードが一番事故りやすいポイントです。

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

日本語ファイル名はRFC 5987形式(filename*=UTF-8''...)になるので、URLエンコード後の文字列で比較します。

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
    ));

この URLEncoder は簡易的な近似で、RFC 5987のattr-char集合とは完全には一致しません。実装が ContentDisposition.builder(...) で組み立てている場合は、期待値も同じAPIで生成すると仕様のズレに振り回されずに済みます。inline指定のエンドポイントでは attachment になっていないかも確認します。

Content-Typeヘッダーを検証する

Content-Typeはファイル種別ごとに固定です。

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

CSVは text/csv;charset=UTF-8 のように文字コード込みで返るAPIも多いので、完全一致ではなく contentTypeCompatibleWith() を使うと表記ゆれに振られません。

バイナリボディをbyte[]で一致検証する

レスポンスボディはバイト配列で取得できます。src/test/resources に期待値ファイルを置き、丸ごと比較します。

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);

期待値ファイルを置きたくない場合は、ハッシュ値だけを持っておき MessageDigest で計算したSHA-256と比較する方法もあります。ただし同じ入力から常に同じバイト列が生成されるファイルでしか使えません。

完全一致・ハッシュ・内容ベース検証の使い分け

  • 完全一致 は、同じ入力から常に同じバイト列が生成されるファイル向けです
  • ハッシュ比較 は、完全一致と同じ条件で期待値ファイルを置きたくないときの代替です
  • 内容ベースの検証 は、タイムスタンプを含むExcel/PDFなど実行のたびにバイト列が変わるファイル向けです

ハッシュはバイト列が変われば必ず変わるため、タイムスタンプ入りファイルの解決策にはなりません。次のExcel/PDF/CSVセクションの内容ベースの検証を使いましょう。

MultipartFile保存後のダウンロードをテストする

アップロード済みファイルを取得するエンドポイントは、アップロード処理自体はテスト対象外とし、保存済みの状態を用意してから検証します。

@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));
}

存在しないファイルは status().isNotFound() で404を確認します。MultipartFileのアップロード・ダウンロード実装はこちらの記事を参照してください。

StreamingResponseBodyは非同期処理を考慮する

StreamingResponseBody は非同期に処理されるため、普通にアサーションするとボディが空のまま終わります。MvcResult を受け取り、asyncDispatch() で非同期処理完了後のリクエストに差し替えます。

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")));

asyncDispatch() を忘れると「200なのにボディが空」になりがちなので、必ずこの2段階の perform() を書きましょう。

Excel/PDF/CSVエクスポートはそれぞれ向く検証方法が違う

エクスポート系は完全一致検証が効かないことが多いので、形式ごとに適した方法を選びます。

ExcelはレスポンスボディをApache POIで再読み込みしてセルの値を確認します。Apache POIによるExcelエクスポート実装も参考にしてください。

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生成の実装はタイムスタンプを埋め込むことが多く、マジックバイト(%PDF)の確認に留めます。CSVエクスポートの実装は文字列としてそのまま検証できます。

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"));

BOM付きUTF-8だと先頭にBOMが残るので、startsWith で引っかかったらまず疑ってみてください。

Rangeリクエストと206 Partial Contentを検証する

大きいファイルの部分取得に対応している場合は、Range ヘッダー付きのリクエストも検証します。Spring Bootは自動でRangeに対応してくれるわけではないので、Controller側で実装している前提のテストです。

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);

範囲指定が不正な場合は status().isRequestedRangeNotSatisfiable() で416を確認しておくと安心です。

まとめ

ダウンロード系のテストは観点が増える分、最初は迷いやすいところです。ただContent-Dispositionの完全一致、byte[] 比較、asyncDispatch()、Rangeヘッダーの4パターンを押さえれば大半のケースはカバーできます。

StreamingResponseBodyの非同期対応は忘れがちなので、実装したら真っ先にテストを書いておくのがおすすめです。