QASimplifiedby AkashGet Free Interview Pack
← Back to Articles

GET, POST, PUT, PATCH and DELETE with Real Testing Examples | API Testing Foundations # 3

4 November 2026

Read the Full Series: API Testing Foundations

  1. API Testing Foundations # 1- What Is an API? A Practical Explanation for QA Engineers

  2. API Testing Foundations # 2 - HTTP for QA Engineers: Understanding Requests and Responses

  3. You are here → Current article

  4. Coming next: API Testing Foundations # 2 Article: HTTP for QA Engineers: Understanding Requests and Responses

In Article # 2, we split an HTTP request into four parts: method, URL, headers and body. This article goes deep on the first one, because the method decides almost everything else about how an endpoint should behave: what it may change, what happens when it’s called twice, and which status code counts as correct.

Most API suites test methods like this: send a request, check for 200, move on. That catches very little. Most method-related bugs come from a method behaving like a different one: a GET that changes data, a PUT that behaves like a PATCH, a DELETE that only hides the record, or a POST that charges a customer twice.

We’ll use one realistic API throughout this article, an e-commerce Orders service:

GET    /api/v1/orders            → list my orders
GET    /api/v1/orders/{id}       → get one order
POST   /api/v1/orders            → create an order
PUT    /api/v1/orders/{id}       → replace an order
PATCH  /api/v1/orders/{id}       → partially update an order
DELETE /api/v1/orders/{id}       → cancel/delete an order

The code examples use Playwright’s request API in TypeScript. The ideas apply the same way to REST Assured, Python requests or Postman.


Two properties to understand first: safe and idempotent

Before looking at each method, you need two words. They give you your test oracle, meaning the rule you check the API against.

Safe means the method should not change server state. Calling it is read-only.

Idempotent means calling it once or calling it ten times leaves the server in the same final state. The response can differ, but the outcome can’t.

These aren’t style suggestions. Browsers, proxies, load balancers, HTTP client libraries and retry policies all rely on them. A gateway may automatically retry a GET or PUT after a timeout because it assumes the retry is harmless. If your PUT actually increments a counter, that automatic retry becomes a production bug nobody wrote code for.

Every method section below comes back to one question: does this endpoint keep the promise its method makes?


GET: read, and nothing else

GET fetches data. It’s the most tested method and also the one tested in the least depth.

What to validate

1. It returns the right data, not just a successful response.

test('GET order returns the order that was created', async ({ request }) => {
  const created = await createOrder(request, { productId: 'P-100', quantity: 2 });

  const res = await request.get(`/api/v1/orders/${created.id}`);
  expect(res.status()).toBe(200);

  const order = await res.json();
  expect(order.id).toBe(created.id);
  expect(order.items[0].productId).toBe('P-100');
  expect(order.items[0].quantity).toBe(2);
  expect(order.status).toBe('PENDING');
});

Note the pattern: create the data first, then read it back. If you assert against “whatever order 1042 happens to contain in QA,” the test breaks as soon as someone changes that record.

2. It’s actually safe. This is the test almost nobody writes:

test('GET does not change the order', async ({ request }) => {
  const order = await createOrder(request);
  const before = await (await request.get(`/api/v1/orders/${order.id}`)).json();

  for (let i = 0; i < 5; i++) await request.get(`/api/v1/orders/${order.id}`);

  const after = await (await request.get(`/api/v1/orders/${order.id}`)).json();
  expect(after).toEqual(before);
});

Real-world violations include: GET /notifications that marks everything as read, GET /coupon/apply?code=X that consumes the coupon, GET /reports that triggers a costly regeneration, and “last viewed” timestamps that update a modifiedAt field other systems depend on. Some of these are intentional (a view count, for example), but that should be a documented decision, not something you discover by accident.

3. Access control on reads. From Article # 2: log in as User A and request User B’s order ID. The expected result is 403 or 404, never 200. Many teams deliberately return 404 so they don’t reveal that the order exists. Either is fine as long as it’s consistent and documented.

4. Collection GETs need their own tests. GET /orders brings empty lists, pagination, filters and sorting. Pagination, filtering and sorting get a full article later in the series. For now, at minimum check:

  • A new user gets [] with a 200, not a 404 and not null

  • The list only contains the caller’s own orders

  • The order you just created appears in the list

5. Not-found behaviour.

  • A valid-format ID that doesn’t exist → 404

  • An invalid-format ID (/orders/abc, /orders/-1) → 400 or 404, never 500

