← Back to blog

Published · Updated · By Elliot Jackson

What to test against local S3—and what to test in AWS

Divide S3 tests between fakes, local endpoints, AWS, and production observability without treating local success as proof of cloud behaviour.

When an application uses S3, the tempting test plan is usually one of two extremes: mock every call, or send everything to AWS. Neither is a particularly useful default.

A mock is quick, but it can only tell you what your mock was programmed to do. A real AWS test is valuable, but it makes every edit slower and adds credentials, cleanup, and cost to the inner loop.

Start with a narrower question.

Which environment can establish the confidence I need for this behaviour?

The answer is normally a layered plan. Use a fake for application decisions, a local S3 endpoint for request and integration behaviour, AWS for provider-specific semantics, and production observability for what only a live system can reveal.

Objectively is my own macOS app, so I have a stake in this recommendation. It is meant to speed up the local, observable part of that loop; it does not replace AWS.

Four layers of confidence

It helps to name the layers before assigning tests to them.

1. A pure unit fake

A fake S3 client or in-memory repository is the cheapest layer. It is useful for code that decides what to store, how to map a domain object to a key, and what your application should do when its storage abstraction returns an error.

It does not exercise an HTTP request, SDK serialisation, SigV4 signing, headers, query parameters, endpoint selection, or an S3 response body. A unit fake is not meant to cover those concerns.

2. A local S3 endpoint

A local endpoint lets your real SDK talk to an HTTP server. This is where you can catch mistakes such as a wrong endpoint, region, credential pair, bucket name, key, content type, or request shape while keeping the feedback loop short.

An endpoint can also make a handled request easier to inspect than a generic client exception. A request feed is a debugging aid, not a guarantee that every request or early failure will appear.

A local endpoint only proves the behaviours it implements. Local S3 tools are subsets in different ways. Adobe S3Mock describes itself as a subset for integration testing; Moto's S3 documentation focuses on mocked S3 behaviour for Python tests and its standalone server documentation covers use with other SDKs; and S3rver's repository explicitly says it does not try to duplicate all S3 behaviour or serve production and is currently archived and read-only. Read those scope disclosures before treating a local pass as evidence.

3. An automated AWS integration environment

Some tests need the actual provider. Use a dedicated AWS account or tightly scoped test resources, synthetic fixtures, least-privilege credentials, unique key prefixes, and cleanup that is safe to rerun.

This layer is where you verify IAM and bucket policies, the addressing and region model your deployment uses, network paths, TLS, provider-specific errors, and any S3 feature that your local implementation does not support. AWS's IAM policy simulator documentation explains that the simulator is useful for policy evaluation but that its results can differ from the live environment; a policy test is not a substitute for a controlled request against AWS.

4. Production observability

No local fixture or test account proves that production will remain healthy. Metrics, structured errors, traces where appropriate, alerts, and a small number of safe synthetic checks answer different questions: did a request arrive, how long did it take, did the application retry, and are failures concentrated in one region or operation?

Treat this as a confidence layer, not as another place to run a full test suite. Production data also brings privacy, retention, and access-control responsibilities that a local fixture does not.

A behaviour-by-environment matrix

The following is a starting allocation, not a universal compatibility guarantee. “Local endpoint” means your chosen local implementation has passed the relevant test against the version you run.

