DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

Mocking Conditional Requests: ETags and 304 Not Modified

Quick answer: A mock that always returns 200 with a full body never exercises the conditional-request path, so a client bug in If-None-Match or ETag handling stays hidden until production. To mock conditional requests correctly, the mock must store a stable validator per resource, compare the incoming If-None-Match header against it, and return 304 Not Modified with no body when the validator matches, while sending 200 with a new ETag when the resource changed. The point is not to mimic the status code in isolation; it is to reproduce the validation lifecycle so your client's caching, revalidation, and stale-while-revalidate logic is actually tested. If your mock hands back the same 200 every time, your tests can pass while the real API returns 304s your client mishandles, usually by dropping the cached body or refetching everything it was supposed to reuse.

Conditional requests are one of the most useful parts of HTTP for clients that poll or cache, and one of the least represented in mock setups because the happy path is easy to record. The mechanism is compact: the server sends an ETag, the client stores it, and on the next request it sends that value back in If-None-Match. If the representation is unchanged, the server responds 304 with no body and the client keeps its cached copy; if it changed, the server returns 200 with a fresh body and a new ETag. Getting that cycle wrong is costly in real systems because it either defeats the bandwidth savings or, worse, leaves the client without a body after a 304 it did not know how to use.

What the mock needs to reproduce

The mock is a stateful participant in this exchange, not a static responder, so it needs the same three pieces a real server maintains.

Element Role Common mock mistake
ETag validator Identifies a version Omitted or random per call
If-None-Match Client's cached version Ignored entirely
304 with empty body Signals reuse cache 304 sent with a full body
Version change Forces 200 + new ETag Resource never changes

The random ETag row is a subtle but important failure: if the mock generates a new validator on every response, the client can never successfully revalidate, which is the opposite of the behavior you want to test and can mask a client that never caches at all.

Implement the conditional logic in the mock

A request handler that keeps the current version in state and branches on the header covers both the unchanged and changed paths, and it lets tests drive the transition explicitly.

Note that the 304 response ends without a body, which is the part mocks most often get wrong. A 304 that includes a representation is not a valid revalidation and can hide a client that incorrectly overwrites its cache with an empty response.

Test the full lifecycle, including a change

A useful test walks the sequence: first fetch receives 200 and an ETag, a repeat fetch with the stored validator receives 304 and the client keeps its cached body, and after the resource is updated the next fetch receives 200 with a new ETag. Skipping the last step means you only test the static case and never prove the client reacts to a changed version.

Weak validators deserve a thought when the mock is used for conditional reads. A weak ETag prefixed with W/ still works for If-None-Match but cannot be used for byte-range requests, so if your API uses weak validators, keep that distinction consistent in the mock rather than mixing strong and weak versions unpredictably.

Keep the mock aligned with the contract

Declaring the ETag response header and the If-None-Match request header in the OpenAPI description makes the conditional behavior part of the contract rather than an undocumented server habit, and generated clients can reflect it. The mock then becomes a runnable interpretation of that contract, so the documented caching behavior and the tested behavior cannot quietly diverge.

The practical rule is to model conditional requests as a stateful cycle: stable validator, header comparison, empty 304 on a match, and a fresh 200 with a new ETag after a change. A mock that always answers 200 is convenient and misleading, because it lets the client's caching logic go untested until the first real 304 arrives. Reproduce the lifecycle, test the change path too, and the client's revalidation behavior is proven before it meets production traffic.

Frequently asked questions

Why does my mock need to return 304 at all?

Because the client's revalidation logic only runs on a 304. If the mock always returns 200, the code that keeps and reuses the cached body is never exercised and can be broken in production.

Should a 304 response include a body?

No. A 304 Not Modified ends without a representation; the client is expected to use its cached copy. Including a body is invalid and can mask client bugs.

What happens if the ETag changes on every response?

The client can never revalidate successfully, so it always fetches the full body and the caching path is effectively disabled, which hides the exact behavior you are trying to test.

Do I need to test a changed resource, or just the 304?

You need both. Testing only the static 304 proves reuse; testing the update proves the client correctly accepts a 200 and new ETag when the version actually changes.

What is a weak ETag and does it matter here?

A weak validator prefixed with W/ is usable for If-None-Match reads but not for byte-range requests. If your API uses weak ETags, keep them consistent in the mock rather than mixing them with strong ones.

This is part of the The API Mocking Guide — start there for the full picture of building realistic mocks.

Top comments (0)