Test S3 storage code without AWS
Run an Express upload route and its storage calls against an in-memory adapter in Vitest, inject S3 failures and retries, and know which S3 behaviors still need MinIO or aws-sdk-client-mock.
Make your code take a Files instance instead of building one, and in tests build that instance with memory(). Your route still runs through the real Files wrapper: its key handling, capability checks, plugins, error codes, and retries are the same code that runs in production. Only the last step, the adapter writing bytes, lands in a Map instead of S3. No credentials, no network, and no mock of S3’s request shapes to keep in sync, because memory() implements the same Adapter interface as s3().
Memory doesn’t behave like S3 everywhere, and the differences are predictable: it can’t sign URLs, so anything that depends on presigning takes a different path. Cover those with a few integration tests against a local MinIO server, and use aws-sdk-client-mock when you need S3 itself to fail in a specific way.
Before you start
- Code that uses files-sdk with the instance passed in. This guide tests the Express app from Upload files to S3 from Express:
createApp(files, { maxImportSize }), a gateway at/api/files, an/imports/:nameroute, and agetSessionhelper insrc/session.ts. - Written against files-sdk 3.0, Vitest 5.0, supertest 7.3, Express 5.2,
aws-sdk-client-mock4.1, and Node.js 24. Every test below passed in that setup, and the MinIO test ran againstRELEASE.2025-09-06T17-38-46Z.
npm install --save-dev vitest supertest @types/supertest aws-sdk-client-mock
Pass the instance in
The pattern this guide depends on is one line in the app: createApp receives files rather than importing it.
import { createApp } from "./app.js";
import { files } from "./files.js"; // createFiles({ adapter: s3({ … }) })
createApp(files).listen(3000);
In a test, the same function gets a memory-backed instance:
import { createFiles } from "files-sdk";
import { memory } from "files-sdk/memory";
const files = createFiles({ adapter: memory() });
const app = createApp(files);
Nothing else about the app changes. If your code imports a module-level files today, you could vi.mock that module to export a memory-backed instance instead. Passing the instance in is less fragile: each test builds its own empty store, or a failing one, without a module mock.
The gateway in that app signs upload tokens with FILES_API_SECRET, and without it each router logs a warning. Set it for the test run:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
env: { FILES_API_SECRET: "test-secret" },
},
});
Test a route end to end
The app reads the signed-in user through getSession. Replace that module with one that trusts a header, so each test picks its user. It’s the only module this guide mocks:
import { createFiles } from "files-sdk";
import { memory } from "files-sdk/memory";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { createApp } from "../src/app.js";
// Sign tests in as whoever the x-test-user header names.
vi.mock("../src/session.js", () => ({
getSession: (req: { get: (name: string) => string | undefined }) => {
const userId = req.get("x-test-user");
return Promise.resolve(userId ? { userId } : null);
},
}));
describe("imports", () => {
it("streams the request body into storage", async () => {
const files = createFiles({ adapter: memory() });
const res = await request(createApp(files))
.put("/imports/report.csv")
.set("x-test-user", "u1")
.set("content-type", "text/csv")
.send("id,total\n1,42\n");
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
contentType: "text/csv",
key: "imports/u1/report.csv",
size: 14,
});
const stored = await files.download("imports/u1/report.csv");
expect(await stored.text()).toBe("id,total\n1,42\n");
});
it("refuses an oversize body before storing anything", async () => {
const files = createFiles({ adapter: memory() });
const app = createApp(files, { maxImportSize: 1024 });
const res = await request(app)
.put("/imports/huge.bin")
.set("x-test-user", "u1")
.set("content-type", "application/octet-stream")
.send(Buffer.alloc(2048));
expect(res.status).toBe(413);
expect(await files.exists("imports/u1/huge.bin")).toBe(false);
});
});
Assert on what landed in storage, through the same files instance: download, head, exists, list. That checks the real effect of the request, not just the status code. When a test needs to look underneath the wrapper, the adapter’s raw property is the backing Map (adapter.raw.size, adapter.raw.has(key)).
The import tests each build their own memory(), so no test sees another’s files. The limit is an argument to createApp for the same reason: a test of the 413 path shouldn’t have to allocate 50 MiB. With a 1,024-byte limit, a 2 KiB body is refused from Content-Length before the route reads it. To cover the other branch, where the counter trips partway through a body with no Content-Length, start the app with listen(0) and send a ReadableStream with fetch and duplex: "half". That test passed too, with nothing stored afterwards.
Test the gateway through the real client
For the /api/files gateway, don’t hand-write its wire protocol in supertest calls. Start the app on a free port and drive it with createFilesClient, the same client the browser uses:
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { createFilesClient } from "files-sdk/client";
import { afterEach, beforeEach } from "vitest";
describe("the files gateway", () => {
let server: Server;
let endpoint: string;
const files = createFiles({ adapter: memory() });
beforeEach(async () => {
server = createApp(files).listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const { port } = server.address() as AddressInfo;
endpoint = `http://127.0.0.1:${port}/api/files`;
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
});
const clientFor = (user?: string) =>
createFilesClient({
endpoint,
headers: user ? { "x-test-user": user } : {},
});
it("scopes each user's files to their own prefix", async () => {
await clientFor("u1").upload("notes.txt", "hello", {
contentType: "text/plain",
});
expect(await files.exists("users/u1/notes.txt")).toBe(true);
const theirs = await clientFor("u2").list();
expect(theirs.items).toHaveLength(0);
await expect(clientFor("u2").download("notes.txt")).rejects.toMatchObject({
code: "NotFound",
});
});
it("completes a keyless upload", async () => {
const stored = await clientFor("u1").upload(
new File(["%PDF-1.7"], "invoice.pdf", { type: "application/pdf" })
);
const info = await files.head(`users/u1/${stored.key}`);
expect(info).toMatchObject({ contentType: "application/pdf", size: 8 });
});
it("refuses verbs the app doesn't allow", async () => {
await expect(
clientFor("u1").copy("notes.txt", "copy.txt")
).rejects.toMatchObject({ code: "ReadOnly" });
});
it("refuses requests without a session", async () => {
await expect(clientFor().list()).rejects.toMatchObject({
code: "Unauthorized",
});
});
});
The client rebuilds a FilesError from the gateway’s error response, so the assertions read like SDK code: code: "NotFound", "ReadOnly", "Unauthorized". Check code rather than message. The messages come from the adapter, and memory’s (memory: not found: missing.txt) and S3’s (The specified key does not exist.) differ.
The keyless upload test passes against memory, but it doesn’t take the path production takes. See Where memory differs from S3.
Simulate storage failures
An adapter is a plain object of methods, so a failing one is the memory adapter with one method replaced:
import { FilesError } from "files-sdk";
import type { Adapter } from "files-sdk";
it("answers 502 when storage fails", async () => {
const base = memory();
const broken: Adapter = {
...base,
upload: () =>
Promise.reject(new FilesError("Provider", "simulated S3 outage")),
};
const files = createFiles({ adapter: broken });
vi.spyOn(console, "error").mockImplementation(() => undefined);
const res = await request(createApp(files))
.put("/imports/report.csv")
.set("x-test-user", "u1")
.send("id,total\n");
expect(res.status).toBe(502);
expect(base.raw.size).toBe(0);
});
Throw the error the real adapter would: a FilesError with the code your code branches on. Provider is what S3 throttling, 5xx responses, and network failures map to. NotFound, Unauthorized (a 403 such as AccessDenied), and Conflict are the other provider codes. Errors lists them.
Because the failure happens below the Files wrapper, the wrapper’s retry policy applies to it, exactly as in production. That makes retry behavior testable too:
import { FilesError, createFiles } from "files-sdk";
import type { Adapter } from "files-sdk";
import { memory } from "files-sdk/memory";
import { expect, it } from "vitest";
// Fail the first `failures` uploads with a retryable provider error.
const flaky = (adapter: Adapter, failures: number) => {
let remaining = failures;
let calls = 0;
const wrapped: Adapter = {
...adapter,
upload: (key, body, options) => {
calls += 1;
if (remaining > 0) {
remaining -= 1;
return Promise.reject(new FilesError("Provider", "simulated 503"));
}
return adapter.upload(key, body, options);
},
};
return { adapter: wrapped, calls: () => calls };
};
it("retries a buffered upload after a provider error", async () => {
const storage = flaky(memory(), 1);
const files = createFiles({ adapter: storage.adapter, retries: 2 });
await files.upload("report.csv", "id,total\n");
expect(storage.calls()).toBe(2);
});
it("doesn't retry a streamed upload", async () => {
const storage = flaky(memory(), 1);
const files = createFiles({ adapter: storage.adapter, retries: 2 });
const body = new Blob(["id,total\n"]).stream();
await expect(files.upload("report.csv", body)).rejects.toMatchObject({
code: "Provider",
});
expect(storage.calls()).toBe(1);
});
The second test documents a rule that’s easy to forget: a ReadableStream can’t be replayed, so the SDK never retries a streamed upload (Retries). The Express app’s import route streams, so its callers have to retry themselves.
You can also inject failures with a plugin that throws from a handlers({ upload }) hook. That works on any adapter, but plugins run outside the retry loop: in a run with retries: 2, a Provider error thrown from a plugin failed the call after one attempt. Use the adapter wrapper when the test is about retries.
Where memory differs from S3
Memory reports what it can’t do through files.capabilities, the same object production code and the gateway read. Most of the differences below follow from one of those flags:
| Behavior | memory() |
s3() |
|---|---|---|
files.capabilities.signedUpload.supported |
false |
true |
Gateway upload(file) |
Proxy PUT through your server |
Presigned POST straight to S3 |
Gateway download |
Streamed by the gateway, 200 |
302 to a presigned URL |
files.url(key) |
memory://key, not fetchable |
A presigned HTTPS URL |
files.url(key, { expiresIn }) |
Throws Unsupported |
Signed, up to 7 days |
files.signedUploadUrl(key) |
An inert memory:// placeholder |
A real presigned target |
| ETag format | Quoted: "05e918d2" |
Bare hex; multipart adds -<parts> |
| Stream bodies | Read fully into memory | Streamed through lib-storage multipart |
| Keys over 1,024 bytes | Accepted | Rejected |
| Conditional writes | Throw Unsupported |
Supported on AWS S3 |
| IAM, bucket CORS, network | None | Real |
The rows to watch:
- Presigned uploads take another path. Against memory, the gateway hands a keyless upload its own proxy
PUT, so the test exercises the proxy route and your server receives the bytes. The S3POSTpolicy, its size limit, and the bucket’s CORS rule aren’t tested at all. signedUploadUrl()doesn’t throw on memory, even thoughcapabilities.signedUpload.supportedisfalse. It returns a placeholder URL so signing code stays testable, and nothing can upload to it. A test that only checks that a URL came back passes against memory and proves nothing about S3.- Don’t assert on ETag strings. Compare one ETag with another from the same backend, or check that it’s defined.
- Memory buffers streams. A test can’t show that a route stays within its memory budget, or that a 6 MiB stream became a two-part multipart upload.
- Key limits differ. S3 limits keys to 1,024 bytes. Memory stored a 2,000-character key that MinIO refused (
Provider,Your key is too long).
One check ties the two worlds together without a network call. Constructing the production adapter makes no request, so a test can pin the capabilities your app depends on:
import { expect, it } from "vitest";
import { files } from "../src/files.js";
// Constructing the adapter makes no network call, so this runs offline.
it("keeps browser uploads direct and size-capped", () => {
expect(files.capabilities.signedUpload).toMatchObject({
maxSize: true,
supported: true,
});
});
If someone later adds validation({ maxSize }) to the production instance, signedUpload turns off and the gateway quietly routes every browser upload through the server. This test fails when that happens.
Integration tests against MinIO
For what memory can’t show (the POST policy, multipart, real error responses), run the same app against a local S3-compatible server. MinIO runs as a single binary or container. Keep these tests in their own file and skip them when no server is configured:
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { CreateBucketCommand } from "@aws-sdk/client-s3";
import { createFiles } from "files-sdk";
import { createFilesClient } from "files-sdk/client";
import { s3 } from "files-sdk/s3";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createApp } from "../src/app.js";
vi.mock("../src/session.js", () => ({
getSession: (req: { get: (name: string) => string | undefined }) => {
const userId = req.get("x-test-user");
return Promise.resolve(userId ? { userId } : null);
},
}));
const endpoint = process.env.S3_TEST_ENDPOINT;
describe.skipIf(!endpoint)("against a local S3 server", () => {
const files = createFiles({
adapter: s3({
bucket: `test-${Date.now()}`,
region: "us-east-1",
endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_TEST_ACCESS_KEY ?? "minioadmin",
secretAccessKey: process.env.S3_TEST_SECRET_KEY ?? "minioadmin",
},
}),
});
let server: Server;
let api: string;
beforeAll(async () => {
await files.raw.send(
new CreateBucketCommand({ Bucket: files.adapter.bucket })
);
server = createApp(files).listen(0);
await new Promise((resolve) => server.once("listening", resolve));
api = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/files`;
});
afterAll(() => {
server.close();
});
it("uploads through a presigned POST and completes", async () => {
const client = createFilesClient({
endpoint: api,
headers: { "x-test-user": "u1" },
});
const stored = await client.upload(
new File(["hello"], "hello.txt", { type: "text/plain" })
);
const info = await files.head(`users/u1/${stored.key}`);
expect(info.size).toBe(5);
});
});
S3_TEST_ENDPOINT=http://127.0.0.1:9000 npx vitest run
This is the same keyless upload as the memory test, but here the gateway signs a real POST policy, the client posts the form to MinIO, and complete heads a real object. files.raw is the adapter’s S3Client, which creates a fresh bucket per run. Without S3_TEST_ENDPOINT, Vitest reports the test as skipped, so the default run stays offline.
MinIO implements the S3 API, but it isn’t S3: permissions and CORS configuration work differently, and Files SDK only claims conditional writes on AWS’s own endpoints, so files.capabilities.conditional is all false against MinIO by default. Run those few checks against a test bucket in a real account, in CI or before a release.
Make S3 fail on cue with aws-sdk-client-mock
Some failures are hard to produce on demand from any server: throttling, an expired credential, an AccessDenied on one call. aws-sdk-client-mock replaces the S3 client’s send, so the s3() adapter’s real error mapping and the wrapper’s retries run against the response you script:
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { mockClient } from "aws-sdk-client-mock";
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { afterEach, expect, it } from "vitest";
const s3Mock = mockClient(S3Client);
afterEach(() => {
s3Mock.reset();
});
const s3Error = (name: string, status: number) =>
Object.assign(new Error(name), {
name,
$metadata: { httpStatusCode: status },
});
it("retries S3 throttling and then succeeds", async () => {
s3Mock
.on(PutObjectCommand)
.rejectsOnce(s3Error("SlowDown", 503))
.resolves({ ETag: '"9a0364b9e99bb480dd25e1f0284c8555"' });
const files = createFiles({
adapter: s3({ bucket: "uploads", region: "us-east-1" }),
retries: 2,
});
const stored = await files.upload("report.csv", "id,total\n", {
contentType: "text/csv",
});
expect(stored.etag).toBe("9a0364b9e99bb480dd25e1f0284c8555");
expect(s3Mock.commandCalls(PutObjectCommand)).toHaveLength(2);
});
it("maps AccessDenied to Unauthorized", async () => {
s3Mock.on(PutObjectCommand).rejects(s3Error("AccessDenied", 403));
const files = createFiles({
adapter: s3({ bucket: "uploads", region: "us-east-1" }),
});
await expect(files.upload("report.csv", "id,total\n")).rejects.toMatchObject({
code: "Unauthorized",
});
});
Reach for it when the test is about S3’s own answers: a specific error code, the exact command input (commandCalls(PutObjectCommand)[0].args[0].input shows the Bucket, Key, and ContentType the adapter sent), or code of yours that calls @aws-sdk/client-s3 directly. For everything else, memory is less work and doesn’t tie the test to how the adapter talks to S3, which can change between releases without changing what files.upload() does.
Troubleshooting
aws-sdk-client-mock doesn’t intercept, and the test fails with The AWS Access Key Id you provided does not exist in our records. The request went to real AWS. mockClient(S3Client) patches the S3Client class you import, and the adapter constructed a different copy. This happens when two copies of @aws-sdk/client-s3 are installed. With files-sdk linked from a local checkout, for example, the adapter resolved the SDK from the checkout’s node_modules, not the app’s. Run npm ls @aws-sdk/client-s3 and make sure the adapter and the test import the same copy.
files-sdk/api: no secret and no FILES_API_SECRET in the test output. Each createFilesRouter call without a secret warns once. Set FILES_API_SECRET in test.env, as in the config above.
Code that works on S3 throws Unsupported against memory. It asked for something memory can’t do: an expiring url(), a conditional write, or anything else files.capabilities reports as unsupported. Branch on the capability in the code under test, or move that case to the MinIO or mocked-client tests.