Q17 of 37 · API testing
What's the difference between idempotent and non-idempotent operations? Why does it matter for testing?
Short answer
Short answer: Idempotent: calling once or many times produces the same final state. PUT, DELETE, GET are idempotent. Non-idempotent: each call adds an effect — POST creates, increment counters. For testing, idempotent endpoints are safe to retry; non-idempotent need idempotency keys or careful single-call assertions.
Detail
Idempotency is a property of the outcome, not the request: after N identical calls, the system state is the same as after 1 call.
Idempotent by HTTP spec:
- GET — reading doesn't change state.
- PUT — replacing with the same body always results in the same state.
- DELETE — first call removes; second call is a no-op (or 404, depending on style).
- HEAD, OPTIONS — read-only.
Not idempotent:
- POST — creates a new resource each time. Two POSTs to
/orderscreate two orders. - PATCH — usually idempotent, but not by spec.
PATCH { op: increment }is non-idempotent.
Why it matters for tests:
1. Retry safety. Idempotent endpoints can be safely retried after timeout. Non-idempotent ones may have created the resource even though the response was lost — retrying duplicates it. Test that:
- The non-idempotent endpoint accepts an
Idempotency-Keyheader. - Replaying the same key returns the original result, not a duplicate.
2. Cleanup behaviour. Tests for idempotent endpoints can run cleanup safely:
afterEach(async () => {
await request.delete(`/users/${userId}`); // safe — DELETE is idempotent
});
A 404 on already-deleted is fine; the goal state is reached.
3. Test design. Idempotent endpoints invite "call twice, assert same response" tests. Non-idempotent need single-call assertions or explicit duplicate-handling tests:
test('POST /orders is idempotent with Idempotency-Key', async ({ request }) => {
const key = randomUUID();
const headers = { 'Idempotency-Key': key };
const body = { items: [{ sku: 'X1', qty: 2 }] };
const res1 = await request.post('/orders', { data: body, headers });
const res2 = await request.post('/orders', { data: body, headers });
expect(res1.status()).toBe(201);
expect(res2.status()).toBe(200);
expect((await res1.json()).id).toBe((await res2.json()).id);
});
Stripe's API is the canonical example — every POST accepts Idempotency-Key, every retry returns the same result.
4. Failure recovery. If a test sends a non-idempotent POST, fails partway through, and reruns, you get duplicate state. Test fixtures for these endpoints need tighter cleanup.
Common bug: server says POST is idempotent because "the response is the same," but in reality two database rows are created. Test by checking the database (or counting via GET), not just the response.