← Back to blog

Published · Updated · By Elliot Jackson

Should you use a filesystem fake or a local S3 endpoint?

Choose the smallest useful storage test double by matching it to the S3 behaviours your application actually depends on.

The easiest way to choose a local S3 tool is to start with the wrong question.

“Which S3 emulator should we use?” assumes that your tests need an S3 emulator. They might not. If the application only needs to store and retrieve bytes behind a deliberately small interface, a filesystem fake can be the more honest choice. If the application depends on an AWS SDK serialising requests, signing them, following redirects, generating presigned URLs, or handling S3-shaped errors, writing straight to disk skips the behaviour you need to exercise.

The useful boundary is the contract your test is meant to protect.

This guide gives you a way to choose that boundary, explains where a filesystem adapter, SDK mock, local S3 endpoint, or AWS environment fits, and shows where a local endpoint such as Objectively becomes relevant.

Start with the behaviour under test

Before choosing a tool, write down what the test must prove.

For example:

  • “The application stores an image under the expected key and can read the bytes back.”
  • “The client sends a correctly signed PutObject request to the configured endpoint.”
  • “A download link expires and returns the expected error afterwards.”
  • “Two SDKs agree about object metadata and pagination.”
  • “The deployed application can use IAM policies, versioning, or a second AWS service.”

These are different tests. A single storage substitute rarely gives you the smallest, clearest answer to all of them.

The decision also depends on where the storage boundary lives in your application. If production code talks to an interface such as ObjectStore, and the S3 client is one implementation of that interface, tests of domain behaviour can use another implementation. If application code constructs an AWS SDK client directly and relies on S3 request semantics, the SDK-to-server boundary is part of the behaviour.

That distinction is worth recording in an architecture decision record (ADR), rather than leaving it as an assumption in a test helper.

The four common choices

A filesystem adapter or fake

A filesystem fake implements the storage interface with a temporary directory or another local store. It can preserve the parts of the contract your application owns: keys, bytes, metadata that your interface exposes, and perhaps basic missing-object behaviour.

It is a good fit when:

  • the test is about application or domain logic rather than S3 itself;
  • the storage interface is intentionally provider-neutral;
  • you need deterministic fixtures and controlled cleanup; and
  • the application does not need to prove HTTP, request signing, URL addressing, or S3-specific response semantics in that test.

The important word is “adapter”. The fake should implement the contract your application has chosen, not quietly grow a second S3 implementation. If the production interface only promises put(key, bytes) and get(key), adding every S3 option to the fake can make tests harder to understand without increasing confidence.

An in-process SDK mock

An SDK mock intercepts calls inside the application process. It is useful when the question is whether your code calls the client correctly, handles a particular exception, retries a defined failure, or responds to a controlled result.

This gives you precise control over awkward cases. You can return an access-denied error without arranging credentials, a network outage, or a bucket with a particular state. You can also assert that a method was called with the expected key and options.

The trade-off is equally precise: a mock can confirm what your code asked the SDK to do, but it does not prove that the SDK serialised the request, signed it, addressed the endpoint, or interpreted a real response correctly. Those are different claims.

Moto’s S3 documentation describes its Python mocking path. For clients outside Python, Moto’s server-mode documentation describes the standalone server. The in-process path is a useful example of keeping language-level tests close to the code under test; it is not a replacement for every endpoint test.

The same distinction appears in other tools’ own descriptions. Adobe S3Mock documents a subset of S3 for local integration testing, whilst S3rver describes a fake server for sandbox and testing rather than production parity. S3rver’s repository is now archived and read-only, so treat it as a legacy dependency rather than an actively maintained default. Their documented limits are useful reminders that “server” does not automatically mean “AWS”. Recheck each project’s current scope before making a tool-specific choice.

A local S3-compatible endpoint

A local endpoint gives the application a real HTTP destination. The SDK still has to construct a request, apply its endpoint configuration, sign it when required, and interpret the server response. The endpoint can then maintain buckets and objects for the test or development session.