6. GET with a body. Some clients allow it, but servers and proxies often ignore or reject it. If an API needs a GET body to work, raise it as a design issue. Many caches and gateways will drop that body.


POST: create (and the duplicate risk)

POST creates a resource or triggers an action. It’s neither safe nor idempotent, which makes it the riskiest method in your suite.

What to validate

1. The full creation contract, not just the status.

test('POST creates an order', async ({ request }) => {
  const res = await request.post('/api/v1/orders', {
    data: { productId: 'P-100', quantity: 2 },
  });

  expect(res.status()).toBe(201);                         // Created, not 200
  expect(res.headers()['location']).toMatch(/\/api\/v1\/orders\/\w+/);

  const body = await res.json();
  expect(body.id).toBeTruthy();                            // server-generated
  expect(body.status).toBe('PENDING');                     // server default
  expect(body.total).toBe(2 * PRICE_P100);                 // server-calculated

  // Verify it really persisted — don't trust the echo
  const fetched = await request.get(`/api/v1/orders/${body.id}`);
  expect(fetched.status()).toBe(200);
});

Three things in that test matter:

  • 201 vs 200. A 200 on creation is a contract smell rather than a crash, but clients and documentation should be able to rely on 201. Agree on the standard with your team and enforce it.

  • The Location header points to the new resource. It’s often missing and rarely checked.

  • Read it back. A POST response can echo your input back even when the save failed or was partial. Only a follow-up GET, or a DB check, proves persistence.

2. Server-owned fields must stay server-owned. This is the mass assignment problem:

test('POST ignores client-supplied server fields', async ({ request }) => {
  const res = await request.post('/api/v1/orders', {
    data: {
      productId: 'P-100',
      quantity: 1,
      id: 'HACKED-1',
      status: 'SHIPPED',
      total: 1,          // ₹1 for anything
      userId: 'someone-else',
    },
  });

  const body = await res.json();
  expect(body.id).not.toBe('HACKED-1');
  expect(body.status).toBe('PENDING');
  expect(body.total).toBe(PRICE_P100);
  expect(body.userId).toBe(CURRENT_USER_ID);
});

If total: 1 is accepted, you’ve found a revenue bug. Rejecting these fields with a 400 or silently ignoring them are both acceptable. Saving them never is.

3. Duplicate submissions. Users double-click, mobile networks retry, and gateways resend after timeouts. Send the same POST twice and see what happens:

test('double submit does not create two orders', async ({ request }) => {
  const payload = { productId: 'P-100', quantity: 1 };
  const key = crypto.randomUUID();

  const [r1, r2] = await Promise.all([
    request.post('/api/v1/orders', { data: payload, headers: { 'Idempotency-Key': key } }),
    request.post('/api/v1/orders', { data: payload, headers: { 'Idempotency-Key': key } }),
  ]);

  const ids = new Set([(await r1.json()).id, (await r2.json()).id]);
  expect(ids.size).toBe(1);
});

Whether the API supports an Idempotency-Key is a design question. If it doesn’t, find out what protects against duplicate orders, because something has to. Idempotency gets its own article later in the series. For now, add a “send it twice” test for every POST that moves money or inventory.

4. POST used for actions. Not every POST creates a resource. POST /orders/{id}/cancel and POST /payments/{id}/refund trigger actions. Test them as state transitions: run them in a valid state, an invalid state (cancelling an already-shipped order should give 409 or 422), and twice in a row.


PUT: replace the whole resource

PUT means “here is the complete new version of this resource.” It’s idempotent: sending the same PUT twice should leave the resource in the same state.

The PUT bug that matters most is missing fields. If PUT is a full replacement, fields you leave out should be cleared or reset to defaults, not quietly kept. Many APIs implement PUT as “update whatever was sent,” which is really PATCH under the wrong name.

What to validate

1. Full replacement semantics.

test('PUT replaces the full resource', async ({ request }) => {
  const order = await createOrder(request, {
    productId: 'P-100', quantity: 2, giftNote: 'Happy Birthday',
  });

  const res = await request.put(`/api/v1/orders/${order.id}`, {
    data: { productId: 'P-100', quantity: 3 },   // giftNote omitted
  });
  expect(res.status()).toBe(200);

  const updated = await (await request.get(`/api/v1/orders/${order.id}`)).json();
  expect(updated.quantity).toBe(3);
  expect(updated.giftNote ?? null).toBeNull();   // replaced, not merged
});

