multipart/form-data のファイルアップロードをテストする

curl、JavaScript、Python、C# でファイルをアップロードし、サーバーが受け取った内容を正確に確かめ、boundary のミスを避けます。

例を API に対して最後に実行した日:

ファイルアップロードをテストするには、サーバーが実際に何を受け取ったか、つまりどのフィールドとファイルが、どの名前で、 どのコンテンツタイプとサイズで届いたかを見る必要があります。https://api.testingapis.com の POST /api/echo は multipart/form-data のリクエストを解析し、その結果を JSON で返します。無料で、キーも 不要、何も保存せず、どのオリジンからのリクエストも受け付けるので、ブラウザからも使えます。以下では、curl、JavaScript、 Python、C# で動くアップロード、それぞれでアップロードを壊すミス、xUnit のテストを紹介します。

curl でのマルチパートアップロード

-F を付けると curl は multipart/form-data を送ります。name=value はテキストフィールド、name=@path はファイルの添付で、;type= でそのコンテンツタイプを指定します。;type= がないと、curl は application/octet-stream を送ります。レスポンスにはほかのリクエスト情報も含まれますが、ここでは jq で大事な 2 つの部分だけを残しています。

Terminal
$ printf 'id,name\n1,Alice\n2,Bob\n' > report.csv
$ curl -s https://api.testingapis.com/api/echo \
    -F "title=Quarterly report" \
    -F "file=@report.csv;type=text/csv" | jq '{form, files}'
{
  "form": {
    "title": "Quarterly report"
  },
  "files": [
    {
      "field": "file",
      "fileName": "report.csv",
      "contentType": "text/csv",
      "size": 22
    }
  ]
}
POST/api/echo
title=Quarterly report

JavaScript:fetch と FormData

FormData を作り、フィールドとファイルを追加して、ボディとして渡します。同じコードがブラウザ(readFile の代わりに <input type="file"> のファイルを使う)でも Node 18 以降でも動きます。

upload.mjs
import { readFile } from "node:fs/promises";

const form = new FormData();
form.append("title", "Quarterly report");
form.append("file", new Blob([await readFile("report.csv")], { type: "text/csv" }), "report.csv");

// No Content-Type header: fetch adds "multipart/form-data; boundary=..." itself.
const res = await fetch("https://api.testingapis.com/api/echo", { method: "POST", body: form });
const { form: fields, files } = await res.json();
console.log(fields, files);
出力(Node 24)
{ title: 'Quarterly report' } [
  {
    field: 'file',
    fileName: 'report.csv',
    contentType: 'text/csv',
    size: 22
  }
]

Python:requests

テキストフィールドは data に、ファイルは files に入れます。各ファイルにはファイル名、ファイルオブジェクト、コンテンツタイプのタプルを渡してください。ファイルオブジェクト だけを渡すと、コンテンツタイプが抜けます。

upload.py
import requests

with open("report.csv", "rb") as f:
    res = requests.post(
        "https://api.testingapis.com/api/echo",
        data={"title": "Quarterly report"},
        files={"file": ("report.csv", f, "text/csv")},  # (file name, file, content type)
    )

body = res.json()
print(body["form"], body["files"])
出力
{'title': 'Quarterly report'} [{'field': 'file', 'fileName': 'report.csv', 'contentType': 'text/csv', 'size': 22}]

C#:MultipartFormDataContent

テキストフィールドごとに StringContent を、ファイルごとに StreamContent(または ByteArrayContent)を追加します。パートをファイルにするのはファイル名の引数です。これを省くと、ファイルのバイトは 普通のテキストフィールドとして届きます。

Program.cs
using System.Net.Http.Headers;
using System.Text.Json;

using var http = new HttpClient();
using var form = new MultipartFormDataContent();

form.Add(new StringContent("Quarterly report"), "title");

var file = new StreamContent(File.OpenRead("report.csv"));
file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
form.Add(file, "file", "report.csv"); // the third argument, the file name, makes it a file

