Direct-to-S3 / Azure tus 1.0 resume IndexedDB Golden Retriever 30 locales Webcam & screen capture

Testing file uploads in ASP.NET Core

Upload code is usually under-tested, because the interesting behaviour lives in places unit tests do not reach: multipart parsing, size limits, chunk assembly, cleanup. Here is what to test at each level — and the tests that pass while proving nothing.

Unit level: fake the file, test your rules

IFormFile is an interface, so validation and storage logic can be tested with no server at all. Do not reach for a mocking framework — a small fake is clearer and reusable:

public static IFormFile FakeFile(string name, byte[] content, string contentType = "application/octet-stream")
{
    var stream = new MemoryStream(content);
    return new FormFile(stream, 0, stream.Length, "file", name)
    {
        Headers = new HeaderDictionary(),
        ContentType = contentType
    };
}

[Fact]
public void Rejects_ExecutableDisguisedAsImage()
{
    var file = FakeFile("photo.jpg.exe", new byte[] { 0x4D, 0x5A });   // "MZ"
    Assert.NotNull(_service.ValidateFile(file.FileName, file.Length, file.ContentType));
}

Use real bytes for the cases that depend on them. Magic-byte detection tested with new byte[100] proves nothing; a genuine JPEG header (FF D8 FF) or PNG header (89 50 4E 47) proves the check works.

The rejections matter more than the successes

A test that a valid file uploads is worth one test. The cases that actually protect you are the refusals, and they are the ones usually missing:

  • Extension not on the allowlist — including double extensions like invoice.pdf.exe, and mixed case like .PhP
  • A file one byte over the limit, and a zero-byte file
  • A file name containing ../, a leading slash, a backslash, or a NUL byte
  • A file name containing <, > and quotes — then assert on what your API returns, because that string will be rendered by somebody
  • A content type that contradicts the extension
  • More files than MaxFilesPerUpload allows

Assert on the outcome, not the message text. A test pinned to an exact error string breaks on every copy edit; a test asserting that nothing was written to disk keeps its value.

Integration level: exercise the real endpoint

Multipart parsing, model binding, antiforgery and body-size limits are all framework behaviour, so they only show up when a real request goes through the pipeline. WebApplicationFactory gives you that without a network:

public class UploadEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public UploadEndpointTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task Upload_StoresTheFile()
    {
        using var content = new MultipartFormDataContent();
        var bytes = new ByteArrayContent(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
        bytes.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(bytes, "file", "photo.jpg");

        var response = await _client.PostAsync("/api/upload/upload", content);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

The name in content.Add(bytes, "file", "photo.jpg") must match the form field the endpoint reads. Getting it wrong produces a “no file provided” response that looks like a server bug and is not one — worth a comment in the test.

The chunked-upload test that silently passes

This one is worth internalising. A test named “chunked upload works” that uploads a 2 KB file with a 5 MB chunk size never chunks anything. The client sees one chunk, takes the single-upload path, passes, and tells you nothing about chunk assembly. We shipped a real chunk-path bug behind exactly this test.

Make the fixture larger than the chunk size, and assert on evidence that chunking happened — more than one request to the chunk endpoint, or a completion call — not merely on a 200.

Then cover the cases that break real deployments:

  • Chunks arriving out of order, since parallel uploads do not preserve ordering
  • A duplicate chunk — a retry after a timeout that actually succeeded. Re-sending chunk 3 must be idempotent, not append twice
  • Completion with a chunk missing — must fail, not assemble a corrupt file. Counting files is not the same as having them
  • Resume after the temp directory was swept, which is what happens after a deploy
  • Assembled output equals the input — compare a hash of the reassembled file with the original. This is the assertion that catches off-by-one errors in offsets

Isolate the filesystem

Tests that write into the app's real upload folder leak state between runs and fail in CI. Point each test class at a fresh temporary directory and delete it afterwards:

public sealed class UploadFixture : IDisposable
{
    public string Root { get; } = Path.Combine(Path.GetTempPath(), "uploadtests", Guid.NewGuid().ToString("N"));
    public UploadFixture() => Directory.CreateDirectory(Root);
    public void Dispose()
    {
        try { Directory.Delete(Root, recursive: true); } catch { /* best effort */ }
    }
}

Assert on the directory as well as the response. After a rejected upload it should be empty; after a cancelled chunked upload the session folder should be gone. Cleanup bugs are invisible until a disk fills up in production.

Limits are configuration, so test the configuration

A size limit involves your options, Kestrel's MaxRequestBodySize and — behind IIS — maxAllowedContentLength. A unit test of your validator proves none of them are wired up. Post something genuinely over the limit through the real pipeline and assert on the status code you promised your clients, so a config change that silently raises or lowers the ceiling fails a test instead of surprising a user.

Browser level: keep it small and keep it real

A couple of end-to-end tests are worth having, because some failures only exist in a browser. Playwright can set files directly on the input without touching the OS file picker:

await page.SetInputFilesAsync("input[type=file]", "fixtures/large.bin");
await page.WaitForSelectorAsync(".cu-file-status--complete", new() { Timeout = 120_000 });

Two things E2E catches that nothing else does: JavaScript errors that break the upload while every server test still passes, and progress that never reaches 100% even though the file arrived. Assert that the browser console produced no errors during the run — a silently broken client is the failure mode server tests are blind to.

What to cover, in priority order

  1. Rejections: extension, size, path traversal, hostile file names
  2. A real multipart request through the real endpoint
  3. Chunked upload with a file genuinely larger than the chunk size, hash-compared after assembly
  4. Out-of-order, duplicate and missing chunks
  5. Cleanup: nothing left behind after rejection, cancellation or completion
  6. Limits enforced through the full pipeline, not just the validator
  7. One browser test that uploads a large file and asserts a clean console