If giftNote survives, the API is merging, not replacing. That isn’t automatically wrong, but the documentation, the developers and the clients all need to agree on it. Mobile apps often send partial objects with PUT, and if the server really replaces the resource, users lose data. Raise the behaviour as a question either way.

2. Idempotency.

test('PUT is idempotent', async ({ request }) => {
  const order = await createOrder(request);
  const payload = { productId: 'P-100', quantity: 5 };

  await request.put(`/api/v1/orders/${order.id}`, { data: payload });
  const first = await (await request.get(`/api/v1/orders/${order.id}`)).json();

  await request.put(`/api/v1/orders/${order.id}`, { data: payload });
  const second = await (await request.get(`/api/v1/orders/${order.id}`)).json();

  expect(second).toEqual({ ...first, updatedAt: second.updatedAt });
});

Note the updatedAt exclusion. Volatile fields like timestamps, version numbers and ETags will legitimately change, so strip them out before comparing.

3. PUT on a non-existent ID. Some APIs create the resource (an “upsert” returning 201). Others return 404. Both are valid designs. Your job is to confirm the behaviour is intentional, documented, and that a user can’t create resources under IDs they shouldn’t control.

4. Immutable fields. Try changing id, userId or createdAt through PUT. The URL ID and the body ID should never be allowed to disagree. PUT /orders/1042 with "id": "1043" in the body must not update order 1043.

5. State rules. Can you PUT a SHIPPED order? Usually not. Expect 409 or 422, not a successful update that changes something already in the courier’s van.


PATCH: change only what was sent

PATCH applies a partial update. Send one field, and only that field should change.

What to validate

1. Only the sent fields change.

test('PATCH updates only the provided field', async ({ request }) => {
  const order = await createOrder(request, {
    productId: 'P-100', quantity: 2, giftNote: 'Happy Birthday',
  });

  await request.patch(`/api/v1/orders/${order.id}`, { data: { quantity: 4 } });

  const updated = await (await request.get(`/api/v1/orders/${order.id}`)).json();
  expect(updated.quantity).toBe(4);
  expect(updated.giftNote).toBe('Happy Birthday');   // untouched
  expect(updated.productId).toBe('P-100');           // untouched
  expect(updated.total).toBe(4 * PRICE_P100);        // derived field recalculated
});

The last assertion is where real bugs hide. Derived fields such as totals, tax and status flags need to recalculate when an input changes. A PATCH that updates quantity but leaves total unchanged produces an order with the wrong total, and the API still returns 200.

2. null vs absent vs empty. This is the most underrated PATCH test:

APIs are often inconsistent here. Some ignore null, some crash on it, and some treat it the same as absent, which means users can never clear a field. Write a test for each row.

3. Idempotency depends on the operation. {"quantity": 4} is idempotent: it sets a value. {"op": "increment", "path": "/quantity", "value": 1} is not, because each call adds one more. If your API supports relative operations, test what happens on a retry. A network retry on an increment can add a unit nobody ordered.

4. Validation still applies. PATCH often skips validation that POST enforces, because developers write the validation for creation and forget partial updates. Try {"quantity": -5}, {"quantity": "five"} and {"status": "REFUNDED"} through PATCH even if POST already rejects them.

5. Format. If the API uses JSON Patch (application/json-patch+json) or Merge Patch (application/merge-patch+json), sending the wrong Content-Type should give 415 or 400, not a partial, half-applied update.


DELETE: remove it, and prove it’s gone

DELETE removes a resource. It’s idempotent: deleting something twice leaves it deleted.

What to validate

1. It’s really deleted, not just the response saying so.

test('DELETE removes the order', async ({ request }) => {
  const order = await createOrder(request);

  const del = await request.delete(`/api/v1/orders/${order.id}`);
  expect([200, 204]).toContain(del.status());

  const after = await request.get(`/api/v1/orders/${order.id}`);
  expect(after.status()).toBe(404);

  const list = await (await request.get('/api/v1/orders')).json();
  expect(list.items.map((o: any) => o.id)).not.toContain(order.id);
});

Check both the single GET and the list. A common bug is a deleted item that disappears from /orders/{id} but is still in /orders because of caching or a separate read model.

2. Second DELETE. Delete the same order again. The final state is the same (it’s gone), so idempotency holds. The response can reasonably be 404 or 204. What matters is that it’s documented, consistent across endpoints, and never 500.