using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(echo.GetProperty("form"));
Console.WriteLine(echo.GetProperty("files"));
出力(.NET 10)
{"title":"Quarterly report"}
[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22}]

アップロードを壊すミス

どの行も /api/echo に送ったもので、最後の列がサーバーの解析結果です。

コードがしたことライブラリサーバーが受け取ったもの
Content-Type ヘッダーを手で multipart/form-data に設定したfetch、requests、HttpClient(フォームの Content-Type を置き換えた場合)何もなし:Missing content-type boundary. パートを区切る boundary が消えている
ファイル名を省いたHttpClient(form.Add(content, name))ファイルの中身がテキストフィールドとして届き、ファイルはなし
ファイル名を省いたfetch(名前のない Blob)blob という名前のファイル
コンテンツタイプを省いたcurl、requests(ファイルオブジェクトだけ)、HttpClientcurl は application/octet-stream、requests と HttpClient は空のコンテンツタイプ
files= なしで data= を使ったrequestsマルチパートですらない:application/x-www-form-urlencoded

いちばん多いのは boundary のミスです。サーバーにはこう見えます。

JSON
{
  "form": { "parseError": "Missing content-type boundary." },
  "files": null,
  "contentType": "multipart/form-data"
}

アップロードの xUnit テスト

リクエストが 200 を返したことだけでなく、サーバーに何が届いたかを検証します。.NET 10 上の xUnit 2.9 で通ります。

UploadTests.cs
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Xunit;

public class UploadTests
{
    [Fact]
    public async Task The_file_arrives_with_its_name_type_and_size()
    {
        var bytes = Encoding.UTF8.GetBytes("id,name\n1,Alice\n2,Bob\n");
        using var http = new HttpClient();
        using var form = new MultipartFormDataContent();
        form.Add(new StringContent("Quarterly report"), "title");
        var file = new ByteArrayContent(bytes);
        file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
        form.Add(file, "file", "report.csv");

        using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
        response.EnsureSuccessStatusCode();
        var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;

        Assert.Equal("Quarterly report", echo.GetProperty("form").GetProperty("title").GetString());
        var sent = Assert.Single(echo.GetProperty("files").EnumerateArray());
        Assert.Equal("file", sent.GetProperty("field").GetString());
        Assert.Equal("report.csv", sent.GetProperty("fileName").GetString());
        Assert.Equal("text/csv", sent.GetProperty("contentType").GetString());
        Assert.Equal(bytes.Length, sent.GetProperty("size").GetInt32());
    }
}

チェックサムと本格的なアップロード用エンドポイント

サインインしたメンバー向けのエンドポイントがあと 2 つあります。POST /api/bodies/formサインインして表示 は同じフィールドとファイルに加えて各ファイルの SHA-256 を返すので、サイズだけでなくバイト列がそのまま届いたことをテストで 証明できます。POST /api/formdata/uploadサインインして表示 は本物のアップロード API のように振る舞い、ファイルを保存して id 付きの 201 Created で応答します。

Terminal
$ curl -s https://api.testingapis.com/api/bodies/form -F "file=@report.csv;type=text/csv"
{"contentType":"multipart/form-data; boundary=------------------------8iZt5Fmg2P4QL6fBoNn1Ms","encoding":"multipart","fields":{},"files":[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22,"sha256":"2e9f445b06e5ca0fb9638798078f6a239ea1fcace10e1dcfb70ceb3a3322ad44"}]}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

アップロードのコードで確認すること

  • Content-Type ヘッダーはライブラリに任せ、boundary が付くようにする。
  • ファイルのパートにはすべてファイル名とコンテンツタイプがある。
  • フィールド名がサーバーの期待するもの(file、files、upload など)と一致している。
  • テキストフィールドとファイルは、別の JSON リクエストではなく、同じマルチパートのボディに入れる。
  • テストでは、ステータスコードだけでなく、名前、型、サイズなどサーバーが受け取った内容を検証する。

ヘッダーやクエリ文字列を含むリクエストの残りの部分については、echo API のリファレンス に返されるすべてのフィールドが載っています。