Back to Proof of Work

Evidence map

Playwright API testingAn API risk to evidence map

How I test an API, shown through tests you can open.

Ten risks I check on any API. For each one: the tests in my public repositories that cover it, whether they run in CI, the exact lines at a pinned commit, and where the coverage is weak or missing.

What this page is

The evidence comes from three independent public projects, not from employer work: a TypeScript suite for the RESTful Booker practice API, a JavaScript suite for the Automation Exercise demo shop (API and web pages), and the API and security tests of a Razorpay Test Mode checkout app that I built as the test target. The targets are practice APIs and a payment sandbox. None of them is a client system.

I gave every API test in those repositories one risk category: all 47 tests in RESTful Booker, 32 of the 37 in Razorpay (the other five check the checkout page) and 16 of the 26 in Automation Exercise (the other ten drive the shop's web pages). The table shows where they sit. It is my tagging, and a count says how many tests touch a risk, not how well they test it.

API tests by risk category and repository, as I tagged them
RiskRESTful BookerRazorpayAutomation ExerciseAll
authentication4026
authorization5005
status codes6028
response structure75315
invalid input97622
CRUD lifecycle90110
read-back after write6028
idempotency/​replay1405
signature/​HMAC010010
error handling0606
API tests tagged47321695

Every code link opens the file at a pinned commit: RESTful Booker a385240, Razorpay da9e7a2, Automation Exercise 789d413. RESTful Booker's newest commit, 1e7f137, changed only its CI workflow file, so the tests are the same as at a385240.

What I check on any API

Each item links to its block in the evidence map below.

  • Authentication

    Can a caller prove who it is, and does a missing or wrong credential fail in a way no client could mistake for success?

  • Authorization

    Once a caller is known, can it reach only what it is allowed to? A valid token is not the same as permission for this record.

  • Status codes

    Does each outcome return the code the API documents, and does the test assert that code instead of assuming success?

  • Response structure

    Are the fields and types a client depends on present, and are fields that must stay private, such as secrets, absent?

  • Invalid input

    What happens with missing, malformed, out-of-range or oversized input: a clear rejection, silent acceptance, or a server error?

  • CRUD lifecycle

    Do create, read, update and delete work in sequence, the way a real client would use them?

  • Read-back after write

    After a change, does a separate read show it, rather than trusting the response to the write itself?

  • Idempotency and replay

    What happens when the same request arrives twice, from a retry, a double click or a redelivered webhook?

  • Signature and HMAC

    Does the server accept a correctly signed message and reject one that is unsigned, wrongly signed or changed after signing?

  • Error handling

    Do failures return a controlled message without internal details, and does the service keep working afterwards?

Evidence map

One block per risk. Each names the tests that cover it, links every test to its lines at the pinned commit, shows a short excerpt copied from the repository, and says what is weak or missing. Razorpay tests are named by their IDs; the other two repositories have no IDs, so their tests are named by file and line range. A badge describes one test or one gap:

  • Runs in CI The repository's own GitHub Actions workflow runs it. That says nothing about how strong it is.
  • Live gateway only It needs my original Razorpay Test Mode account, so CI never runs it.
  • Partly tested Some of the risk is covered; the gap is stated next to it.
  • Not tested No test in these repositories covers it. I say what I would test instead.
  • Assertion cannot fail The test passes whatever the API returns, so it proves nothing.

Authentication

Partly tested What I verify: a valid login returns a usable credential, and a failed login is reported as a failure, not as a success.

Weak: auth.spec.ts:45–51 (opens in a new tab) only checks that two tokens are truthy.

test('POST /auth with valid credentials should return token', async () => {
  const token = await authClient.createToken(config.auth.username, config.auth.password);

  expect(token).toBeTruthy();
  expect(token).toMatch(/^[a-f0-9]+$/);
  expect(token.length).toBeGreaterThan(0);
});
RESTful Booker, valid login. tests/api/auth.spec.ts, lines 14–20 @ a385240 (opens in a new tab)

Authorization

Partly tested What I verify: a caller can change only what it is allowed to change, and a missing or wrong token is refused.

What I would test: two accounts, each trying to read and change the other's data; every role against every endpoint; a token issued for one resource used on another.

test('PUT /booking/{id} without token should return 403', async () => {
  const response = await bookingClient.updateBookingResponse(bookingId, updatedBooking, '');

  expect(response.status()).toBe(403);
});

test('PUT /booking/{id} with invalid token should fail', async () => {
  const response = await bookingClient.updateBookingResponse(bookingId, updatedBooking, 'invalid_token');

  expect(response.status()).toBe(403);
});
RESTful Booker, PUT without a token and with an invalid token. tests/api/booking-update.spec.ts, lines 40–50 @ a385240 (opens in a new tab)

Status codes

Runs in CI What I verify: each outcome returns the documented code, and the test asserts the code instead of assuming success.

// Automation Exercise returns HTTP 200
// and places the actual API error code
// inside the response body.

expect(response.status()).toBe(200);
// ...
expect(body).toHaveProperty('responseCode');

expect(body.responseCode).toBe(405);
Automation Exercise, POST to a read-only list (condensed). tests/api/products.api.spec.js, lines 144–148, 160–162 @ 789d413 (opens in a new tab)

Response structure

Partly tested What I verify: the fields and types a client relies on are present, and fields that must stay private are absent.

test('POST /booking with numeric fields should maintain data types', async () => {
  const response = await bookingClient.createBooking(validBooking);

  expect(typeof response.booking.totalprice).toBe('number');
  expect(typeof response.booking.depositpaid).toBe('boolean');
  expect(typeof response.bookingid).toBe('number');
});
RESTful Booker, field types. tests/api/booking-create.spec.ts, lines 34–40 @ a385240 (opens in a new tab)
const response = await request.post('/api/payments/create-order', {});
// ...
if (response.status() === 200) {
  const body = await response.json();
// ...
} else if (response.status() === 503) {
  // Razorpay not configured - acceptable skip for this phase
  const body = await response.json();
  expect(body.error).toBe('Razorpay not configured');
}
Razorpay ORDER-001: no branch for the 400 that CI gets (condensed). tests/api/orders.spec.js, lines 40, 44–45, 61–65 @ da9e7a2 (opens in a new tab)

Invalid input

Partly tested What I verify: bad input is rejected with a clear client error, not accepted silently or answered with a server error.

test('POST /booking with checkout before checkin is accepted by API', async () => {
  const bookingWithBackwardDates = {
    ...validBooking,
    bookingdates: {
      checkin: '2025-03-20',
      checkout: '2025-03-15',
    },
  };

  const response = await bookingClient.postResponse('/booking', bookingWithBackwardDates);
  expect(response.ok).toBeTruthy();
});
RESTful Booker: response.ok without the call, line 61. tests/api/negative-testing.spec.ts, lines 51–62 @ a385240 (opens in a new tab)
if (response.status() === 200) {
  const body = await response.json();
  // Backend ignores whatever the client sent and always uses config.js's amount/currency
  expect(body.amount).toBe(50000);
  expect(body.currency).toBe('INR');
}
Razorpay ORDER-003: the only assertions sit inside this branch. tests/api/orders.spec.js, lines 31–36 @ da9e7a2 (opens in a new tab)

CRUD lifecycle

Runs in CI What I verify: create, read, update and delete work in sequence, as one flow a client would follow.

  • Runs in CI RESTful Booker · workflow.spec.ts:10–47 (opens in a new tab): authenticate, create, read, update, read again, delete, and confirm the booking is gone.
  • Runs in CI RESTful Booker · workflow.spec.ts:49–84 (opens in a new tab): create two bookings, find both in the list, update and delete one, and confirm the other still exists.
  • Runs in CI RESTful Booker · workflow.spec.ts:86–111 (opens in a new tab): create, change one field with PATCH, and confirm the other fields kept their values.
  • Runs in CI Automation Exercise · account.api.spec.js:17–62 (opens in a new tab): a fixture creates a fresh account, the test reads it back by email, and the fixture deletes it afterwards.
  • Partly tested RESTful Booker · cleanup. The workflow tests delete their bookings only after their other assertions, so a failed assertion earlier in the test skips the delete. Only six of the 47 tests delete a booking at all.
  • Not tested Two clients updating the same record at once, and filtering or paging the list. I would update one record from two sessions and check which write wins and what each response says, and request a filtered, paged list and check its contents against what I created.
// Delete first booking
await bookingClient.deleteBooking(bookingId1, token);

// Verify first is deleted, second still exists
const deleteCheck = await bookingClient.getBookingResponse(bookingId1);
expect(deleteCheck.status()).toBe(404);

const stillExists = await bookingClient.getBooking(bookingId2);
expect(stillExists.firstname).toBe(validBooking.firstname);

// Cleanup: delete second booking
await bookingClient.deleteBooking(bookingId2, token);
RESTful Booker, end of the two-booking workflow: cleanup is the last line. tests/api/workflow.spec.ts, lines 72–83 @ a385240 (opens in a new tab)

Read-back after write

Runs in CI What I verify: after a write, a separate read shows the change, instead of trusting the write's own response.

test('DELETE /booking/{id} with valid token should delete booking', async () => {
  const createResponse = await bookingClient.createBooking(validBooking);
  const bookingId = createResponse.bookingid;

  const response = await bookingClient.deleteBooking(bookingId, token);
  expect(response).toBeTruthy();
});
RESTful Booker, the delete test: a truthy body is the only check. tests/api/booking-delete.spec.ts, lines 20–26 @ a385240 (opens in a new tab)

Idempotency and replay

Partly tested What I verify: sending the same request twice does not create a second effect or corrupt state.

test('DELETE /booking/{id} twice should return 405 on second attempt', async () => {
  const createResponse = await bookingClient.createBooking(validBooking);
  const bookingId = createResponse.bookingid;

  await bookingClient.deleteBooking(bookingId, token);

  const secondDelete = await bookingClient.deleteBookingResponse(bookingId, token);
  expect(secondDelete.status()).toBe(405);
});
RESTful Booker, deleting the same booking twice. tests/api/booking-delete.spec.ts, lines 59–67 @ a385240 (opens in a new tab)

Signature and HMAC

Partly tested What I verify: a correctly signed message is accepted, and an unsigned, wrongly signed or modified one is rejected.

test('SIG-001: POST /api/payments/verify with valid signature accepts a real captured payment @razorpay-live', async ({ request }) => {
  const validSignature = generateSignature(realOrderId, realPaymentId);
  // ...
  const response = await request.post('/api/payments/verify', {
    data: {
      orderId: realOrderId,
      paymentId: realPaymentId,
      signature: validSignature,
    },
  });
  // ...
  expect(response.status()).toBe(200);
Razorpay SIG-001, tagged @razorpay-live and left out of CI (condensed). tests/security/signature-verification.spec.js, lines 37–38, 40–46, 48 @ da9e7a2 (opens in a new tab)

Error handling

Partly tested What I verify: failures return a controlled message with no internal details, and the service keeps working afterwards.

// Should fail at signature validation
expect(response.status()).toBe(401);

const body = await response.json();
const responseString = JSON.stringify(body);

// Verify no stack trace or internal details
expect(responseString).not.toContain('at ');
expect(responseString).not.toContain('Error');
expect(responseString).not.toContain('stack');
expect(responseString).not.toContain('/routes/');
Razorpay ERR-002: status first, then what the body must not contain. tests/security/error-handling.spec.js, lines 35–45 @ da9e7a2 (opens in a new tab)

How I structure Playwright API suites

API client classes

Requests go through client classes, not inline calls. In RESTful Booker a base class builds the URL and headers, and one class per resource gives each call a name, so a test reads bookingClient.createBooking(validBooking) and a header change is made in one place. Automation Exercise uses the same idea in plain JavaScript, one class per area (AccountApi.js (opens in a new tab)).

export class BookingClient extends APIClient {
  constructor(request: APIRequestContext, baseURL: string) {
    super(request, baseURL);
  }
  // ...
  async createBooking(booking: Booking): Promise<CreateBookingResponse> {
    return this.post('/booking', booking);
  }
RESTful Booker, the booking client (condensed). src/api/clients/booking-client.ts, lines 5–8, 14–16 @ a385240 (opens in a new tab)

Raw responses and parsed bodies

Each HTTP verb has two helpers. The parsed ones return the body; the …Response ones return the raw response, so a test can assert the status. The weakness is that the parsed helpers never look at the status. The delete helper below returns the text of any reply, so a 403 comes back as the string “Forbidden”, and that is why the truthy check in the delete test proves nothing. The article walks through the same split for get.

async delete(endpoint: string, token?: string): Promise<Record<string, any> | string> {
  const headers = this.getHeaders(token);
  const response = await this.request.delete(`${this.baseURL}${endpoint}`, { headers });
  const text = await response.text();
  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}
// ...
async deleteResponse(endpoint: string, token?: string): Promise<APIResponse> {
  const headers = this.getHeaders(token);
  return this.request.delete(`${this.baseURL}${endpoint}`, { headers });
}
RESTful Booker, a parsed-body helper and its raw-response twin (condensed). src/api/clients/api-client.ts, lines 44–53, 76–79 @ a385240 (opens in a new tab)

Data for each test

Each test creates the data it needs instead of sharing records. In Automation Exercise a fixture generates a unique account, creates it through the API, hands it to the test and deletes it in its teardown, which Playwright runs even when the test fails. RESTful Booker creates a booking per test, but its read, update and partial-update files do it in beforeEach and never delete it. The Razorpay webhook tests that expect an event to be accepted build a unique event ID with Date.now(), because processed IDs are stored and a fixed ID would be “already processed” on the next run (webhook-validation.spec.js, lines 43–47 (opens in a new tab)).

const userData = generateAccountData();
// ...
const createResponse =
    await accountApi.createAccount(userData);
// ...
if (!account.deleted) {

    const deleteResponse =
        await accountApi.deleteAccount(
            userData.email,
            userData.password
        );

    expect(deleteResponse.status()).toBe(200);
Automation Exercise, the account fixture (condensed). tests/fixtures/account.fixture.js, lines 20, 34–35, 86–94 @ 789d413 (opens in a new tab)

Read back after every write

A write's own response says what the server claims to have done; a separate GET says what it stored. I treat the second as the evidence. The read-back block above lists where these repositories do it, and the delete test shows what happens when a test trusts the write alone.

Retries, workers and traces

Each setting changes how much a green run means. RESTful Booker allows one retry in CI and runs one worker there (playwright.config.ts, lines 7–8 (opens in a new tab)). Razorpay allows two retries in CI with one worker, and its CI command runs Chromium only and leaves out the live tests (playwright.config.js, lines 7–8 (opens in a new tab); package.json, line 13 (opens in a new tab)). Automation Exercise allows one retry everywhere, not only in CI. All three keep a trace only on the first retry, so a run that needed no retry leaves no trace to open. A test that fails and then passes on retry still counts as passed, so I read a green run together with its retry setting.

retries: 1,
  // ...
  trace: 'on-first-retry',
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
Automation Exercise, retries and what is kept on failure (condensed). playwright.config.js, lines 9, 13–15 @ 789d413 (opens in a new tab)

What this does not prove

All of this is true of the published code at the commits above.

  • No schema or contract validation. Responses are checked field by field, and four assertions in RESTful Booker compare a whole booking with toEqual. Nothing validates a response against a schema.
  • No performance or load testing. None of the three repositories asserts a response time or sends load.
  • No role-based authorization. The only access control tested is a token being present, absent or invalid. No test uses more than one role, or one user's token on another user's data.
  • Cleanup is missing in most RESTful Booker tests. Only six of the 47 delete what they create, so the suite leaves bookings on a shared public service.
  • Some tests cannot fail. Three RESTful Booker negative tests (lines 35, 48 and 61 of negative-testing.spec.ts) pass whatever the API returns, and Razorpay's ORDER-001 and ORDER-003 run no assertions in CI. The counts on this page include them.
  • Payment verification is never accepted in CI. SIG-001 and REPLAY-001 need my original Razorpay Test Mode account, so CI only tests that payment verification rejects bad input.
  • Practice APIs and a sandbox only. RESTful Booker and Automation Exercise are public services that other people also use, and their behaviour and uptime are outside my control. The Razorpay tests run against my own app in Test Mode, so a defect found there is a bug in my code, not in Razorpay. None of it is a production system.
  • CI results are historical. The last runs were on 22 October 2026 for RESTful Booker (run #3 (opens in a new tab); by my reading of the job log, no test was retried), 14 September 2026 for Razorpay (run #3 (opens in a new tab)) and 29 August 2026 for Automation Exercise (run #17 (opens in a new tab), with retries on). Each passed when it ran; that says nothing about today. Job logs need a GitHub sign-in.
  • The categories are mine. Another tester could file some of these tests under a different risk.

Professional work

In my job I test six client projects covering donation and payment platforms, CRM and role-based access, and logistics, with API checks in Postman. I describe that work only in general terms, on the About page and the home page, and nothing on this page comes from it. It includes role-based access control on a CRM platform, which is the kind of authorization testing these public repositories cannot show.

Next steps

Need an API tested this way?

Describe the API and the risk you are worried about in the Services form. Nothing starts until scope and access are agreed.