3. Soft delete vs hard delete. Many systems don’t actually remove the row. They set deleted = true or status = CANCELLED. If so, check:

  • It’s hidden from normal GETs and lists

  • It can’t be updated by PUT or PATCH afterwards

  • It doesn’t count in totals, reports or search

  • It’s still visible where it legally must be, such as audit logs and invoices

4. Cascades and dependencies. What happens to the payment, shipment and invoice when an order is deleted? Should deleting a customer with active orders be blocked (409)? These rules are where data integrity bugs live.

5. Authorization. Deleting is the most damaging thing a user can do, so test it hardest: a different user’s resource, a read-only role, an expired token. Then verify the resource still exists afterwards. A 403 response that still deleted the record is a real and serious bug.


The method matrix: testing what shouldn’t be allowed

Every endpoint supports some methods and should reject the rest. A useful loop:

const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
const allowed: Record<string, string[]> = {
  '/api/v1/orders':      ['GET', 'POST'],
  '/api/v1/orders/{id}': ['GET', 'PUT', 'PATCH', 'DELETE'],
};

for (const [path, ok] of Object.entries(allowed)) {
  for (const m of methods.filter(m => !ok.includes(m))) {
    test(`${m} ${path} is rejected`, async ({ request }) => {
      const url = path.replace('{id}', await existingOrderId(request));
      const res = await request.fetch(url, { method: m });
      expect(res.status()).toBe(405);
      expect(res.headers()['allow']).toBeTruthy();
    });
  }
}

The expected answer is 405 Method Not Allowed, ideally with an Allow header listing the valid methods. What you’ll actually find in practice: 404s, 500s, and occasionally an unsupported method that works, such as DELETE /orders (the collection) wiping every order. That last one is rare, but it’s very serious when it happens.

Two other methods are worth knowing:

  • HEAD is GET without a body, used for existence checks and caching. Its headers should match GET’s.

  • OPTIONS is used by browsers for CORS preflight. If the frontend sees mysterious CORS errors, check OPTIONS before blaming anything else.


A full lifecycle test: the methods working together

Single-method tests check behaviour. A lifecycle test checks the sequence, which is how real clients use an API:

test('order lifecycle: create → read → update → patch → delete', async ({ request }) => {
  // CREATE
  const created = await (await request.post('/api/v1/orders', {
    data: { productId: 'P-100', quantity: 1 },
  })).json();

  // READ
  let order = await (await request.get(`/api/v1/orders/${created.id}`)).json();
  expect(order.status).toBe('PENDING');

  // REPLACE
  await request.put(`/api/v1/orders/${created.id}`, {
    data: { productId: 'P-200', quantity: 2 },
  });
  order = await (await request.get(`/api/v1/orders/${created.id}`)).json();
  expect(order.productId).toBe('P-200');

  // PARTIAL UPDATE
  await request.patch(`/api/v1/orders/${created.id}`, { data: { quantity: 3 } });
  order = await (await request.get(`/api/v1/orders/${created.id}`)).json();
  expect(order.quantity).toBe(3);
  expect(order.total).toBe(3 * PRICE_P200);

  // DELETE
  expect([200, 204]).toContain((await request.delete(`/api/v1/orders/${created.id}`)).status());
  expect((await request.get(`/api/v1/orders/${created.id}`)).status()).toBe(404);
});

Keep lifecycle tests few. When one fails, it doesn’t tell you which step broke as clearly as a focused test would. Use them as integration proof on top of focused tests, not as a replacement for them. This is the same layering idea from Automation Foundations Article # 2, applied inside the API layer. CRUD testing end to end gets a full article later in this series.


Common method bugs, as a hunting list


The takeaway

Each HTTP method makes a promise. GET promises not to change anything. PUT promises that repeating it is harmless. DELETE promises the thing is gone. POST promises nothing, which is exactly why it needs the most care.

A good API test doesn’t stop at checking that the request succeeded. It checks whether the endpoint kept the promise its method makes. Once you think about methods this way, you’ll start finding bugs that a status-code-only suite will never catch.

Try this before next week: pick one resource in your project and run the five checks from the hunting list that you’ve never run: GET safety, POST twice, PUT with a missing field, PATCH with null, and a second DELETE. Note what you find. There’s a good chance at least one result surprises you.


Next in the series: HTTP Status Codes Every QA Engineer Should Understand. This article kept saying “expect 409 or 422” and “never 500.” Next we’ll go through what each code actually means, how to choose the right one, and how to tell an API with a deliberate error strategy from one that’s guessing.