测试 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 进行 multipart 上传

-F 让 curl 发送 multipart/form-data。name=value 是文本字段,name=@path 附加一个文件,;type= 设置它的内容类型。不加 ;type= 时,curl 发送 application/octet-stream。响应里还有请求的其他细节,这里用 jq 只保留最重要的两部分:

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,添加字段和文件,然后把它作为请求体传入。同样的代码可以在浏览器中运行(用来自 <input type="file"> 的文件代替 readFile),也可以在 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-datafetch、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:空的内容类型
用了 data= 却没有 files=requests根本不是 multipart: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());
    }
}

校验和与一个接近真实的上传端点

另有两个端点面向已登录的成员。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)。
  • 文本字段和文件放在同一个 multipart 请求体中,而不是单独发一个 JSON 请求。
  • 测试要断言服务器收到的内容:名称、类型和大小,而不只是状态码。

至于请求的其余部分,包括请求头和查询字符串,echo API 参考列出了它返回的每个字段。