BehaviourUnit fakeLocal endpointAWS test environmentProduction observability
Key and bucket-name decisionsYesOptionalNoNo
SDK serialisation and endpoint configurationNoYesSmoke testError-rate monitoring
Bucket and object CRUDContract logicYes, for supported operationsYesRequest/error metrics
Metadata and content typeMapping onlyYes, if preserved by the endpointYesSampled validation where safe
Listing and paginationSimulated branchesVerify supported variantsVerify the AWS variant used in deploymentLatency and error metrics
Range requests and conditional headersBranch logicVerify supported variantsVerify the production flowError and latency metrics
Multipart uploadState-machine unit testsVerify if implementedVerify with realistic sizes and failuresTransfer failures and duration
Presigned URLsURL-building branchesVerify signing and required headersVerify expiry, permissions, and upload/downloadExpiry and 4xx monitoring
Path-style or virtual-hosted addressingNoVerify the local modeVerify the deployment modeHost/routing failures
IAM, bucket policies, and deniesExpected application reactionUsually no provider evaluationYesAccess-denied alerts and audit logs
Events and notificationsHandler logicOnly if the endpoint implements themYesDelivery and retry metrics
Regions, DNS, TLS, VPC, and network faultsNoLimited or noYesRegion/network dashboards
Consistency after writes and listingsModel application assumptionsVerify the local behaviour you rely onVerify AWS's documented object and bucket-configuration semanticsStale-read/error monitoring
Scale, durability, and availabilityNoNoLoad and resilience tests as neededSLOs, alarms, and incident data
Advanced bucket families and provider-only APIsNoOnly if explicitly supportedYes, when usedFeature-specific monitoring

AWS's current S3 overview documents general-purpose, directory, table, and vector buckets. “S3-compatible” is not a single, exhaustive feature set. A local endpoint that handles ordinary object CRUD may still be entirely unsuitable for an application using a provider-specific bucket family.

Consistency deserves the same treatment. AWS's documented consistency model provides strong read-after-write consistency for object PUT and DELETE operations, while noting that bucket configuration changes can be eventually consistent. A local server may appear immediately consistent simply because it is a single local process. Test the behaviour your application depends on in both places rather than turning that observation into a parity claim.

Local implementations may reject advanced S3 features that Amazon S3 supports. Those failures are useful local negative cases, but they are not evidence that Amazon S3 responds identically. Keep the boundary visible instead of hiding it behind a broad “S3-compatible” label.

The boundary that catches people: addressing

Path-style and virtual-hosted–style requests can look like a small URL detail. They are actually part of the request-routing contract.

In a path-style request, the bucket appears in the path:

https://s3.eu-west-1.amazonaws.com/my-bucket/image.png

In a virtual-hosted–style request, it appears in the host name:

https://my-bucket.s3.eu-west-1.amazonaws.com/image.png

Amazon's virtual-hosting documentation describes both forms and their region-specific endpoint rules. LocalStack's S3 documentation also calls out both addressing modes. A test that only exercises the first form can pass locally while deployment generates the second.

A local success is useful here, but incomplete. You can test your SDK configuration and request construction locally, then run a small AWS test that confirms the host, region, signing, and redirect/error behaviour you actually deploy.

These examples use path-style requests but do not establish virtual-hosted compatibility. Treat virtual-hosted addressing as an AWS test until the product documentation explicitly covers it.

Classify a flow by risk, not by habit

For each S3 flow, ask two questions:

  1. What can go wrong if this behaviour is wrong?
  2. Is the failure specific to AWS, our deployment topology, or a feature outside the local server's scope?

A thumbnail upload might need a unit fake for key generation, a local endpoint for the SDK round trip, and one AWS contract test for the presigned upload used by the browser. A compliance-sensitive download might additionally need AWS policy and audit checks. A background multipart transfer deserves a local state-machine test, an endpoint test if supported, and an AWS run that exercises part failures and cleanup.

The higher the impact and the stronger the AWS coupling, the less reasonable it is to stop at a local pass.

This also prevents a common mistake: sending every test to AWS simply because the application uses S3. Most request-shape and integration mistakes don't need a cloud round trip to be found. Conversely, a green local test should not be allowed to silently certify IAM, TLS, network routing, durability, availability, billing, or provider-specific semantics.

A promotion pattern that stays honest