This is the smallest useful choice when you need to exercise behaviours such as:

  • endpoint and credential configuration;
  • AWS Signature Version 4 request construction;
  • path-style or virtual-hosted-style addressing;
  • HTTP status and S3 error handling;
  • metadata, ranges, conditions, pagination, or multipart flows;
  • presigned URL generation and use; or
  • interaction between clients written in different languages or running in different processes.

AWS documents service-specific endpoint configuration through environment variables, shared configuration, and explicit client settings in its SDK and Tools endpoint reference. Its S3 virtual-hosting documentation describes both path-style and virtual-hosted-style requests, while its Signature Version 4 presigned-URL documentation describes the signing and expiry parameters. These configuration and protocol seams are part of what an endpoint test can exercise. A test that replaces the client with a filesystem call cannot cover them.

An endpoint is still a test double. It does not become Amazon S3 because the URL starts with http://localhost, and “S3-compatible” does not describe one fixed level of compatibility. Check the operations and addressing modes your application uses.

A broader emulator or AWS itself

Choose a broader environment when the behaviour crosses the boundary of a single object-storage endpoint. IAM policy evaluation, bucket policies, versioning, event delivery, network topology, encryption integrations, or interactions with several AWS services may need an emulator with those services or an AWS-backed test environment.

This is where a local S3 endpoint stops being the smallest useful answer. It may still be valuable for the object-storage portion of the inner loop, but it should not be presented as proof of the wider deployment.

A behaviour-to-test-double matrix

Use this as a starting point, then adapt it to your application’s actual contract.

Behaviour you need to proveSmallest useful doubleWhat it tells youWhat it does not tell you
Key generation, byte handling, and domain rulesFilesystem adapterYour application’s storage logic works through its chosen interfaceWhether an S3 SDK request is valid
A missing object or provider failure branchFilesystem fake or SDK mockYour error handling works for the defined failureWhether a real service returns that exact error shape
SDK method arguments and call countSDK mockThe client is invoked with the expected optionsWhether the SDK serialises, signs, or sends the request correctly
Endpoint, credentials, and region configurationLocal S3 endpointThe configured client can reach and authenticate against a local serviceAWS account, IAM, or cloud-network behaviour
HTTP paths, status codes, metadata, pagination, or multipart requestsLocal endpoint with verified operation coverageThe client and server interact for those specific operationsUnlisted or unsupported S3 operations
Presigned URL creation and useEndpoint, then AWS coverage where it mattersThe local client/server path handles the tested URL flowProduction expiry, policy, CDN, or cloud-specific behaviour
IAM, bucket policy, versioning, events, or several servicesBroader emulator or AWSThe wider integration under the chosen environmentBehaviour outside that environment’s documented scope
Production permissions, network boundaries, and provider-specific featuresAWS test environmentThe deployment-facing behaviourNothing beyond the test account and its conditions

The matrix prevents a common mistake: treating a successful local upload as evidence for every storage property. It is evidence for the path you actually exercised.

When a filesystem fake is the right answer

Imagine an image-processing service with a small port:

put(key, bytes, content_type)
get(key)
delete(key)

The service’s unit tests may only need to establish that a generated thumbnail uses the right key, that the bytes are not empty, and that a failed read produces the application’s expected result. A temporary directory behind that port is a sensible test implementation. It keeps the test about image-processing and key-selection rules.

That does not mean the filesystem fake is pretending to be S3. It is deliberately testing a narrower contract.

If the S3 client is hidden behind the same port, add a separate contract or integration suite for the adapter that talks to S3. The domain suite remains small, while the adapter suite can test the provider-specific details that matter.

The upgrade trigger is a change in the contract. If the image service starts relying on S3 metadata, conditional requests, multipart uploads, or presigned downloads, the filesystem implementation must either model those behaviours explicitly or stop claiming to cover them. At that point, an endpoint test is likely useful.

When an endpoint is worth the extra seam

