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 つの部分だけを残しています。
$ 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
}
]
}/api/echotitle=Quarterly report
JavaScript:fetch と FormData
FormData を作り、フィールドとファイルを追加して、ボディとして渡します。同じコードがブラウザ(readFile の代わりに <input type="file"> のファイルを使う)でも Node 18 以降でも動きます。
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);{ title: 'Quarterly report' } [
{
field: 'file',
fileName: 'report.csv',
contentType: 'text/csv',
size: 22
}
]Python:requests
テキストフィールドは data に、ファイルは files に入れます。各ファイルにはファイル名、ファイルオブジェクト、コンテンツタイプのタプルを渡してください。ファイルオブジェクト だけを渡すと、コンテンツタイプが抜けます。
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)を追加します。パートをファイルにするのはファイル名の引数です。これを省くと、ファイルのバイトは 普通のテキストフィールドとして届きます。
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"));{"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(ファイルオブジェクトだけ)、HttpClient | curl は application/octet-stream、requests と HttpClient は空のコンテンツタイプ |
files= なしで data= を使った | requests | マルチパートですらない:application/x-www-form-urlencoded |
いちばん多いのは boundary のミスです。サーバーにはこう見えます。
{
"form": { "parseError": "Missing content-type boundary." },
"files": null,
"contentType": "multipart/form-data"
}アップロードの xUnit テスト
リクエストが 200 を返したことだけでなく、サーバーに何が届いたかを検証します。.NET 10 上の xUnit 2.9 で通ります。
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 で応答します。
$ 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 のリファレンス に返されるすべてのフィールドが載っています。