One workable promotion path looks like this:

  1. Edit locally. Use a fake for fast domain-level tests and a local endpoint for real SDK requests, object state, and the supported error paths you care about.
  2. Run deterministic CI tests. Use a headless fake or test server designed for the CI runner. A desktop application does not belong in CI unless it explicitly supports that workflow.
  3. Gate important changes on AWS. Run a smaller contract suite against disposable or isolated AWS resources on merges, releases, or a schedule. Keep the suite focused on provider behaviour rather than repeating every unit case.
  4. Observe the deployed path. Monitor the operations that matter, with synthetic fixtures and safeguards appropriate to the data and account.

The exact cadence depends on risk. A team shipping a low-impact internal tool may run AWS checks nightly. A payment or identity flow may require a merge gate and a pre-release run. The principle is the same: make the cloud-specific confidence visible instead of pretending the local layer supplies it.

Contract tests: compare expectations, not every implementation detail

A contract test should describe the behaviour your application relies on, then run that contract against both environments where it makes sense.

Contract caseAssertionLocal resultAWS result
Put an object with a declared content typeA subsequent metadata read returns the expected content typeRun against the current local build if supportedRun against the test bucket
Read a byte rangeStatus, body length, and range metadata match the requestRun if range support is verifiedRun against AWS
Request a missing keyYour adapter receives the expected missing-object error shapeCompare the local responseCompare the AWS response
Upload through a presigned URLRequired headers, expiry, and method are honouredRun only if the local implementation supports this caseRun against AWS with a short-lived URL
Use the deployment addressing modeThe request reaches the intended bucket and regionRun only for the local addressing modeRun against the actual AWS endpoint form
Deny an operation with policyThe caller is rejected for the intended reasonUsually outside a simple local endpointRun with a least-privilege identity and policy

Contract tests should not force identical XML, headers, or error wording where your application does not depend on them. Make the differences deliberate. If a difference matters, add a cloud test or change the adapter. If it does not, keep the contract at the business-relevant level.

Keep AWS tests safe

Real-cloud tests should be boring to operate:

  • Use synthetic data and a dedicated account or isolated test resources.
  • Grant only the actions the suite needs.
  • Give each run a unique prefix, and make cleanup idempotent.
  • Set budgets, alarms, and lifecycle cleanup where appropriate.
  • Never commit long-lived credentials or reuse a developer's personal access keys.
  • Record the AWS region, SDK version, feature flags, and test date when they affect the result.

These are operational safeguards, not a substitute for security or compliance review. They simply reduce the chance that a test creates an incident of its own.

Verify that cleanup removes the disposable files and state your test created. Treat credential removal as a separate security requirement.

A short decision checklist

Before adding a test, answer:

  • Am I testing domain logic, request construction, provider behaviour, or production health?
  • Does this test require IAM, policies, regions, DNS, TLS, network paths, events, scale, or an advanced S3 API?
  • Does the local implementation support the exact operation, addressing mode, headers, and error path?
  • Is this result based on a tested release or an unverified assumption?
  • What synthetic data, permissions, cleanup, and cost controls does an AWS run need?
  • What change would trigger this allocation to be reviewed?

Review the boundary when the application adopts a new S3 feature, changes SDK or addressing configuration, introduces presigned or multipart flows, moves region, or has an incident caused by emulator divergence. A compatibility matrix is useful only while it reflects the versions and behaviours you actually run.

The practical answer

Use local S3 to shorten the loop around supported request and object behaviour. Use AWS to test the parts that belong to AWS: policies, routing, network, provider-specific APIs, and the deployment conditions that matter. Keep a fake for code that doesn't need S3 at all, and use production observability for the failures no test environment can predict completely.

That is where Objectively fits for Mac developers: an interactive local endpoint with a request feed and object browser. Check the Objectively product page for the current scope, and keep AWS tests for anything the local layer cannot prove.

Testing a second AWS-shaped local endpoint can broaden emulator coverage, but it still does not establish Amazon S3 production behaviour, IAM, durability, networking, quotas, billing, or availability.