Datei-Uploads mit multipart/form-data testen

Lade eine Datei mit curl, JavaScript, Python und C# hoch, sieh genau, was der Server empfangen hat, und vermeide den Boundary-Fehler.

Beispiele zuletzt am gegen die API ausgeführt

Um einen Datei-Upload zu testen, musst du sehen, was der Server tatsächlich empfangen hat: welche Felder, welche Dateien, unter welchen Namen, mit welchem Content-Type und welcher Größe. POST /api/echo auf https://api.testingapis.com parst einen multipart/form-data-Request und schickt das als JSON zurück. Kostenlos, ohne Schlüssel, speichert nichts und erlaubt Requests von jedem Origin, funktioniert also auch aus dem Browser. Unten: ein funktionierender Upload in curl, JavaScript, Python und C#, die Fehler, die Uploads in jeder Sprache kaputtmachen, und ein xUnit-Test.

Ein Multipart-Upload mit curl

-F lässt curl multipart/form-data senden. name=value ist ein Textfeld, name=@path hängt eine Datei an, und ;type= setzt ihren Content-Type. Ohne ;type= sendet curl application/octet-stream. Die Antwort enthält noch weitere Request-Details; hier behält jq die zwei Teile, auf die es ankommt:

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: FormData mit fetch

Bau ein FormData, hänge Felder und Dateien an und übergib es als Body. Derselbe Code läuft im Browser (mit einer Datei aus einem <input type="file"> statt readFile) und in Node 18 oder neuer.

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);
Ausgabe (Node 24)
{ title: 'Quarterly report' } [
  {
    field: 'file',
    fileName: 'report.csv',
    contentType: 'text/csv',
    size: 22
  }
]

Python: requests

Textfelder gehören in data, Dateien in files. Gib jeder Datei ein Tupel aus Dateiname, Dateiobjekt und Content-Type. Übergibst du nur das Dateiobjekt, fehlt der Content-Type.

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"])
Ausgabe
{'title': 'Quarterly report'} [{'field': 'file', 'fileName': 'report.csv', 'contentType': 'text/csv', 'size': 22}]

C#: MultipartFormDataContent

Füge pro Textfeld ein StringContent hinzu und pro Datei ein StreamContent (oder ByteArrayContent). Erst das Argument mit dem Dateinamen macht einen Teil zur Datei: Lässt du es weg, kommen die Bytes der Datei als normales Textfeld an.

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"));
Ausgabe (.NET 10)
{"title":"Quarterly report"}
[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22}]

Die Fehler, die Uploads kaputtmachen

Jede Zeile wurde an /api/echo geschickt; die letzte Spalte zeigt, was der Server geparst hat.

Was der Code getan hatBibliothekenWas der Server bekam
Den Header Content-Type von Hand auf multipart/form-data gesetztfetch, requests, HttpClient (Content-Type des Formulars ersetzt)Nichts: Missing content-type boundary. Die Boundary, die die Teile trennt, ist weg
Den Dateinamen weggelassenHttpClient (form.Add(content, name))Den Dateiinhalt als Textfeld, aber keine Datei
Den Dateinamen weggelassenfetch (Blob ohne Namen)Eine Datei namens blob
Den Content-Type weggelassencurl, requests (nur das Dateiobjekt), HttpClientcurl: application/octet-stream; requests und HttpClient: einen leeren Content-Type
data= ohne files= genutztrequestsGar kein Multipart: application/x-www-form-urlencoded

Der Boundary-Fehler ist der häufigste. Das sieht der Server:

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

Ein xUnit-Test für einen Upload

Er prüft, was beim Server angekommen ist, nicht nur, dass der Request 200 zurückgab. Er besteht mit xUnit 2.9 auf .NET 10.

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

Prüfsummen und ein realistischer Upload-Endpunkt

Zwei weitere Endpunkte sind für angemeldete Mitglieder. POST /api/bodies/formanmelden zum Anzeigen meldet dieselben Felder und Dateien plus den SHA-256 jeder Datei, ein Test kann also beweisen, dass die Bytes unversehrt angekommen sind, nicht nur die Größe. POST /api/formdata/uploadanmelden zum Anzeigen verhält sich wie eine echte Upload-API: Er speichert die Datei und antwortet mit 201 Created und einer ID.

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"}]}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Was du in deinem Upload-Code prüfen solltest

  • Der Header Content-Type bleibt der Bibliothek überlassen, damit er die Boundary trägt.
  • Jeder Dateiteil hat einen Dateinamen und einen Content-Type.
  • Die Feldnamen passen zu dem, was der Server erwartet (file, files oder upload).
  • Textfelder und Dateien stehen im selben Multipart-Body, nicht in einem separaten JSON-Request.
  • Ein Test prüft, was der Server empfangen hat: Namen, Typen und Größen, nicht nur den Statuscode.

Für den Rest eines Requests, Header und Query-Strings eingeschlossen, listet die Referenz der Echo-API jedes Feld, das sie zurückgibt.