Now consider an upload flow that gives a browser a presigned URL. The application creates a URL with the SDK, the browser sends an HTTP PUT, and the application later reads metadata from the object. The test now needs to cover the whole path from SDK request to stored object and later read.

The test needs to answer questions such as:

  1. Did the SDK use the intended endpoint and region?
  2. Was the URL constructed with the expected addressing style and expiry?
  3. Did the HTTP request carry a signature the server accepted?
  4. Did the server store the object and its relevant metadata?
  5. Does a later read or list operation expose the state the application expects?

A filesystem fake cannot answer those questions because it removes the client/server protocol from the test. An SDK mock can answer a few of them by assumption, but it cannot show that the complete request path works. A local endpoint is useful precisely because it keeps that seam intact.

There is still a boundary. You should test the exact operations you use and retain an AWS-backed test for the provider-specific behaviour that matters in production. Local success is a fast feedback loop, not a cloud compatibility certificate.

Where Objectively fits

Objectively is relevant after you have decided that you need an endpoint, rather than a filesystem adapter or an in-process mock. Its public page describes a native macOS app that runs a local S3-compatible server for development, with no Docker or cloud service required. It also lists isolated environments, copy-ready connection details, a live request feed, an object browser, and menu-bar access.

There is a material relationship to disclose here: I’m building Objectively, so it’s my product. It’s a relevant option for a Mac developer who wants an interactive local S3 loop, not an independent recommendation or a claim that it is the right test double for every application.

The product page presents the value here as a local endpoint with visible local state. If you need to watch requests arrive and inspect objects while working on a Mac, those surfaces may make the endpoint easier to understand than a server that only exposes logs or test assertions.

The public SDK examples show how to point clients at Objectively’s local endpoint. That makes it a candidate for testing the local protocol path, not a claim of broad AWS compatibility; verify the operations your application needs.

If your tests only need a provider-neutral ObjectStore interface, Objectively adds a protocol seam you may not need. For CI, non-Mac platforms, multi-service or IAM testing, and cloud-level confidence, use an environment designed for those requirements.

A small decision record you can keep with the code

Write down the decision in terms that can survive a tool change:

Storage behaviour under test:
Production contract:
Chosen test double:
Why this is the smallest useful choice:
Behaviours this test exercises:
Behaviours it deliberately does not exercise:
Where AWS-backed coverage lives:
Upgrade trigger:

For example, “Chosen test double: filesystem adapter” is incomplete. “Filesystem adapter for thumbnail key and byte tests; S3 adapter contract suite covers metadata and presigned downloads; AWS test covers production policy” tells the next person what confidence the test provides.

This record also keeps tool selection separate from architecture. Once you know that you need a local endpoint, you can compare endpoint tools by platform, lifecycle, operation coverage, inspection, CI support, and maintenance. That is a different decision from whether an endpoint is necessary in the first place.

The upgrade triggers to watch for

Revisit the decision when:

  • a defect escapes because the fake did not model an S3-specific behaviour;
  • the application starts using metadata, pagination, multipart, conditional requests, presigned URLs, or another provider-specific feature;
  • more than one SDK or process must interoperate against the same local state;
  • endpoint configuration or request signing becomes a source of bugs;
  • the team needs to inspect request and object state interactively; or
  • the test starts making claims about IAM, network security, service integration, or production AWS behaviour.

The first five triggers usually move a test towards a local endpoint. The last one may move it beyond a single endpoint, towards a broader emulator or AWS. There is no prize for keeping every test at the same layer.

The practical rule

Use a filesystem fake when the application’s contract is storage of keys and bytes. Use an SDK mock when the test needs controlled calls and failures. Use a local S3 endpoint when the HTTP/S3 protocol is part of the behaviour. Use an emulator or AWS when the integration extends beyond that endpoint.

If you need the third option on a Mac, Objectively is the relevant product branch. If you need the first, second, or fourth, another implementation may be smaller or better supported. The faithful test double is the one that preserves the behaviour you care about and leaves the rest explicitly untested.