API Contract Testing: Schema, OpenAPI and Consumer-Driven Pact

API Contract Testing: Schema, OpenAPI and Consumer-Driven Pact

A backend developer renames price to amount. Every backend test still passes. The release goes out, and the Android app shows “Rs null” on every product. Contract testing exists to stop exactly this: it checks that the API keeps the promises its consumers rely on, automatically, on every change. In this lesson you’ll learn the three levels of API contract testing, and run a real consumer-driven contract with Pact against the live practice API.

Quick answerAPI contract testing verifies that a provider API and its consumers agree on the shape of requests and responses: endpoints, fields, types, required values and status codes. Three common approaches: (1) schema contract tests, which validate responses against a JSON Schema/OpenAPI; (2) spec diffing, which flags breaking changes between OpenAPI versions in CI; and (3) consumer-driven contract testing with tools like Pact, where each consumer records the interactions it needs and the provider’s build verifies it still satisfies all of them.
Advertisement

Why Functional Tests Don’t Catch Contract Breaks

Real-life example: the renamed field The backend team owns GET /products/{id} and its tests. They rename price to amount and update their own tests, which all pass. Nobody owns the question “does the Android app, the iOS app, the partner feed and the search service still understand this response?” A contract test answers it in CI before release.
Functional testContract test
QuestionDoes the API do the right thing?Does the API still speak the language consumers expect?
ChecksBusiness logic, values, side effectsFields, types, required/optional, status codes, headers
Owned byProvider teamShared between provider and consumers
CatchesWrong totals, broken rulesRenamed/removed fields, type changes, new required inputs

Level 1: Schema Contract Tests

The simplest contract: validate every response against a schema. The practice API publishes its own user schema at GET /schema/user:

GEThttps://sqatools.in/wp-json/sqa-api/v1/schema/user
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/schema/user"

Because it says "additionalProperties": false and lists every required field and type, any renamed, removed, retyped or extra field fails validation. You already wrote this test in the JSON Schema lesson; module 6 turns it into a reusable framework helper.

Strength: easy, no coordination needed. Weakness: the provider defines the schema, so it validates what the provider thinks consumers need, not what they actually use.

Advertisement

Level 2: OpenAPI Diff (Breaking-Change Detection)

If the team keeps an OpenAPI file in Git, compare the old and new versions on every pull request:

# Fails the build on breaking changes such as removed fields or new required parameters
oasdiff breaking main-openapi.yaml pr-openapi.yaml --fail-on ERR
ChangeBreaking?
Add an optional response fieldNo
Remove or rename a response fieldYes
Change a field type (number → string)Yes
Add a required request field / parameterYes
Add a new enum value in a responseOften yes (clients with strict enums crash)
Add a new endpointNo

Strength: catches changes before code is even deployed. Weakness: only as good as the spec; if the code drifts from the spec, nothing notices (combine with Level 1).

Level 3: Consumer-Driven Contract Testing with Pact

In consumer-driven contracts, each consumer writes down exactly what it sends and what it needs back. That file (the pact) is shared with the provider, whose build replays it against the real API. The provider can change anything consumers don’t use, and gets told immediately if it breaks something they do use.

  1. Consumer test runs the consumer’s real client code against a Pact mock server that returns the expected response. Pact records the interaction into a JSON pact file.
  2. The pact file is published, usually to a Pact Broker (self-hosted) or PactFlow.
  3. Provider verification replays each recorded request against the real provider and compares the real response with the contract, using matchers (type, regex) instead of exact values.
  4. can-i-deploy checks the broker before release: “is this version compatible with everything in production?”
Real-life example: microservices teams In a company with 40 microservices owned by different teams, end-to-end environments are slow and flaky. Consumer-driven contracts let the checkout team and the pricing team each test independently, while the broker guarantees their versions are compatible before either deploys.
Advertisement

Hands-On: A Real Pact Contract with the Practice API

Scenario: a MobileApp consumer needs id, name, role and active from GET /users/1. Install Pact into your Playwright project:

npm install -D @pact-foundation/pact
import { test, expect } from '@playwright/test';
import path from 'node:path';
import { PactV3, MatchersV3, Verifier } from '@pact-foundation/pact';

const { integer, string, regex, boolean } = MatchersV3;
const PACT_DIR = path.join(__dirname, '..', '..', 'pacts');

test.describe.configure({ mode: 'serial' });

test('consumer: mobile app expects this shape from GET /users/1', async () => {
  const provider = new PactV3({ consumer: 'MobileApp', provider: 'SqaUsersApi', dir: PACT_DIR });

  provider
    .given('user 1 exists')
    .uponReceiving('a request for user 1')
    .withRequest({ method: 'GET', path: '/users/1', headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': regex('application/json.*', 'application/json') },
      body: {
        data: {
          id: integer(1),
          name: string('Asha Verma'),
          role: regex('admin|editor|viewer', 'admin'),
          active: boolean(true),
        },
      },
    });

  await provider.executeTest(async (mock) => {
    // This is the consumer's real client code calling the Pact mock server
    const res = await fetch(`${mock.url}/users/1`, { headers: { Accept: 'application/json' } });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data.name).toBe('Asha Verma');
  });
});

test('provider: the real API honours the contract', async () => {
  test.setTimeout(60_000);
  await new Verifier({
    provider: 'SqaUsersApi',
    providerBaseUrl: 'https://sqatools.in/wp-json/sqa-api/v1',
    pactUrls: [path.join(PACT_DIR, 'MobileApp-SqaUsersApi.json')],
    stateHandlers: { 'user 1 exists': async () => { /* data is fixed on the practice API */ } },
    logLevel: 'warn',
  }).verifyProvider();
});

Both tests pass against the live API. Notice the contract only mentions the four fields the app uses; the provider can add fields like city freely. The matchers mean “an integer like 1”, “a string like Asha Verma”, “one of admin/editor/viewer”, so the contract doesn’t break when data values change.

Advertisement

See it fail on purpose

Change name: string('Asha Verma') to full_name: string('Asha Verma') in the consumer test, as if the app expected a renamed field. The consumer test still passes (it talks to the mock), but provider verification fails: the real API has no full_name. The real verifier output:

Verifying a pact between MobileApp and SqaUsersApi
  a request for user 1
     Given user 1 exists
    returns a response which
      has status code 200 (OK)
      includes headers "Content-Type" with value "application/json" (OK)
      has a matching body (FAILED)

1.1) has a matching body
     $.data -> Actual map is missing the following keys: full_name

That’s the early warning you want: precise, in CI, before any user sees “null”.

Provider States

given('user 1 exists') is a provider state: a precondition the provider must set up before replaying the request. In real projects the state handler seeds the database (create user 1, create an order with status “shipped”…). The practice API’s data is fixed, so the handler is empty.

Advertisement

Pact in CI/CD

# Consumer pipeline: run consumer tests, then publish the pact
npx pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_URL" --broker-token "$PACT_BROKER_TOKEN"

# Provider pipeline: verify pacts fetched from the broker (Verifier with pactBrokerUrl), publish results

# Before deploying either side
npx pact-broker can-i-deploy --pacticipant MobileApp --version "$GIT_SHA" --to-environment production

The broker shows a matrix of which consumer and provider versions are compatible, and the can-i-deploy gate prevents incompatible releases.

Choosing an Approach

SituationRecommended
One team, one public API, many unknown consumersSchema tests + OpenAPI diff
Many internal microservices, known consumersConsumer-driven contracts (Pact) + broker
Partner integrations with a signed specOpenAPI as contract + schema tests on both sides
GraphQLSchema checks (GraphQL Inspector) + Pact’s GraphQL support
Event-driven (Kafka messages)Pact message contracts or schema registry compatibility rules
Advertisement

Common Mistakes

  • Using exact values in contracts ("name": "Asha Verma") instead of matchers, so every data change breaks the contract.
  • Putting business-logic checks in contracts. Contracts check shape; functional tests check behaviour.
  • Consumers listing every field “just in case”: the contract should contain only what the consumer really uses.
  • Never running provider verification in the provider’s own pipeline.
  • Skipping the can-i-deploy gate, so incompatible versions still reach production.
Advertisement

Interview Questions: API Contract Testing

What is API contract testing?

Testing that a provider API and its consumers agree on the structure of requests and responses, such as fields, types, required values and status codes, so changes on one side don’t break the other.

What is consumer-driven contract testing?

An approach where each consumer defines the interactions it relies on in a contract (pact), and the provider verifies it satisfies all consumer contracts in its own build. Consumers drive what must stay stable.

What is Pact?

An open-source consumer-driven contract testing tool. Consumer tests run against a Pact mock server and generate pact files; the provider replays them with the Pact verifier; a Pact Broker shares contracts and answers can-i-deploy.

How is contract testing different from functional testing?

Functional tests verify behaviour and business logic. Contract tests verify the interface shape both sides depend on. Both are needed.

What are provider states in Pact?

Named preconditions, such as “user 1 exists”, that the provider sets up (usually by seeding data) before replaying an interaction during verification.

Why use matchers instead of exact values in contracts?

Matchers check type and format (integer, string, regex, each-like arrays) rather than exact data, so contracts stay valid when real data changes while still catching structural breaks.

What is a breaking change?

A change that can break existing consumers: removing or renaming fields, changing types, adding required request fields or parameters, removing endpoints, or adding enum values consumers don’t handle.

What is can-i-deploy?

A Pact Broker command that checks whether a given application version is compatible with the versions of its consumers and providers in a target environment, used as a deployment gate.

Can JSON Schema validation be considered contract testing?

Yes, it’s a provider-side form of contract testing. It ensures responses follow the published schema, but unlike consumer-driven contracts it doesn’t capture what each consumer actually uses.

When would you not use Pact?

For public APIs with many unknown consumers, where schema tests and OpenAPI breaking-change checks are more practical, or for very small systems where the coordination cost outweighs the benefit.

Advertisement
View the full API testing syllabus

gRPC API Testing Basics: Protobuf, Status Codes and Streaming

gRPC API Testing Basics: Protobuf, Status Codes and Streaming

Inside large companies, most API calls never touch a browser: the order service talks to pricing, pricing talks to inventory, inventory talks to the warehouse, millions of times a minute. Many of these internal calls use gRPC because it’s fast and strongly typed. If you test microservices, you’ll meet it sooner or later. This lesson explains gRPC from a tester’s angle and walks through real calls to a public gRPC test server, including streaming and deadlines.

Quick answergRPC is a remote procedure call framework that sends compact binary Protocol Buffers messages over HTTP/2. The API contract is a .proto file defining services, methods and message types. To test gRPC you call methods with a client (Postman, grpcurl, BloomRPC-style tools or code), check the response message fields and the gRPC status code (OK, INVALID_ARGUMENT, NOT_FOUND, PERMISSION_DENIED, UNAUTHENTICATED, DEADLINE_EXCEEDED…), test the four call types (unary, server streaming, client streaming, bidirectional), deadlines, metadata (headers) and backward compatibility of the .proto contract.
Advertisement

Why gRPC Exists (REST vs gRPC)

Real-life example: a ride-hailing app When you request a ride, dozens of internal services coordinate: driver matching, pricing, ETA, fraud checks, payments. Each request fans out to many services within a few hundred milliseconds. Small binary messages, persistent HTTP/2 connections and generated client code make gRPC a natural fit for this kind of internal traffic, while the public mobile API is often still REST or GraphQL.
REST (JSON)gRPC
ContractOpenAPI (optional).proto file (mandatory)
PayloadJSON textProtocol Buffers binary (smaller, faster to parse)
TransportHTTP/1.1 or HTTP/2HTTP/2 only
StyleResources + HTTP methodsMethods (functions) on services
StreamingLimited (SSE, WebSocket separately)Built in: server, client and bidirectional
ErrorsHTTP status codesgRPC status codes (HTTP status is usually 200)
Browser supportNativeNeeds gRPC-Web proxy
Readable with cURL?YesNo: binary, needs tools

The .proto Contract

We’ll test the public grpcb.in test server (plaintext on grpcb.in:9000, TLS on grpcb.in:9001). Its hello service is defined like this:

syntax = "proto3";
package hello;

service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse);                // unary
  rpc LotsOfReplies (HelloRequest) returns (stream HelloResponse);    // server streaming
}

message HelloRequest  { string greeting = 1; }
message HelloResponse { string reply = 1; }

Key points for testers:

  • Each field has a number (= 1). The number, not the name, is what goes on the wire. Changing or reusing numbers breaks clients.
  • In proto3, missing fields get default values (empty string, 0, false). An API can’t tell “not sent” from “sent empty” unless it uses wrappers or optional. Test how the service treats defaults.
  • Code generators create typed clients in Java, Go, Python, TypeScript and more from this file.
Advertisement

The Four Types of gRPC Calls

TypeShapeReal example
Unary1 request → 1 responseGetPrice(product_id)
Server streaming1 request → many responsesLive driver location updates for a trip
Client streamingMany requests → 1 responseUpload GPS points, get a trip summary
Bidirectional streamingMany ↔ manyChat, real-time collaboration

gRPC Status Codes (Know These for Interviews)

CodeNameRoughly like HTTPMeaning
0OK200Success
1CANCELLED499Client cancelled
2UNKNOWN500Unknown error
3INVALID_ARGUMENT400Bad input
4DEADLINE_EXCEEDED504Took longer than the client allowed
5NOT_FOUND404Resource doesn’t exist
6ALREADY_EXISTS409Duplicate
7PERMISSION_DENIED403Not allowed
8RESOURCE_EXHAUSTED429Quota / rate limit
9FAILED_PRECONDITION400/412System not in the required state
12UNIMPLEMENTED501Method not implemented
13INTERNAL500Server bug
14UNAVAILABLE503Service down; usually safe to retry
16UNAUTHENTICATED401Missing or invalid credentials

Just like 4xx vs 5xx in REST, invalid client input should give INVALID_ARGUMENT, never INTERNAL or UNKNOWN.

Advertisement

Calling gRPC Manually

Postman

  1. New → gRPC. Server URL: grpcb.in:9000 (TLS off).
  2. Choose Using server reflection (grpcb.in supports it) or import the .proto file.
  3. Select hello.HelloService / SayHello, message {"greeting": "Asha"}, click Invoke.
  4. Response: {"reply": "hello Asha"} with status 0 OK.

grpcurl (command line)

# List services via server reflection
grpcurl -plaintext grpcb.in:9000 list

# Describe a service
grpcurl -plaintext grpcb.in:9000 describe hello.HelloService

# Unary call
grpcurl -plaintext -d '{"greeting": "Asha"}' grpcb.in:9000 hello.HelloService/SayHello

# Metadata (gRPC's headers), e.g. auth
grpcurl -plaintext -H "authorization: Bearer <token>" -d '{}' my-service:50051 orders.OrderService/ListOrders
Server reflection Reflection lets tools discover services without the .proto file. It’s handy in test environments; production services often disable it, so keep the .proto files in your test repository.

Automating gRPC Tests with Playwright + grpc-js

Playwright’s runner, fixtures and reports work for any async test, so you can drive gRPC with the official Node client. Install it in your project:

npm install -D @grpc/grpc-js @grpc/proto-loader

Save the .proto above as hello.proto in the project root, then:

import { test, expect } from '@playwright/test';
import * as grpc from '@grpc/grpc-js';
import * as loader from '@grpc/proto-loader';

const pkg: any = grpc.loadPackageDefinition(loader.loadSync('hello.proto'));
const client = new pkg.hello.HelloService('grpcb.in:9000', grpc.credentials.createInsecure());
const deadline = (ms: number) => new Date(Date.now() + ms);

function sayHello(greeting: string, ms = 8_000): Promise<{ reply: string }> {
  return new Promise((resolve, reject) =>
    client.SayHello({ greeting }, { deadline: deadline(ms) }, (err: grpc.ServiceError | null, res: any) =>
      err ? reject(err) : resolve(res)));
}

test('unary call returns the expected reply', async () => {
  expect(await sayHello('Asha')).toEqual({ reply: 'hello Asha' });
});

test('server streaming returns several messages', async () => {
  const replies: string[] = await new Promise((resolve, reject) => {
    const got: string[] = [];
    const stream = client.LotsOfReplies({ greeting: 'Asha' }, { deadline: deadline(8_000) });
    stream.on('data', (m: any) => got.push(m.reply));
    stream.on('error', reject);
    stream.on('end', () => resolve(got));
  });
  expect(replies.length).toBeGreaterThan(1);
  for (const r of replies) expect(r).toBe('hello Asha');
});

test('a too-short deadline fails with DEADLINE_EXCEEDED', async () => {
  const err = await sayHello('Asha', 1).catch((e: grpc.ServiceError) => e);
  expect((err as grpc.ServiceError).code).toBe(grpc.status.DEADLINE_EXCEEDED);   // 4
});

When written, this returned hello Asha for the unary call, 10 messages from the stream, and status code 4 for the 1 ms deadline. Public test servers can be slow or offline, so for your own services run tests against a test environment you control.

Advertisement

gRPC Test Checklist

AreaWhat to test
FunctionalEvery method with valid input; response fields and enums
ValidationMissing/default fields, out-of-range numbers, invalid enums → INVALID_ARGUMENT
Not found / conflictsNOT_FOUND, ALREADY_EXISTS, FAILED_PRECONDITION
AuthNo/invalid token in metadata → UNAUTHENTICATED; wrong role → PERMISSION_DENIED
DeadlinesServer honours client deadlines; slow dependencies don’t hang callers
StreamingMessage count/order, cancellation mid-stream, errors mid-stream, back-pressure
RetriesUNAVAILABLE retried with back-off; non-idempotent calls not retried blindly
CompatibilityOld clients work after .proto changes (add fields with new numbers; never reuse numbers)
PerformanceLatency and throughput with ghz or k6’s gRPC module
SecurityTLS in non-local environments; reflection disabled in production if required

Breaking vs Safe .proto Changes

ChangeSafe?
Add a new field with a new numberSafe (old clients ignore it)
Add a new methodSafe
Rename a field (same number)Wire-safe, but breaks generated code and JSON mappings
Change a field’s typeBreaking
Change or reuse a field numberBreaking (data corruption risk)
Remove a field without reservedRisky: number may be reused later

Tools like buf breaking check these rules automatically in CI, the gRPC equivalent of OpenAPI diff tools from the OpenAPI lesson.

Advertisement

Tools for gRPC Testing

  • Postman: gRPC requests with reflection or .proto import, streaming support.
  • grpcurl: cURL-like command-line client.
  • Kreya, Insomnia: GUI clients.
  • Code: grpc-js (TypeScript), grpcio (Python), grpc-java; run them in Playwright, pytest or JUnit.
  • ghz, k6: load testing.
  • buf: linting and breaking-change detection.
Advertisement

Interview Questions: gRPC Testing

What is gRPC?

A high-performance remote procedure call framework from Google that uses Protocol Buffers for serialization and HTTP/2 for transport, with a .proto file as the contract and generated clients in many languages.

How is gRPC different from REST?

gRPC uses a mandatory .proto contract, binary Protobuf messages, HTTP/2, method-based calls and built-in streaming, and reports errors with gRPC status codes. REST uses resources, HTTP methods, mostly JSON and HTTP status codes, and works natively in browsers.

What are the four types of gRPC calls?

Unary (one request, one response), server streaming, client streaming and bidirectional streaming.

Name some gRPC status codes.

OK (0), INVALID_ARGUMENT (3), DEADLINE_EXCEEDED (4), NOT_FOUND (5), ALREADY_EXISTS (6), PERMISSION_DENIED (7), RESOURCE_EXHAUSTED (8), UNIMPLEMENTED (12), INTERNAL (13), UNAVAILABLE (14) and UNAUTHENTICATED (16).

How do you test a gRPC API manually?

Use Postman’s gRPC request or grpcurl with server reflection or the .proto file, invoke each method with valid and invalid messages, pass auth via metadata and check response fields and status codes.

Advertisement
What is a deadline in gRPC?

The maximum time the client is willing to wait for a call. When it expires, the call fails with DEADLINE_EXCEEDED and the server should stop work. Testers verify services set and honour deadlines.

What changes to a .proto file are breaking?

Changing a field’s type or number, reusing a deleted field’s number, removing fields or methods clients use, and changing a field from singular to repeated. Adding new fields with new numbers and new methods is safe.

What is gRPC metadata?

Key-value pairs sent with a call, similar to HTTP headers, used for authentication tokens, tracing ids and other context.

What is server reflection?

A gRPC service that lets clients list services and method definitions at runtime without the .proto file. It’s useful for testing tools and is often disabled in production.

Why can’t you test gRPC with plain cURL?

gRPC messages are binary Protobuf frames over HTTP/2 with specific framing, so you need a gRPC-aware client such as grpcurl, Postman or generated code.

Advertisement
View the full API testing syllabus

WebSocket and Webhook Testing: Real-Time APIs Explained

WebSocket and Webhook Testing: Real-Time APIs Explained

Not every API waits for you to ask. Live cricket scores, stock tickers, ride tracking and chat are pushed to you over WebSockets; payment confirmations, shipping updates and GitHub events are pushed to your server as webhooks. Both break in ways request-response testing never sees: dropped connections, out-of-order messages, duplicate events, forged signatures. This lesson shows you how to test both, with live echo servers and runnable Playwright code.

Quick answerWebSocket testing checks the connection handshake (HTTP 101), authentication on connect, message format and order, server pushes, heartbeats, reconnection after drops, and behaviour under many connections. Webhook testing checks that the provider sends the right event payload to your URL, that your endpoint verifies the signature, responds quickly with 2xx, handles retries and duplicate deliveries idempotently, tolerates out-of-order events and rejects replayed or tampered requests.
Advertisement

Polling vs WebSocket vs Webhook

Real-life example: waiting for a parcel Polling is calling the courier every 5 minutes: “Is it here yet?” WebSocket is staying on an open phone line with the courier, who tells you every update the moment it happens. Webhook is giving the courier your doorbell: they ring it once when the parcel arrives, and you don’t have to wait on the line.
Polling (REST)WebSocketWebhook
DirectionClient asks repeatedlyTwo-way, persistent connectionServer calls your URL on events
LatencyUp to the polling intervalMillisecondsSeconds
Typical useSimple status checksChat, live scores, trading, ride tracking, multiplayerPayments, CI events, CRM updates, shipping status
Who connectsClient → serverClient → server, then both sendProvider → your server

WebSocket Basics for Testers

  1. The client sends an HTTP request with Upgrade: websocket to a wss:// URL.
  2. The server answers 101 Switching Protocols. The connection stays open.
  3. Both sides exchange messages (text, usually JSON, or binary) in any order.
  4. Either side closes with a close code (1000 normal, 1001 going away, 1006 abnormal, 1008 policy violation, 1011 server error).

Many apps add a protocol on top: subscribe/unsubscribe messages, heartbeats (ping/pong), message ids and acknowledgements. Socket.IO, STOMP and GraphQL subscriptions are common higher-level protocols.

Advertisement

Try It: Public Echo Servers

Two free echo servers send back whatever you send, which is perfect for learning:

  • wss://echo.websocket.org: first sends a greeting like Request served by 4d896d95b55478, then echoes each message.
  • wss://ws.postman-echo.com/raw: echoes immediately.

In Postman

  1. New → WebSocket. URL: wss://ws.postman-echo.com/raw. Click Connect.
  2. Type {"event":"subscribe","match":"IND-vs-AUS"} and Send.
  3. The Messages pane shows sent (up arrow) and received (down arrow) messages with timestamps.
  4. Click Disconnect and check the close event.

In the browser console

const ws = new WebSocket('wss://ws.postman-echo.com/raw');
ws.onopen = () => ws.send(JSON.stringify({ ping: 1 }));
ws.onmessage = (e) => console.log('received', e.data);   // received {"ping":1}
ws.onclose = (e) => console.log('closed', e.code);

WebSocket Test Cases

AreaTestExpected
HandshakeConnect to the URL101; protocol/subprotocol agreed
AuthConnect without token / expired tokenRejected (401 on handshake or close 1008)
Message formatSubscribe to a topicAck message in the documented schema
Server pushTrigger an event (e.g. place an order)Update arrives within the SLA (e.g. 2 s)
OrderingSeveral rapid eventsReceived in order, or sequence numbers allow reordering
Invalid inputMalformed JSON, unknown actionError message, connection stays healthy (or documented close)
HeartbeatIdle for 60 sPing/pong keeps it alive; dead connections detected
ReconnectDrop the networkClient reconnects with back-off and resubscribes; no lost or duplicated updates
AuthorizationSubscribe to another user’s channelRefused
LoadThousands of concurrent connectionsStable latency and memory (k6, Artillery)
Advertisement

Automating WebSocket Tests in Playwright

For a pure API-level test, use the WebSocket class built into Node.js 22+ inside a Playwright test. A small helper turns events into promises:

import { test, expect } from '@playwright/test';

function openSocket(url: string): Promise<WebSocket> {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url);
    ws.onopen = () => resolve(ws);
    ws.onerror = () => reject(new Error(`could not connect to ${url}`));
  });
}

function nextMessage(ws: WebSocket, timeoutMs = 5_000): Promise<string> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('no message received')), timeoutMs);
    ws.addEventListener('message', (e) => { clearTimeout(timer); resolve(String(e.data)); }, { once: true });
  });
}

test('echo server returns the exact JSON we send', async () => {
  const ws = await openSocket('wss://ws.postman-echo.com/raw');
  const msg = { event: 'subscribe', match: 'IND-vs-AUS', id: Date.now() };

  const reply = nextMessage(ws);
  ws.send(JSON.stringify(msg));
  expect(JSON.parse(await reply)).toEqual(msg);

  ws.close(1000);
});

test('messages come back in order', async () => {
  const ws = await openSocket('wss://ws.postman-echo.com/raw');
  const received: string[] = [];
  const done = new Promise<void>((resolve) => {
    ws.onmessage = (e) => { received.push(String(e.data)); if (received.length === 5) resolve(); };
  });
  for (let i = 1; i <= 5; i++) ws.send(`ball-${i}`);
  await done;
  expect(received).toEqual(['ball-1', 'ball-2', 'ball-3', 'ball-4', 'ball-5']);
  ws.close();
});

For web apps, Playwright can also watch the browser’s own sockets with page.on('websocket', ws => ws.on('framereceived', ...)) and mock a socket server with page.routeWebSocket(), which is useful when testing how the UI reacts to pushed messages.

Webhook Basics for Testers

Real-life example: online payment A customer pays on a payment gateway’s page. The gateway POSTs an event like payment.captured to the store’s webhook URL with a JSON body and a signature header. The store verifies the signature, marks the order paid and replies 200. If the store’s server is down, the gateway retries later, so the same event can arrive twice and the store must not ship two parcels.

A typical webhook request your server receives:

POST /webhooks/payments HTTP/1.1
Content-Type: application/json
X-Signature: 5d7c3e...a91f            (HMAC-SHA256 of the raw body with a shared secret)
X-Event-Id: evt_01JD8K2Q
X-Timestamp: 1794482821

{"event":"payment.captured","payment_id":"pay_123","order_id":"ord_987","amount":229746,"currency":"INR"}
Advertisement

Webhook Test Cases

AreaTestExpected
DeliveryTrigger the event in the provider’s test modeYour URL receives the correct payload and headers
Signature validCorrect HMAC2xx; order updated
Signature invalid / missingChange one byte of the body401/400; nothing processed
ReplayResend an old request with an old timestampRejected (timestamp tolerance, e.g. 5 minutes)
DuplicatesSame event id twiceProcessed once (idempotent); second returns 2xx
Orderingpayment.refunded before payment.capturedHandled correctly (state checks or re-fetch)
RetriesYour endpoint returns 500 / times outProvider retries with back-off; eventual success processes once
SpeedMeasure response timeFast 2xx (heavy work queued), within the provider’s timeout
Unknown eventsEvent type you don’t handle2xx and ignored, not 500
SecurityHTTPS only; secret not loggedAs configured

Tools for capturing webhooks

  • webhook.site / RequestBin: get a public URL and see every request sent to it. Don’t send real customer data there.
  • ngrok / Cloudflare Tunnel: expose your local server to a provider during development.
  • Provider CLIs and dashboards: many payment providers can resend events and show delivery logs.
Advertisement

Testing Webhook Signatures in Code

Most providers sign webhooks with HMAC-SHA256 over the raw body (sometimes with the timestamp). Your receiver recomputes the signature and compares it in constant time. Here’s a self-contained test of that logic:

import { test, expect } from '@playwright/test';
import crypto from 'node:crypto';

const SECRET = 'whsec_test_123';   // test secret; real secrets come from environment variables

const sign = (rawBody: string, timestamp: number) =>
  crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex');

function verify(rawBody: string, timestamp: number, signature: string, now = Date.now() / 1000): boolean {
  if (Math.abs(now - timestamp) > 300) return false;                       // replay window: 5 minutes
  const expected = Buffer.from(sign(rawBody, timestamp), 'hex');
  const given = Buffer.from(signature, 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

const body = JSON.stringify({ event: 'payment.captured', order_id: 'ord_987', amount: 229746 });
const ts = Math.floor(Date.now() / 1000);

test('valid signature is accepted', () => {
  expect(verify(body, ts, sign(body, ts))).toBe(true);
});

test('tampered body is rejected', () => {
  const tampered = body.replace('229746', '100');
  expect(verify(tampered, ts, sign(body, ts))).toBe(false);
});

test('old (replayed) request is rejected', () => {
  const old = ts - 3600;
  expect(verify(body, old, sign(body, old))).toBe(false);
});

test('duplicate delivery is processed once', () => {
  const processed = new Set<string>();
  const handle = (eventId: string) => { if (processed.has(eventId)) return 'duplicate'; processed.add(eventId); return 'processed'; };
  expect(handle('evt_01JD8K2Q')).toBe('processed');
  expect(handle('evt_01JD8K2Q')).toBe('duplicate');
});

In an integration test, the same sign() helper lets you POST signed fake events straight to your own webhook endpoint with request.post(), so you don’t need the real provider for every run.

Advertisement

Common Bugs in Real-Time APIs

  • WebSocket server accepts connections without authentication.
  • Client never reconnects after the phone switches from Wi-Fi to mobile data.
  • Messages arrive out of order during bursts; UI shows an old score.
  • Memory leak: server keeps dead connections forever.
  • Webhook signature not checked, or checked on a re-serialised body instead of the raw body.
  • Duplicate webhook ships an order twice or credits a wallet twice.
  • Webhook handler does slow work synchronously and times out, triggering endless retries.
Advertisement

Interview Questions: WebSocket and Webhook Testing

What is the difference between a WebSocket and a webhook?

A WebSocket is a persistent two-way connection opened by the client, used for real-time messaging. A webhook is a one-way HTTP POST sent by a provider to a URL you registered when an event occurs.

How do you test a WebSocket API?

Verify the handshake (101), authentication on connect, subscribe and message schemas, server pushes within SLA, message ordering, invalid message handling, heartbeats, reconnection after network loss, channel authorization and behaviour under many concurrent connections.

What status code indicates a successful WebSocket handshake?

101 Switching Protocols.

How do you test webhooks?

Trigger events in the provider’s test mode or send signed fake events, verify payloads and headers, signature validation, quick 2xx responses, idempotent handling of duplicates, out-of-order events, retries after failures and replay protection.

Why must webhook handlers be idempotent?

Providers retry when they don’t get a timely 2xx, and networks can duplicate deliveries, so the same event may arrive more than once. Storing processed event ids prevents double processing.

How is a webhook signature verified?

The receiver recomputes an HMAC (usually SHA-256) of the raw request body, often with a timestamp, using the shared secret, and compares it with the signature header in constant time. It also rejects old timestamps to prevent replays.

Which tools do you use to test WebSockets?

Postman’s WebSocket requests, browser DevTools (Network > WS), Playwright (Node WebSocket client, page.on(‘websocket’), routeWebSocket), and k6 or Artillery for load.

How do you test webhooks locally?

Use tunnels such as ngrok to expose a local server, inspection services like webhook.site, the provider’s event replay tools, or send signed test payloads directly to your endpoint from automated tests.

What are WebSocket close codes?

Numbers sent when closing a connection, such as 1000 normal closure, 1001 going away, 1006 abnormal closure without a close frame, 1008 policy violation and 1011 internal error. Tests check the right code is used.

What would you check if live updates sometimes show stale data?

Message ordering and sequence numbers, reconnection and resubscription logic, missed messages during disconnects, caching on the client, and whether the client re-fetches state after reconnecting.

Advertisement
View the full API testing syllabus

GraphQL API Testing: Queries, Mutations, Errors and Security

GraphQL API Testing: Queries, Mutations, Errors and Security

GraphQL APIs look simple from the outside, with one URL and one POST, but they hide a surprising number of testing traps. Errors come back with HTTP 200, “not found” is a null instead of a 404, and a single query can ask for so much nested data that it slows the whole server down. In this lesson you’ll test a real public GraphQL API with Postman, cURL and Playwright, and learn the checklist that catches GraphQL-specific bugs.

Quick answerTo test a GraphQL API, send POST requests to its single endpoint with a JSON body containing query (and optional variables and operationName). Verify the data object and the errors array, not just the HTTP status, because validation errors and missing records usually return HTTP 200. Test queries, mutations, variables, nullability, field-level authorization, pagination, query depth/complexity limits and that introspection is disabled in production where required.
Advertisement

GraphQL in 2 Minutes

Real-life example: a restaurant buffet vs a fixed thali A REST endpoint is a fixed thali: GET /users/1 gives you every item on the plate whether you want it or not. GraphQL is a buffet: you pick exactly what goes on your plate, { user(id: 1) { name city } }, and nothing else. Great for the customer, but the kitchen must handle someone piling up 50 plates at once.
  • One endpoint, usually POST /graphql.
  • Schema with types and fields defines what can be asked (strongly typed).
  • Query reads, mutation writes, subscription streams updates (usually over WebSocket).
  • Response is JSON with data and/or errors.

Comparison with REST and SOAP: REST vs SOAP vs GraphQL.

The Public API We’ll Test

We’ll use the free public Countries GraphQL API at https://countries.trevorblades.com/. It needs no key and returns country data. (It’s a third-party demo service; be gentle with it and don’t load test it.)

query {
  country(code: "IN") {
    name
    capital
    currency
    languages { name }
  }
}

Real response (HTTP 200):

{
  "data": {
    "country": {
      "name": "India",
      "capital": "New Delhi",
      "currency": "INR",
      "languages": [ { "name": "Hindi" }, { "name": "English" } ]
    }
  }
}
Advertisement

Sending GraphQL Requests: cURL and Postman

cURL

curl -s https://countries.trevorblades.com/ \
  -H "Content-Type: application/json" \
  -d '{"query":"{ country(code: \"IN\") { name capital currency } }"}'

With variables (the right way)

Never build queries by gluing strings together; pass values as variables. It’s cleaner and avoids injection.

{
  "query": "query GetCountry($code: ID!) { country(code: $code) { name emoji } }",
  "variables": { "code": "JP" },
  "operationName": "GetCountry"
}

Response: {"data":{"country":{"emoji":"🇯🇵","name":"Japan"}}}

Postman

  1. New HTTP request, method POST, URL https://countries.trevorblades.com/.
  2. Body → GraphQL. Postman fetches the schema and gives autocomplete.
  3. Write the query in the QUERY box and variables in the GRAPHQL VARIABLES box. Send.

The Biggest Trap: HTTP 200 Doesn’t Mean Success

We sent three “wrong” requests to the real API. Look at the status codes:

RequestHTTP statusBody
Typo in a field name (nam)200{"errors":[{"message":"Cannot query field \"nam\" on type \"Country\". Did you mean \"name\"?", "extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
Country that doesn’t exist (ZZ)200{"data":{"country":null}}
Broken syntax (missing })400{"errors":[{"message":"The request did not contain a valid GraphQL request..."}]}
What this means for your tests A test that only checks status === 200 passes for a typo’d field and for a missing record. Every GraphQL test must assert on errors (absent, or the expected code) and on the exact data values, including null where a record doesn’t exist.

Status code behaviour varies by server (some return 400 for validation errors when using the newer “GraphQL over HTTP” conventions), so check your API’s documented behaviour and test it explicitly.

Advertisement

What to Test in a GraphQL API

AreaTest ideas
QueriesEach field returns correct values; nested objects; aliases (a: country(code:"IN") b: country(code:"JP")); fragments
VariablesRequired variable missing; wrong type (ID! given a number/null); extra variables
NullabilityNon-null fields (String!) never null; nullable fields null when data is absent
MutationsCreate/update/delete effects; returned payload; validation errors in errors or a typed error field
ErrorsError message, path, extensions.code; partial data with errors
PaginationCursor pagination (first, after, pageInfo.hasNextPage, endCursor)
AuthorizationField-level and object-level checks: a user can query me { orders } but not user(id: 2) { orders }
Performance and abuseDeeply nested queries, huge lists, aliases repeating an expensive field 100 times, batching
SchemaBreaking changes (removed fields, changed types); deprecated fields still work
IntrospectionDisabled or restricted in production if the policy says so

GraphQL Security Checks

Real-life risk: the nested query attack If author { posts { author { posts { ... } } } } can nest 20 levels, one small request can force millions of database lookups. Servers should enforce a maximum depth, a complexity/cost limit and pagination limits (e.g. first ≤ 100). Testers verify these limits return a clear error instead of timing out.
  • Introspection: { __schema { types { name } } } reveals the whole schema. Often disabled in production for internal APIs.
  • Field suggestions: “Did you mean …?” messages (like the one above) can leak hidden field names; some teams disable them in production.
  • Authorization per resolver: every field that returns sensitive data needs its own check.
  • Injection: arguments still reach databases; test them like REST inputs.
  • Batching / alias abuse: 1,000 login attempts in one request via aliases can bypass rate limiting.
Advertisement

Automating GraphQL Tests with Playwright

Playwright’s request fixture is all you need. A small helper keeps tests readable:

import { test, expect, APIRequestContext } from '@playwright/test';

const GQL = 'https://countries.trevorblades.com/';

async function gql(request: APIRequestContext, query: string, variables?: Record<string, unknown>) {
  const res = await request.post(GQL, { data: { query, variables } });
  return { status: res.status(), body: await res.json() };
}

test('query with variables returns the right country', async ({ request }) => {
  const { status, body } = await gql(request,
    'query GetCountry($code: ID!) { country(code: $code) { name capital currency } }',
    { code: 'IN' });

  expect(status).toBe(200);
  expect(body.errors).toBeUndefined();                 // the essential GraphQL check
  expect(body.data.country).toEqual({ name: 'India', capital: 'New Delhi', currency: 'INR' });
});

test('unknown record returns null, not an error', async ({ request }) => {
  const { status, body } = await gql(request, '{ country(code: "ZZ") { name } }');
  expect(status).toBe(200);
  expect(body.errors).toBeUndefined();
  expect(body.data.country).toBeNull();
});

test('invalid field returns 200 with a validation error', async ({ request }) => {
  const { status, body } = await gql(request, '{ country(code: "IN") { nam } }');
  expect(status).toBe(200);
  expect(body.data).toBeUndefined();
  expect(body.errors[0].extensions.code).toBe('GRAPHQL_VALIDATION_FAILED');
  expect(body.errors[0].message).toContain('Cannot query field "nam"');
});

test('aliases fetch two records in one request', async ({ request }) => {
  const { body } = await gql(request, '{ in: country(code: "IN") { name } jp: country(code: "JP") { name } }');
  expect(body.data).toEqual({ in: { name: 'India' }, jp: { name: 'Japan' } });
});

These tests ran green against the live API while this lesson was written. For your own APIs, also add schema validation of data (next module) and auth tests per field.

GraphQL Tools for Testers

  • Postman / Insomnia: GraphQL body type with schema autocomplete.
  • GraphiQL / Apollo Sandbox: in-browser explorer with docs from introspection.
  • Playwright, REST Assured, pytest: GraphQL is just HTTP POST, so any API framework works.
  • GraphQL Inspector: detects breaking schema changes in CI.
  • k6: load testing GraphQL operations.
Advertisement

Mutations: Testing Writes

The countries API is read-only, so here is a typical mutation shape you’ll see in real projects:

mutation AddToCart($input: AddToCartInput!) {
  addToCart(input: $input) {
    cart { id itemCount total }
    errors { field message }         # some APIs return business errors as data
  }
}
# variables: { "input": { "productId": "101", "quantity": 2 } }

Test cases: valid input updates the cart (verify with a follow-up query); quantity 0 / -1 / 1000 returns an error in errors or the typed errors field; unknown product; another user’s cart id (authorization); running the same mutation twice (idempotency where expected).

Common Mistakes When Testing GraphQL

  • Asserting only status 200.
  • Building queries with string concatenation instead of variables.
  • Testing only the fields the UI uses; other clients may request more.
  • Ignoring null in nested data (partial failures).
  • Not testing depth/complexity limits and introspection settings.
Advertisement

Interview Questions: GraphQL Testing

How is GraphQL testing different from REST testing?

GraphQL has one endpoint and a schema; clients choose fields. Tests must check the data and errors arrays rather than relying on status codes, cover field selection, variables, nullability, field-level authorization, and abuse limits such as query depth and complexity.

Why can a GraphQL error return HTTP 200?

Because GraphQL reports errors in the response body’s errors array and can return partial data. Many servers return 200 for validation and resolver errors and use 4xx only for malformed requests.

What are queries, mutations and subscriptions?

Queries read data, mutations change data, and subscriptions push real-time updates to clients, usually over WebSocket.

What are GraphQL variables and why use them?

Values passed separately from the query text in a variables object, typed in the operation definition such as $code: ID!. They make queries reusable and prevent injection from string concatenation.

What is introspection and why might it be disabled?

A built-in feature that lets clients query the schema itself (__schema, __type). It helps tooling but exposes the full API surface, so internal APIs often disable or restrict it in production.

Advertisement
How do you test GraphQL performance risks?

Send deeply nested queries, very large page sizes, many aliases of expensive fields and batched operations, and verify the server enforces depth, complexity and pagination limits with clear errors rather than slowing down.

How does pagination usually work in GraphQL?

Through cursor-based connections: arguments like first and after, and a pageInfo object with hasNextPage and endCursor, with results in edges and node.

How do you test authorization in GraphQL?

For each sensitive type and field, call queries and mutations with different roles and users, including nested paths that reach the same data, and verify unauthorized fields return errors or null without leaking data.

Which tools do you use for GraphQL testing?

Postman or Insomnia for manual testing, GraphiQL or Apollo Sandbox for exploration, Playwright or other HTTP frameworks for automation, GraphQL Inspector for schema changes and k6 for load testing.

What is a GraphQL fragment?

A reusable set of fields defined once (fragment CountryInfo on Country { name capital }) and spread into queries with …CountryInfo, keeping large queries consistent.

Advertisement
View the full API testing syllabus

How to Report API Bugs: Template, Examples and Severity

How to Report API Bugs: Template, Examples and Severity

Finding an API bug is half the job. The other half is reporting it so clearly that a developer can reproduce it in one minute, understand the impact and fix the right thing. A vague ticket like “Order API not working” bounces back and forth for days; a precise one with a cURL command gets fixed the same day. In this lesson you’ll learn the API bug report template that developers love, how to judge severity and priority, and see real examples, including genuine issues found during this course.

Quick answerA good API bug report contains: a specific title (method, endpoint, condition, wrong result), environment and build, preconditions (user, role, data), the exact request as a cURL command, the actual response (status, headers, body), the expected result with the source (spec, requirement), evidence (request/trace id, timestamp, logs), reproducibility, severity and priority, and the impact. Always remove real tokens and personal data before sharing.
Advertisement

Why API Bug Reports Are Different

UI bugs can be shown with a screenshot. API bugs live in requests and responses that nobody can see unless you capture them. That means:

  • The exact request matters: one header or one field type changes everything.
  • The exact response (status, headers, body) is the evidence.
  • Backend logs are searchable by request id and timestamp, so include them.
  • The same API serves web, Android, iOS and partners, so state which client or whether it’s reproducible directly.
Real-life example: the two-day ping-pong Ticket: “Payment fails sometimes.” Developer: “Works for me.” Tester: “Try again.” Two days later someone notices it fails only when the coupon field is null instead of missing. A cURL command with the actual body would have shown that in the first message.

The API Bug Report Template

FieldWhat to write
Title[METHOD] [endpoint] returns [actual] instead of [expected] when [condition]
EnvironmentQA / staging URL, build or version, date and time with time zone
PreconditionsUser and role, test data ids, feature flags
Steps / RequestcURL command (or Postman request) that reproduces it
Actual resultStatus code, relevant headers, response body (trimmed)
Expected resultExact status and body, with the reference (spec section, story, acceptance criteria)
EvidenceRequest/trace id, timestamp, screenshots of logs, HAR or Postman console export
ReproducibilityAlways / 3 of 10 / only with specific data
Severity / PriorityImpact on system / urgency to fix
ImpactWho is affected and how (users, money, data, security)
NotesWorkaround, first seen in build X, related tickets, your analysis
Advertisement

Writing a Great Title

Weak titleStrong title
Validation not workingPOST /users accepts age 17 (min is 18) and returns 201
Error in order APIPOST /orders returns 500 instead of 422 when quantity is negative
Security issueGET /orders/{id} returns another customer’s order (BOLA)
Wrong statusDELETE /cart/items/{id} returns 200 with empty body; spec says 204
Slow APIGET /products?category=books p95 latency 4.2 s (SLA 800 ms) on QA

A strong title lets a lead triage the bug without opening it.

Capturing the Request Correctly

  • Postman: Code (</>) → cURL. Make sure variables are resolved (check the Console).
  • Browser: DevTools → Network → right-click → Copy as cURL. Or save a HAR file for multi-request issues.
  • Playwright: attach request and response to the test report, or use the trace viewer.
  • Mobile: proxy the app through Charles, Proxyman or mitmproxy on a test device.
Sanitise before sharing Replace real tokens, cookies, API keys, passwords and personal data (names, phone numbers, Aadhaar/PAN, card numbers) with placeholders like <ADMIN_TOKEN>. Tickets are read by many people and often synced to other tools. Never paste production customer data.
Advertisement

Example 1: Validation Bug (Full Report)

Title:        POST /objects returns 500 when "ttl" is a JSON array instead of 422

Environment:  QA - https://qa.shop.example/api/v1, build 2026.11.3, 12-Nov-2026 15:20 IST
Preconditions: none (endpoint is public)

Request:
curl -i -X POST "https://qa.shop.example/api/v1/objects" \
  -H "Content-Type: application/json" \
  -d '{"name":"Bag","ttl":[60]}'

Actual:
HTTP/1.1 500 Internal Server Error
X-Request-Id: 9f2c1d7e
{"error":"Unhandled exception"}

Expected:
HTTP/1.1 422 with error.details[0].field = "ttl"
(OpenAPI: NewObject.ttl is integer 10-1800; API guidelines section 4: invalid input -> 422)

Reproducibility: 5/5. Same result for ttl = {}, true.
Severity: Medium (unhandled exception; no data impact)   Priority: P2
Impact: Any client sending a wrong type gets a 500; error monitoring is flooded.
Notes:  ttl = "60" (string) correctly returns 422, so only non-scalar types fail.

Notice the extra analysis in Notes. It narrows the cause to “non-scalar types” and saves the developer time.

Example 2: Security Bug (BOLA)

Title:        GET /orders/{id} returns other customers' orders (BOLA / IDOR)

Environment:  Staging, build 2026.11.2
Preconditions: Customer A (qa_cust_a) and Customer B (qa_cust_b), both role=customer.
               Order 70412 belongs to B.

Request (as A):
curl -i "https://staging.shop.example/api/v1/orders/70412" -H "Authorization: Bearer <TOKEN_CUSTOMER_A>"

Actual:   200 OK with B's name, phone, address and items.
Expected: 404 (or 403) and no data. (Security requirement SEC-12: users access only their own orders.)

Reproducibility: Always. Order ids are sequential, so any order can be read.
Severity: Critical   Priority: P1 (blocker for release)
Impact:   Personal data of all customers exposed to any logged-in user. Data protection risk.
Notes:    PATCH /orders/70412 as A returns 403 correctly, so only the read path lacks the ownership check.

Security bugs often go into a restricted ticket or a separate security queue. Follow your company’s disclosure process.

Advertisement

Example 3: Real Findings From This Course

While writing these lessons we tested real public services. Two genuine, reportable observations:

Stack trace in a public SOAP service

Title:   Divide with intB=0 returns SOAP Fault exposing stack trace and server file path
Service: http://www.dneonline.com/calculator.asmx (public demo calculator)
Actual:  HTTP 500, faultcode soap:Server, faultstring contains
         "System.OverflowException ... at Calculator.Divide(Int32 intA, Int32 intB) in G:\PleskVhosts\...\Calculator.asmx:line 19"
         plus header X-AspNet-Version: 2.0.50727
Expected: soap:Client fault with a safe message such as "intB must not be 0"; no stack trace, paths or version headers.
Severity: Medium (information disclosure, OWASP API8)

Inconsistent error format for unknown versions

The SQA Tools practice API returns its own error shape {"error":{"status","code","message"}} everywhere, except for unknown routes such as a wrong version, where the underlying framework answers with a different shape:

GEThttps://sqatools.in/wp-json/sqa-api/v1/../v9/users
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/../v9/users"
GET https://sqatools.in/wp-json/sqa-api/v9/users
Actual:   404 {"code":"rest_no_route","message":"No route was found ...","data":{"status":404}}
Expected: 404 in the documented format {"error":{"status":404,"code":"not_found","message":"..."}}
Severity: Low   Priority: P3
Impact:   Clients that parse error.code break on this path.

Low severity, but exactly the kind of consistency issue a careful tester reports.

Severity vs Priority

SeverityPriority
MeaningHow bad the impact on the system or users isHow soon it must be fixed
Set byUsually the testerUsually product owner / lead
LevelsCritical, High (Major), Medium, LowP1 (now), P2, P3, P4
API exampleSeverityPriority
Payment API double-charges on retryCriticalP1
BOLA exposing customer addressesCriticalP1
Admin report export returns 500 for one rare filterHighP3 (used once a month, workaround exists)
Typo in error message shown to all users at launchLowP2 (visible, cheap to fix)
Error format inconsistent on unknown routesLowP3
Advertisement

Before You Click “Create”: Self-Check

  1. Did I reproduce it at least twice, ideally with a fresh token and fresh data?
  2. Is it really a bug? Check the spec, the story and recent changes. If the spec is unclear, raise a question rather than a bug.
  3. Is it a duplicate? Search existing tickets by endpoint.
  4. Did I isolate the minimum request that fails (remove unrelated fields)?
  5. Did I include request id/timestamp so logs can be found?
  6. Did I remove secrets and personal data?
  7. Is the expected result backed by a reference, not my opinion?

Automation Failures vs Real Bugs

When an automated API test fails, triage before reporting:

  • Product bug: the API changed behaviour against the spec. Report with the request from the test report.
  • Test bug: wrong expectation, hardcoded data, order dependency. Fix the test.
  • Environment issue: service down, expired test credentials, data reset. Notify the environment owner, don’t log a product bug.
  • Flaky: passes on rerun. Investigate timing, shared data and rate limits; don’t just add retries.

Playwright makes evidence easy: attach the response to the report on failure.

test('POST /objects returns 201', async ({ request }, testInfo) => {
  const res = await request.post(`${BASE}/objects`, { data: { name: 'Bag', ttl: 60 } });
  if (res.status() !== 201) {
    await testInfo.attach('response', {
      body: JSON.stringify({ status: res.status(), headers: res.headers(), body: await res.text() }, null, 2),
      contentType: 'application/json',
    });
  }
  expect(res.status()).toBe(201);
});
Advertisement

Communicating With Developers

  • Be factual: “returns 500” not “the API is broken”.
  • Offer analysis, not blame: “fails only for non-scalar ttl values” helps more than “validation is missing everywhere”.
  • For critical bugs, message the developer or lead directly as well as filing the ticket.
  • Retest promptly when fixed, on the same data plus related cases (fixes often break neighbours).
  • Close the loop: add an automated regression test so it can’t come back.
Advertisement

Interview Questions: API Bug Reporting

What do you include in an API bug report?

A specific title, environment and build, preconditions, the exact request as cURL, the actual status, headers and body, the expected result with a reference, request id and timestamp, reproducibility, severity, priority, impact and any analysis or workaround, with secrets removed.

What is the difference between severity and priority?

Severity is the degree of impact of the defect on the system or users; priority is the urgency of fixing it based on business needs. A high-severity bug can have low priority and vice versa.

Give an example of high severity and low priority.

A crash (500) in a rarely used admin export with an available workaround: technically severe but can wait for a later release.

Give an example of low severity and high priority.

A wrong brand name or spelling in an error message shown to every user on launch day: little functional impact, but needs fixing immediately.

Why include a cURL command in an API bug?

It reproduces the exact request, including method, headers and body, on any machine in seconds, removing ambiguity and “works on my machine” delays.

Why include a request id or timestamp?

It lets developers find the exact request in server logs and distributed traces across services, speeding up root-cause analysis.

What would you do if the developer says it’s not a bug?

Check the reference (spec, story, acceptance criteria) and share it. If requirements are unclear, involve the product owner to decide, and update the spec or tests based on the decision.

How do you decide if an automated test failure is a real bug?

Rerun and analyse: check whether the API behaviour changed against the spec, whether the test or data is wrong, whether the environment is unhealthy, or whether it’s flaky due to timing or shared data. Only product behaviour issues become bug reports.

How do you report a security vulnerability found in an API?

Follow the company’s security process: restricted ticket or security channel, minimal reproduction with test accounts, clear impact, no public sharing and no exploitation beyond proving the issue.

What is a good bug title format for APIs?

[METHOD] [endpoint] returns [actual result] instead of [expected result] when [condition], for example “POST /orders returns 500 instead of 422 when quantity is negative”.

Advertisement
View the full API testing syllabus

Swagger and OpenAPI for Testers: Read Specs and Generate Tests

Swagger and OpenAPI for Testers: Read Specs and Generate Tests

On most projects the first thing you get for a new API is a link to its Swagger page. Testers who can read an OpenAPI specification properly start testing on day one, spot gaps in the contract before code is written, and generate collections, mocks and even test cases from it in minutes. This lesson teaches you to read and use OpenAPI as a tester, with a real specification for part of the SQA Tools practice API.

Quick answerOpenAPI (formerly called Swagger) is a standard YAML/JSON format that describes a REST API: its endpoints (paths), methods, parameters, request bodies, responses, schemas, and authentication (securitySchemes). Swagger UI renders it as interactive documentation where you can “Try it out”. Testers use the spec to design test cases, find missing or ambiguous rules, import requests into Postman, generate mocks and contract tests, and detect breaking changes between versions.
Advertisement

Swagger vs OpenAPI: Names Explained

TermWhat it is
OpenAPI Specification (OAS)The standard itself (versions 2.0, 3.0, 3.1). 2.0 was called “Swagger 2.0”.
Swagger UIInteractive HTML documentation generated from an OpenAPI file
Swagger EditorBrowser editor to write and validate specs
Swagger Codegen / OpenAPI GeneratorGenerate client SDKs and server stubs from the spec
Redoc, Stoplight, ScalarAlternative documentation renderers

So “send me the Swagger” usually means “send me the OpenAPI file or the Swagger UI link”. The official demo is the Petstore at petstore3.swagger.io; open it in a new tab to explore while you read.

Anatomy of an OpenAPI File

Here is an OpenAPI 3.0 description of three practice API endpoints. Read it top to bottom:

openapi: 3.0.3
info:
  title: SQA Tools Practice API
  version: 1.0.0
servers:
  - url: https://sqatools.in/wp-json/sqa-api/v1
paths:
  /users/{id}:
    get:
      summary: Get one user
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: The user
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
  /objects:
    post:
      summary: Create a temporary object
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewObject' }
      responses:
        '201':
          description: Created
          headers:
            Location: { schema: { type: string, format: uri } }
            ETag:     { schema: { type: string } }
        '413': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
  /auth/me:
    get:
      security: [ { bearerAuth: [] } ]
      responses:
        '200': { description: Current user }
        '401': { $ref: '#/components/responses/Error' }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
  schemas:
    User:
      type: object
      required: [id, name, username, email, role, city, age, active, created_at]
      additionalProperties: false
      properties:
        id:         { type: integer, minimum: 1 }
        name:       { type: string }
        username:   { type: string }
        email:      { type: string, format: email }
        role:       { type: string, enum: [admin, editor, viewer] }
        city:       { type: string }
        age:        { type: integer, minimum: 18 }
        active:     { type: boolean }
        created_at: { type: string, format: date-time }
    NewObject:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        data: { type: object }
        ttl:  { type: integer, minimum: 10, maximum: 1800, default: 600 }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                required: [status, code, message]
Advertisement

Reading it as a tester

SectionWhat it tells youTest cases it gives you
serversBase URLs per environmentEnvironment variables in Postman/Playwright
paths + methodsEvery endpointCoverage checklist; 405 for undeclared methods
parametersPath/query/header params with types, required flags, rangesMissing, wrong type, boundaries (id 0, -1, abc)
requestBody schemaRequired fields, lengths, enums, defaultsBVA on ttl 9/10/1800/1801; name 0/1/100/101 chars; default ttl = 600
responsesEvery documented status code and schemaOne test per code; schema validation per response
headers in responsesLocation, ETag, rate limit headersHeader assertions
securityWhich endpoints need which auth401 without token for each secured operation
additionalProperties: falseNo extra fields allowedUnknown field → 422; responses don’t leak fields
Advertisement

Using Swagger UI to Test

  1. Open the Swagger UI link. Operations are grouped by tags.
  2. Click Authorize and paste your token or key once; it’s applied to secured calls.
  3. Expand an operation → Try it out → fill parameters/body → Execute.
  4. Swagger UI shows the cURL command, request URL, response code, body and headers.
  5. Compare the actual response with the documented one below it. Every mismatch is a bug in the API or in the docs.
Production Swagger UI “Try it out” against production can create real orders or send real emails. Use test environments. Also check whether Swagger UI should be publicly exposed at all in production (OWASP API9: inventory and exposure).
Advertisement

Finding Bugs in the Spec Itself (Shift-Left)

Reviewing the spec before development is one of the highest-value things a tester can do. Questions to ask:

  • Missing limits: name: { type: string } with no maxLength. What happens with 1 MB?
  • Missing error responses: only 200 documented. What about 400, 401, 404, 422, 429?
  • Ambiguous types: money as number (float) instead of integer paise or a decimal string.
  • Inconsistent naming: userId in one place, user_id in another.
  • Missing pagination on list endpoints that will grow.
  • Security gaps: sensitive endpoints without security.
  • Enum changes: are new values allowed later without breaking mobile apps?
Real-life example In a spec review for a refund API, a tester noticed amount had no maximum and no relation to the original payment. The team added “amount must not exceed captured amount minus previous refunds”. The would-be bug (refunding more than was paid) never reached code.

Spec vs Reality: Contract Testing

The spec is only useful if the API matches it. Compare them:

  • Schema validation of every response against the spec’s schemas (Ajv in Playwright, jsonSchema in Postman).
  • Undocumented endpoints: list what’s deployed and compare. The practice API exposes a discovery endpoint you can diff against docs:
GEThttps://sqatools.in/wp-json/sqa-api/v1/endpoints
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/endpoints"
  • Undocumented status codes: the API returns 409 but the spec never mentions it.
  • Breaking changes between versions: tools like openapi-diff or oasdiff compare two spec files and flag removed endpoints, new required fields or changed types. Run them in CI on every pull request that touches the spec.
Advertisement

Generating Things from the Spec

GoalToolHow it helps testers
Postman collectionPostman → Import → OpenAPI file/URLAll requests created in seconds, with example bodies
SoapUI/ReadyAPI projectImport Swagger/OpenAPIResources and methods created automatically
Mock serverStoplight Prism (prism mock openapi.yaml), Postman mocksTest front-ends and write tests before the backend exists
TypeScript typesopenapi-typescriptTyped request/response objects in Playwright tests
Fuzz / property testsSchemathesisThousands of generated edge-case requests; any 5xx or schema mismatch fails
Security scanOWASP ZAP API scanAutomated checks on every documented endpoint
# Mock the API locally from the spec
npx @stoplight/prism-cli mock openapi.yaml          # serves on http://127.0.0.1:4010

# Fuzz the real (test) API from the spec
pip install schemathesis
schemathesis run openapi.yaml --url https://qa.example.com/api --checks all

# Detect breaking changes between two versions of the spec
oasdiff breaking old-openapi.yaml new-openapi.yaml

Using the Spec in Playwright Tests

import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import fs from 'node:fs';
import YAML from 'yaml';

const spec = YAML.parse(fs.readFileSync('openapi.yaml', 'utf8'));
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const validateUser = ajv.compile(spec.components.schemas.User);
const BASE = spec.servers[0].url;

test('GET /users/{id} matches the OpenAPI User schema', async ({ request }) => {
  for (const id of [1, 2, 3]) {
    const res = await request.get(`${BASE}/users/${id}`);
    expect(res.status()).toBe(200);
    const ok = validateUser((await res.json()).data);
    expect(ok, JSON.stringify(validateUser.errors)).toBe(true);
  }
});

test('every documented error code for /users/{id} is reachable', async ({ request }) => {
  const documented = Object.keys(spec.paths['/users/{id}'].get.responses);   // ['200','400','404']
  const seen = new Set([
    (await request.get(`${BASE}/users/1`)).status(),
    (await request.get(`${BASE}/users/abc`)).status(),
    (await request.get(`${BASE}/users/999`)).status(),
  ].map(String));
  expect([...seen].sort()).toEqual(documented.sort());
});

The tests now fail automatically when the API drifts from its contract or the contract changes without the tests.

Advertisement

Writing Spec-Based Test Cases: A Quick Recipe

  1. For each operation, list every documented response code: one test each.
  2. For each parameter and body field: required/optional, type, format, min/max, enum → negative and boundary tests.
  3. For each security requirement: no token, invalid token, wrong role.
  4. For each response schema: schema validation on positive tests.
  5. For each response header: assertion.
  6. Then add what the spec can’t express: business rules, state transitions, cross-field rules, performance.

Common Mistakes

  • Trusting the spec blindly. It’s often out of date; the tests should prove it.
  • Only using Swagger UI for manual clicks and never importing the spec into tools.
  • Ignoring required and nullable differences (missing vs null).
  • Not versioning the spec in Git alongside the code.
Advertisement

Interview Questions: Swagger and OpenAPI

What is the difference between Swagger and OpenAPI?

OpenAPI is the specification standard for describing REST APIs. Swagger was its original name (Swagger 2.0) and is now the name of a set of tools such as Swagger UI, Swagger Editor and Swagger Codegen that work with OpenAPI files.

How do testers use Swagger/OpenAPI?

To understand endpoints, parameters, schemas and status codes; design test cases; try requests in Swagger UI; import collections into Postman or SoapUI; validate responses against schemas; generate mocks and fuzz tests; and detect breaking changes between versions.

What are the main sections of an OpenAPI file?

openapi version, info, servers, paths (operations with parameters, requestBody and responses), components (schemas, responses, parameters, securitySchemes) and security.

What is a $ref in OpenAPI?

A reference to a reusable definition, usually under components, such as $ref: ‘#/components/schemas/User’, so schemas and responses are defined once and reused.

How do you test that an API matches its OpenAPI specification?

Validate responses against the spec’s schemas in automated tests, check every documented status code and header, compare deployed endpoints with documented ones, and run tools like Schemathesis or contract tests in CI.

What is a breaking change in an API?

A change that can break existing clients: removing an endpoint or field, renaming a field, changing a type, adding a new required request field, or narrowing allowed values. Tools like oasdiff detect them by comparing specs.

How can you start testing before the backend is ready?

Use the OpenAPI spec to write test cases and automated tests, and run them against a mock server generated from the spec (Prism or Postman mocks). When the real API is deployed, point the same tests at it.

What would you look for when reviewing an API spec?

Missing validation limits, missing error responses, ambiguous types (especially money and dates), inconsistent naming, missing pagination, missing security on sensitive endpoints, and unclear enum and nullability rules.

What is the use of the “Try it out” button in Swagger UI?

It sends real requests to the API from the documentation page and shows the request URL, cURL command, status, headers and body, which is useful for quick exploration in test environments.

Should Swagger UI be exposed in production?

It depends on whether the API is public. For internal APIs it’s often disabled or protected in production, because it exposes the full attack surface. Testers should check it matches the company’s policy.

Advertisement
View the full API testing syllabus

Negative and Boundary Value Testing for APIs with Real Examples

Negative and Boundary Value Testing for APIs with Real Examples

Any developer can make an API work with perfect input. Your job as a tester is to find what happens with everything else: the empty field, the age of 17, the price of minus one, the 5 MB name, the emoji, the duplicated key. This is where most API bugs and many security issues live. In this lesson you’ll learn a systematic method for negative and boundary testing, and run more than 40 real cases against a live validation endpoint.

Quick answerNegative API testing sends invalid, missing, malformed or unexpected input and checks the API rejects it safely: the right 4xx status (400 for unparseable requests, 422 for rule violations, 415 for wrong content type, 413 for oversized bodies), a clear field-level error message, no data saved and never a 5xx. Boundary value analysis tests the edges of every limit: min-1, min, max and max+1 for numbers, lengths, sizes, dates and list counts.
Advertisement

Why Negative Testing Matters So Much

Real-life examples Negative quantity: an online store accepted "quantity": -1, so the line total became negative and the order total dropped below zero. Unvalidated amount: a wallet top-up API trusted an amount field sent from the client. 500 on emoji: a delivery app crashed when a customer named their address “Home 🏠”. Each one is a missing negative test.
  • Users make mistakes, old app versions send old formats, partner systems send surprises, and attackers send everything.
  • Correct rejection protects data integrity: a bad request must not half-save.
  • Clear error messages reduce support tickets and help front-end developers show the right field error.

The Validation Endpoint We’ll Test

POST /validate/user on the practice API validates a user payload without saving it. Its rules:

FieldRule
nameRequired string, 2 to 50 characters (spaces-only rejected)
emailRequired, valid email format
ageRequired whole number, 18 to 100 (not a string)
roleOptional: admin, editor or viewer (default viewer)
phoneOptional: 10-digit Indian mobile number starting with 6 to 9
Anything elseRejected as “Unknown field”
POSThttps://sqatools.in/wp-json/sqa-api/v1/validate/user
curl -i \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"A","email":"asha@","age":"30","role":"manager","salary":1}' \
  "https://sqatools.in/wp-json/sqa-api/v1/validate/user"

One request, five errors, all reported together with the field name. That’s the behaviour to expect (and to ask for) from any API.

Advertisement

Technique 1: Boundary Value Analysis

Developers commonly write < where they meant <=. Boundaries catch those off-by-one bugs.

Fieldmin-1minmaxmax+1
age (18-100)17 → 42218 → 200100 → 200101 → 422
name length (2-50)1 char → 4222 chars → 20050 chars → 20051 chars → 422
ttl on /objects (10-1800)9 → 42210 → 2011800 → 2011801 → 422
per_page (1-50)0 → 4001 → 20050 → 20051 → 400
expires_in on login (5-3600)4 → 4225 → 2003600 → 2003601 → 422
Request body size (10 KB)––under 10 KB → okover 10 KB → 413

Other boundaries to look for in real APIs: dates (today, past, far future, Feb 29, time zones), list sizes (0 items, 1, max items in a cart), money (0, 0.01, max transaction limit), file sizes and counts (5th vs 6th saved address).

Technique 2: Equivalence Partitioning by Data Type

For each field, test one value from every “class” of wrong input:

ClassExample for agePractice API result
Valid30200
Number as string“30”422
Decimal30.5422
Negative-1422
Nullnull422
Missing key(no age)422
Huge number1e309422
Boolean / array / objecttrue, [30], {“v”:30}Should be 422

Do the same for strings (empty, spaces only, very long, Unicode, emoji, HTML, SQL), enums (wrong value, wrong case “Admin”), and formats (email without domain, phone with country code, date in the wrong format).

Advertisement

Technique 3: Structural (Request-Level) Negative Tests

These break the request itself, not one field:

CaseHowExpected
Malformed JSON{"name": "Asha", 400 invalid_json
Empty bodyNo body400 missing_body
Wrong top-level type[{"name":"Asha"}]400 invalid_body
Wrong Content-Typetext/plain415
Oversized bodyOver 10 KB413
Unknown field"salary": 1422 “Unknown field”
Duplicate keys{"age":30, "age":17}Consistent rule (practice API: last value wins → 422)
Wrong methodDELETE /products/101405 + Allow
Wrong URL / version/v9/users404, no stack trace
Unsupported AcceptAccept: text/csv406
POSThttps://sqatools.in/wp-json/sqa-api/v1/validate/user
curl -i \
  -X POST \
  -H "Content-Type: application/json" \
  -d '[{"name":"Asha"}]' \
  "https://sqatools.in/wp-json/sqa-api/v1/validate/user"
Duplicate keys: a real security issue If a gateway reads the first "amount" and the backend reads the last one, an attacker can get a request approved for Rs 1 and executed for Rs 10,000. Whatever the rule, all layers must agree. Ideally, duplicate keys are rejected.

Technique 4: Cross-Field and Business Rule Violations

  • min_price greater than max_price: the practice API returns 400 “min_price cannot be greater than max_price”.
  • End date before start date in a booking.
  • Coupon valid only above Rs 500 applied to a Rs 499 cart.
  • Delivery pincode outside the service area.
  • Body id different from the URL id in a PUT (practice API: 422).
  • Transfer amount above the account balance or the daily limit.
GEThttps://sqatools.in/wp-json/sqa-api/v1/products?min_price=5000&max_price=100
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/products?min_price=5000&max_price=100"
Advertisement

Technique 5: Special Characters, Unicode and Injection Strings

InputWhyExpected
आशा वर्मा, Zoë, O'BrienReal namesAccepted and stored exactly
😀🏠4-byte UTF-8 (breaks some databases)Accepted or cleanly rejected; never 500
" "Whitespace onlyTreated as empty (practice API: 422)
" Asha "Leading/trailing spacesTrimmed or rejected consistently
' OR '1'='1SQL injectionStored as text or rejected; no SQL errors
<script>alert(1)</script>Stored XSSStored as text, escaped on display
\u0000, control charactersNull bytesRejected
10,000-character stringLength limits, buffer issues422/413, not 500

What a Good Error Response Looks Like

{
  "error": {
    "status": 422,
    "code": "validation_failed",
    "message": "2 field(s) failed validation.",
    "details": [
      { "field": "age",   "message": "age is required: a whole number from 18 to 100 (not a string)." },
      { "field": "phone", "message": "phone must be a 10-digit Indian mobile number starting with 6-9." }
    ]
  }
}

Check every negative response for:

  • The specific status code (not a generic 400 for everything, never 200, never 500).
  • A machine-readable code and the field name, so apps can highlight the right input.
  • All errors at once, not one per attempt.
  • A human-readable message without internals (no SQL, stack traces or class names).
  • The same error format on every endpoint.
  • No side effects: nothing created, updated, charged or emailed.
Advertisement

Automating 40+ Negative Cases with Playwright

import { test, expect } from '@playwright/test';

const URL = 'https://sqatools.in/wp-json/sqa-api/v1/validate/user';
const valid = { name: 'Asha', email: 'asha@example.com', age: 30 };

type Case = { title: string; body: Record<string, unknown>; status: number; field?: string };

const cases: Case[] = [
  // Boundaries
  { title: 'age 17',  body: { ...valid, age: 17 },  status: 422, field: 'age' },
  { title: 'age 18',  body: { ...valid, age: 18 },  status: 200 },
  { title: 'age 100', body: { ...valid, age: 100 }, status: 200 },
  { title: 'age 101', body: { ...valid, age: 101 }, status: 422, field: 'age' },
  { title: 'name 1 char',   body: { ...valid, name: 'A' },            status: 422, field: 'name' },
  { title: 'name 2 chars',  body: { ...valid, name: 'As' },           status: 200 },
  { title: 'name 50 chars', body: { ...valid, name: 'a'.repeat(50) }, status: 200 },
  { title: 'name 51 chars', body: { ...valid, name: 'a'.repeat(51) }, status: 422, field: 'name' },
  // Types
  { title: 'age as string', body: { ...valid, age: '30' }, status: 422, field: 'age' },
  { title: 'age decimal',   body: { ...valid, age: 30.5 }, status: 422, field: 'age' },
  { title: 'age null',      body: { ...valid, age: null }, status: 422, field: 'age' },
  { title: 'name spaces',   body: { ...valid, name: '   ' }, status: 422, field: 'name' },
  // Formats and enums
  { title: 'email no domain',  body: { ...valid, email: 'asha@' },          status: 422, field: 'email' },
  { title: 'unknown role',     body: { ...valid, role: 'manager' },        status: 422, field: 'role' },
  { title: 'phone with +91',   body: { ...valid, phone: '+919876543210' }, status: 422, field: 'phone' },
  { title: 'valid phone',      body: { ...valid, phone: '9876543210' },    status: 200 },
  { title: 'unknown field',    body: { ...valid, salary: 1 },              status: 422, field: 'salary' },
  { title: 'unicode name',     body: { ...valid, name: 'आशा वर्मा' },       status: 200 },
];

for (const c of cases) {
  test(`validate/user: ${c.title} -> ${c.status}`, async ({ request }) => {
    const res = await request.post(URL, { data: c.body });
    expect(res.status()).toBe(c.status);
    if (c.field) {
      const fields = (await res.json()).error.details.map((d: any) => d.field);
      expect(fields).toContain(c.field);
    }
  });
}

test('malformed JSON -> 400 without internals', async ({ request }) => {
  const res = await request.post(URL, { headers: { 'Content-Type': 'application/json' }, data: '{"name": "Asha", ' });
  expect(res.status()).toBe(400);
  expect(await res.text()).not.toMatch(/exception|stack|\.php/i);
});

test('wrong content type -> 415', async ({ request }) => {
  const res = await request.post(URL, { headers: { 'Content-Type': 'text/plain' }, data: 'name=Asha' });
  expect(res.status()).toBe(415);
});

Each row is a test with a readable name in the report, and adding a case is one line.

Advertisement

Negative Testing Checklist

  1. Every required field missing, one at a time, and all together.
  2. Every field: wrong type, null, empty, too short, too long, boundary values.
  3. Every enum: unknown value, wrong case.
  4. Every format: invalid email, phone, date, URL, UUID.
  5. Body level: malformed, empty, array, oversized, duplicate keys, unknown fields.
  6. Headers: wrong or missing Content-Type, Accept, auth.
  7. Cross-field and business rules.
  8. Unicode, special characters and injection strings.
  9. Responses: correct 4xx, clear field errors, no internals, no side effects.
Advertisement

Interview Questions: Negative and Boundary API Testing

What is negative testing in APIs?

Sending invalid, missing, malformed or unexpected input to verify the API rejects it gracefully with the correct 4xx status, a clear error message, no data changes and no server errors.

What is boundary value analysis? Give an API example.

Testing at the edges of allowed ranges, where off-by-one bugs occur. For an age field allowed from 18 to 100, test 17, 18, 100 and 101 and expect 422, 200, 200 and 422.

Which status codes should negative API tests expect?

400 for malformed or unparseable requests, 422 for validation rule failures, 415 for unsupported content type, 413 for oversized payloads, 405 for wrong methods, 406 for unsupported Accept, 401/403 for auth problems and 404 for unknown resources. Never 5xx.

What is the difference between equivalence partitioning and boundary value analysis?

Equivalence partitioning groups inputs that should behave the same and tests one value per group. Boundary value analysis tests values at and around the edges of those groups. They’re usually used together.

How do you test a numeric field in an API?

Valid values, boundaries (min-1, min, max, max+1), zero, negative numbers, decimals, very large numbers, numbers as strings, null, missing field, booleans, arrays and objects.

What should a good validation error response contain?

A specific 4xx status, a machine-readable error code, field-level details naming each failing field, a clear human-readable message, all errors at once, a consistent format and no internal implementation details.

Why test duplicate keys in JSON?

JSON parsers handle duplicates differently (first wins, last wins or error). If different layers interpret them differently, attackers can bypass validation, for example passing a small amount through a gateway while the backend processes a large one.

How do you confirm a rejected request had no side effects?

After the negative request, query the relevant resources, lists, balances or counters (or the database) to confirm nothing was created, changed or triggered, such as emails or payments.

Is a 500 error acceptable for invalid input?

No. Invalid client input should always produce a 4xx validation error. A 500 indicates missing validation or an unhandled exception and should be logged as a defect.

How do you organise many negative test cases in automation?

Use data-driven tests: a table of inputs, expected status and expected error field, looped by one test function, so each row appears as a named test and new cases are added as data.

Advertisement
View the full API testing syllabus

SoapUI REST Testing, Property Transfer and Data-Driven Tests

SoapUI REST Testing, Property Transfer and Data-Driven Tests

SoapUI isn’t only for SOAP. Many enterprise QA teams use one SoapUI project for both their legacy SOAP services and their newer REST APIs. In this lesson you’ll build a REST project against the live SQA Tools practice API, assert JSON with JSONPath, pass a login token between steps with Property Transfer, write small Groovy scripts and run a data-driven test from a CSV file, all with the free open-source edition.

Quick answerIn SoapUI, create a REST project from a URI or an OpenAPI file, define resources and methods, and add requests to TestCases. Assert JSON with JsonPath Match, JsonPath Existence, Contains and Valid HTTP Status Codes. Pass values between steps with the Property Transfer step (e.g. $.access_token → a TestCase property used as Bearer ${#TestCase#token}). For data-driven testing in the open-source edition, read a CSV in a Groovy Script step and loop the request; ReadyAPI offers DataSource and DataSource Loop steps without code.
Advertisement

Create a REST Project

  1. File → New REST Project.
  2. URI: https://sqatools.in/wp-json/sqa-api/v1/users/1 and click OK.
  3. SoapUI splits it into a service (endpoint https://sqatools.in), a resource (/wp-json/sqa-api/v1/users/1), a method (GET) and a request.
  4. Click the green arrow. Switch the response view to JSON; check Raw for status and headers.

Alternatively, Import an OpenAPI/Swagger file (File → Import Swagger/OpenAPI, or via the plugin in some versions) to create every resource at once.

GEThttps://sqatools.in/wp-json/sqa-api/v1/users/1
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/users/1"

Better structure with parameters

Edit the resource path to /wp-json/sqa-api/v1/users/{id}. SoapUI adds a TEMPLATE parameter id. Add QUERY parameters such as role, page, per_page on a /users resource. Parameter styles: QUERY, TEMPLATE (path), HEADER, MATRIX, PLAIN.

Create a TestSuite for the REST API

TestCaseSteps
Users – readGET user 1, GET user 999, GET user abc
Users – paginationGET page 2 per_page 5, GET per_page 51
Objects – CRUDPOST, GET, PATCH, DELETE, GET (404)
Auth – token flowPOST login, Property Transfer, GET /auth/me
Validation – data drivenGroovy loop over CSV + POST /validate/user

Right-click a request → Add to TestCase, as you did for SOAP.

Advertisement

JSON Assertions in SoapUI

AssertionExampleExpected
Valid HTTP Status Codes–200
JsonPath Match$.data.nameAsha Verma
JsonPath Existence Match$.data.emailtrue
JsonPath Count$.data (on page 2)5
JsonPath RegEx Match$.data.email.+@.+\..+
Contains–"role":"admin"
Response SLA–2000 ms
Script AssertionGroovy logicSee below

For older SoapUI versions without JsonPath assertions, SoapUI also exposes JSON responses as XML, so XPath Match works on them too (e.g. //data/name).

Script Assertion (Groovy) for business rules

import groovy.json.JsonSlurper

def json = new JsonSlurper().parseText(messageExchange.responseContent)
def order = json.data

order.items.each { item ->
    assert item.line_total == item.quantity * item.unit_price
}
def subtotal = order.items.sum { it.line_total }
assert order.summary.subtotal == subtotal
assert Math.abs(order.summary.total - (subtotal * 1.18)) < 0.01
assert messageExchange.timeTaken < 2000

Attach it to a GET /orders/5001 step: it proves the totals are internally consistent, which no simple Contains check can.

Property Transfer: Chain Login → Protected Call

Real-life example A banking regression suite logs in once per TestCase, then calls GetAccounts, GetStatement and Transfer with the same session token. Property Transfer copies the token from the login response into every later request automatically.
  1. Step 1, REST Request “Login”: POST /wp-json/sqa-api/v1/auth/login, media type application/json, body:
    { "username": "${#Project#username}", "password": "${#Project#password}" }
  2. Add TestCase property token (empty).
  3. Step 2, Property Transfer: Source = Login, Property = Response, path language JSONPath, path $.access_token. Target = TestCase, Property = token.
  4. Step 3, REST Request “Me”: GET /wp-json/sqa-api/v1/auth/me, add header Authorization = Bearer ${#TestCase#token}.
  5. Assert on Step 3: status 200, JsonPath $.data.role = admin.

Set project properties username = admin and password = admin@123 (these are public practice credentials). For real systems, pass passwords at run time with -P on the command line instead of saving them in the project file.

Property expansion syntax

SyntaxReads from
${#Project#name}Project property
${#TestSuite#name}TestSuite property
${#TestCase#name}TestCase property
${StepName#Response#$.path}Directly from an earlier step’s response (JSONPath/XPath)
${#Global#name}, ${#Env#VAR}Global property / OS environment variable
${=new Date().time}Inline Groovy expression
Advertisement
Advertisement

Groovy Script Steps: Small Code, Big Wins

A Groovy Script test step can generate data, read files, call other steps and log. Some everyday snippets:

// Unique test data
def uid = UUID.randomUUID().toString().take(8)
testRunner.testCase.setPropertyValue("objectName", "soapui-" + uid)

// Read a property and log it
def token = testRunner.testCase.getPropertyValue("token")
log.info "Token starts with: " + token?.take(12)

// Read the previous step's JSON response
def resp = context.expand('${Create Object#Response}')
def id = new groovy.json.JsonSlurper().parseText(resp).id
testRunner.testCase.setPropertyValue("objectId", id)

// Run another step by name
testRunner.runTestStepByName("Delete Object")

Data-Driven Testing from a CSV (Open Source)

Goal: test the age boundaries of POST /validate/user (18 to 100) from a file.

age,expected
17,422
18,200
100,200
101,422

TestCase “Validation – data driven” with three steps:

  1. Validate (REST Request, disabled so it runs only when called): POST /wp-json/sqa-api/v1/validate/user with body
    { "name": "Asha", "email": "asha@example.com", "age": ${#TestCase#age} }
  2. Loop (Groovy Script):
    def rows = new File(context.expand('${#Project#dataDir}') + "/ages.csv").readLines().drop(1)
    def failures = []
    rows.each { line ->
        def (age, expected) = line.split(",")*.trim()
        testRunner.testCase.setPropertyValue("age", age)
        def result = testRunner.runTestStepByName("Validate")
        def status = result.messageExchange.responseStatusCode
        log.info "age=${age} -> ${status} (expected ${expected})"
        if (status != (expected as int)) failures << "age ${age}: got ${status}, expected ${expected}"
    }
    assert failures.isEmpty() : failures.join("\n")

The script fails the TestCase with a clear list of mismatches. Add more columns (name length, email format) to cover other fields.

POSThttps://sqatools.in/wp-json/sqa-api/v1/validate/user
curl -i \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"Asha","email":"asha@example.com","age":101}' \
  "https://sqatools.in/wp-json/sqa-api/v1/validate/user"
In ReadyAPI Use a DataSource step (CSV, Excel, JDBC database, JSON) and a DataSource Loop step instead of the Groovy loop. Each column becomes a property automatically.
Advertisement

Environments and Endpoints

  • Use a project property for the host (${#Project#host}) in the service endpoint.
  • Override at run time: testrunner.sh -Phost=https://qa.example.com ....
  • ReadyAPI adds an Environments switcher similar to Postman’s.

Common SoapUI Problems and Fixes

ProblemFix
415 Unsupported Media Type on POSTSet the request Media Type to application/json
Property shows as literal ${...}Check spelling and scope (#Project vs #TestCase); property names are case-sensitive
JsonPath returns nothingTest the path in the Outline tab; JSON root is $
SSL errors on test serversImport the certificate into SoapUI’s JRE keystore; avoid disabling checks globally
Project file merge conflicts in GitEnable “Composite project” so each test is a separate file
Slow or flaky public serviceAdd a Delay step or retries; increase socket timeout in Preferences
Advertisement

Running REST Suites in CI

./testrunner.sh \
  -s"SQA Practice REST" \
  -PbaseHost=https://sqatools.in \
  -Ppassword="$QA_PASSWORD" \
  -r -j -f ./reports \
  SQA-Practice-soapui-project.xml

Publish reports/*.xml with your CI’s JUnit report step. A failing assertion returns a non-zero exit code and fails the build.

SoapUI vs Postman vs Playwright for REST

SoapUIPostmanPlaywright
SOAP supportExcellent (WSDL-driven)Basic (raw XML)Manual (raw XML)
REST supportGoodExcellentExcellent
Scripting languageGroovyJavaScriptTypeScript
Data-drivenGroovy (free) / DataSource (paid)CSV/JSON in Runner/NewmanNative loops and fixtures
Best fitEnterprise SOAP + REST teamsExploration, mixed-skill teamsEngineering-led automation, UI + API
Advertisement

Interview Questions: SoapUI REST and Data-Driven Testing

Can SoapUI test REST APIs?

Yes. Create a REST project from a URI or an OpenAPI definition, define resources, methods and parameters, and use JSON-aware assertions such as JsonPath Match together with status code, SLA and script assertions.

What is Property Transfer in SoapUI?

A test step that copies a value from one place to another, for example extracting $.access_token from a login response with JSONPath and storing it in a TestCase property used by later requests.

What are the property levels in SoapUI?

Global, Project, TestSuite, TestCase and TestStep properties, plus environment variables. They are referenced with property expansion syntax such as ${#Project#name} or ${#TestCase#name}.

How do you do data-driven testing in SoapUI open source?

Read data from a CSV, Excel or database in a Groovy Script step, set TestCase properties for each row and run the request step by name in a loop, asserting results per row. ReadyAPI provides DataSource and DataSource Loop steps without code.

What are TEMPLATE and QUERY parameters in SoapUI REST?

TEMPLATE parameters are path parameters inside the resource path, like {id} in /users/{id}. QUERY parameters are appended after the question mark, like ?role=admin.

What is Groovy used for in SoapUI?

Script steps and script assertions: generating data, parsing responses, complex validations, controlling test flow, reading files and databases, and logging.

How do you override environment values when running SoapUI from the command line?

Use testrunner with -P options to override project properties, for example -Phost=https://qa.example.com, and keep endpoints and credentials as properties in the project.

How do you validate JSON values in SoapUI?

With JsonPath Match, JsonPath Existence, JsonPath Count and JsonPath RegEx assertions, or with a Groovy Script Assertion using JsonSlurper for complex rules.

What is a composite project in SoapUI?

A project saved as a folder of files instead of one large XML, which reduces merge conflicts when several testers work in the same project under version control.

Why would a team use SoapUI instead of Postman for REST?

When the team already uses SoapUI for SOAP services and wants one tool, project and CI runner for both, or prefers Groovy scripting and SoapUI’s assertion model.

Advertisement
View the full API testing syllabus

SoapUI Tutorial: SOAP Web Service Testing Step by Step

SoapUI Tutorial: SOAP Web Service Testing Step by Step

Banks, insurers, airlines, telecom operators and government systems still run on SOAP web services, and SoapUI is the tool their QA teams use to test them. In this SoapUI tutorial you’ll install SoapUI, import a real WSDL, send SOAP requests, add assertions (including XPath and SOAP Fault checks), build a test suite and even find a real bug in a public SOAP service. No coding needed.

Quick answerSoapUI is an open-source tool for testing SOAP and REST APIs. For SOAP testing you create a project from a WSDL; SoapUI generates a request template for every operation. You fill in values, send the request and add assertions such as SOAP Response, Not SOAP Fault, Contains, XPath Match, Schema Compliance and Response SLA. Requests are organised as Project → TestSuite → TestCase → TestStep, and suites can run from the command line with testrunner. ReadyAPI is the paid version with extra features.
Advertisement

Where You’ll Meet SOAP in Real Projects

Real-life examples Banking: core banking systems expose operations like GetAccountBalance and FundTransfer to the mobile banking middleware. Insurance: agent portals call CreateQuote and IssuePolicy. Telecom: billing systems expose GetBillSummary. Travel: airline reservation systems and GDS integrations. Many of these were built 10 to 20 years ago and are still business-critical.

Service companies working for these clients regularly ask for “SoapUI” in QA job descriptions. If you’ve only tested REST, this lesson closes the gap. Background: REST vs SOAP vs GraphQL.

Key SOAP Terms Before You Start

TermMeaning
WSDLWeb Services Description Language: the XML contract listing operations, messages, types and the endpoint URL
OperationA function the service offers, e.g. Add, GetBalance
Envelope / Header / BodyThe parts of every SOAP message
SOAP FaultThe standard error element inside Body (faultcode, faultstring, detail)
BindingHow messages are sent: SOAP 1.1 or SOAP 1.2
NamespaceA URI that qualifies element names, e.g. xmlns:tem="http://tempuri.org/"
SOAPActionHTTP header (SOAP 1.1) naming the operation
XSDXML Schema defining the data types
Advertisement

Step 1: Install SoapUI

  1. Download SoapUI Open Source from soapui.org (Windows, macOS, Linux). It’s free; ReadyAPI is the commercial edition.
  2. Install and launch it. The left panel is the Navigator (project tree); the main area opens editors for requests and tests.
SoapUI vs ReadyAPI Open source covers everything in this course: SOAP and REST requests, assertions, test suites, properties, Groovy scripts, data-driven loops (with a little scripting) and command-line runs. ReadyAPI adds point-and-click DataSource steps, richer reports, service virtualisation and team features.

Step 2: Create a SOAP Project from a WSDL

We’ll use a free public demo calculator service:

http://www.dneonline.com/calculator.asmx?WSDL
  1. File → New SOAP Project.
  2. Project name: Calculator. Initial WSDL: paste the URL above.
  3. Keep Create Requests ticked. Click OK.
  4. SoapUI reads the WSDL and creates two bindings, CalculatorSoap (SOAP 1.1) and CalculatorSoap12 (SOAP 1.2), each with four operations: Add, Subtract, Multiply, Divide, and a sample request “Request 1” under each.

Public demo services can be slow or occasionally offline. If it doesn’t respond, try again later; the steps are identical for your company’s WSDL.

Step 3: Send Your First SOAP Request

Expand CalculatorSoap → Add and double-click Request 1. SoapUI generated this template:

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:tem="http://tempuri.org/">
   <soapenv:Header/>
   <soapenv:Body>
      <tem:Add>
         <tem:intA>?</tem:intA>
         <tem:intB>?</tem:intB>
      </tem:Add>
   </soapenv:Body>
</soapenv:Envelope>

Replace the ? with 5 and 7, then click the green Submit arrow. The response:

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" ...>
   <soap:Body>
      <AddResponse xmlns="http://tempuri.org/">
         <AddResult>12</AddResult>
      </AddResponse>
   </soap:Body>
</soap:Envelope>

Use the tabs on the response side: XML (body), Raw (HTTP status and headers: HTTP/1.1 200 OK, Content-Type: text/xml) and the time in milliseconds at the bottom.

The same call with cURL, so you see what SoapUI sends under the hood:

curl -i -X POST "http://www.dneonline.com/calculator.asmx" \
  -H "Content-Type: text/xml; charset=utf-8" \
  -H 'SOAPAction: "http://tempuri.org/Add"' \
  -d '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tem="http://tempuri.org/"><soapenv:Body><tem:Add><tem:intA>5</tem:intA><tem:intB>7</tem:intB></tem:Add></soapenv:Body></soapenv:Envelope>'
SOAP 1.1 vs 1.2 on the wire SOAP 1.1 uses Content-Type: text/xml plus a separate SOAPAction header. SOAP 1.2 uses Content-Type: application/soap+xml; action="..." and the namespace http://www.w3.org/2003/05/soap-envelope. Try the same operation under CalculatorSoap12 to see the difference in the Raw tab.
Advertisement

Step 4: Build a Test Suite

Requests under the interface are for exploring. Real tests live in test suites:

  1. Right-click the Add request → Add to TestCase → create TestSuite Calculator Regression and TestCase Add - positive.
  2. The request becomes a TestStep. Add more steps for Subtract, Multiply and Divide the same way.
LevelExampleHolds
ProjectCalculatorInterfaces (WSDL), test suites, project properties
TestSuiteCalculator RegressionRelated test cases
TestCaseDivide – negativeOrdered test steps (one scenario)
TestStepSOAP Request, Properties, Property Transfer, Groovy Script, Delay, Conditional GotoOne action

Step 5: Add Assertions

Open a test step, click the Assertions tab at the bottom, then +:

AssertionChecksExample
SOAP ResponseResponse is a valid SOAP messageAlways add it
Not SOAP FaultNo Fault in the bodyPositive cases
SOAP FaultA Fault is presentNegative cases
Contains / Not ContainsText or regex in the response<AddResult>12</AddResult>; Not Contains “Exception”
XPath MatchValue at an XPath equals expectedSee below
Schema ComplianceResponse matches the WSDL/XSD typesContract check
Response SLAResponse time under N ms2000 ms
Valid HTTP Status CodesStatus is in a list200
Script AssertionCustom Groovy logicComplex rules

XPath Match assertion

Click Declare to let SoapUI add the namespaces, then write:

declare namespace soap='http://schemas.xmlsoap.org/soap/envelope/';
declare namespace ns1='http://tempuri.org/';
//ns1:AddResponse/ns1:AddResult

Expected result: 12. Namespace-free alternative that’s handy when prefixes change: //*[local-name()='AddResult'].

Why XPath beats “Contains” A Contains check for “12” also passes if the response says <ErrorCode>1203</ErrorCode>. XPath Match checks the value of the exact element, which is what a bank auditor would expect for an amount field.
Advertisement

Step 6: Negative Tests and SOAP Faults

Create a TestCase Divide - negative and send intA = 5, intB = 0. The real response:

HTTP/1.1 500 Internal Server Error
Content-Type: text/xml; charset=utf-8
X-AspNet-Version: 2.0.50727
X-Powered-By: ASP.NET

<soap:Fault>
  <faultcode>soap:Server</faultcode>
  <faultstring>System.Web.Services.Protocols.SoapException: Server was unable to process request.
    ---> System.OverflowException: Arithmetic operation resulted in an overflow.
    at Calculator.Divide(Int32 intA, Int32 intB) in G:\PleskVhosts\...\Calculator.asmx:line 19 ...</faultstring>
  <detail />
</soap:Fault>

Add assertions SOAP Fault and Valid HTTP Status Codes = 500. Then look closer, as a tester should. This demo service shows three issues you’d report in a real project:

  1. Information disclosure (security): the faultstring exposes a .NET stack trace, internal class names and the server’s file path. The X-AspNet-Version header reveals the framework version. (OWASP API8, Security Misconfiguration.)
  2. Wrong fault code: dividing by zero is a client input problem, so it should be soap:Client with a clear message such as “intB must not be 0”, not soap:Server.
  3. Misleading message: “overflow” instead of “division by zero”.

More negative cases to add: non-numeric input (abc, returns a fault), integer overflow (2147483647 + 1, also a fault), missing elements, empty body, wrong SOAPAction, wrong namespace, and malformed XML. Add a Not Contains assertion for “Exception” and “line” on every negative case to catch stack-trace leaks automatically.

Step 7: Properties: Stop Hardcoding Values

Define values once and reuse them with ${...} expansions:

  • Project property endpoint = http://www.dneonline.com/calculator.asmx, then set the request endpoint to ${#Project#endpoint}, so switching environments is one change.
  • TestCase property a = 20, b = 5, then the request uses <tem:intA>${#TestCase#a}</tem:intA>.
  • Expected values in XPath assertions can also use properties.

Passing values between steps (for example a session id from Login into GetBalance) uses Property Transfer, covered in the next lesson with REST examples: SoapUI REST and Data-Driven Testing.

Advertisement

Step 8: Run Suites and Read Results

  • Double-click the TestSuite and press the green Run button; each case turns green or red.
  • Open a failed step: the assertion panel shows exactly which assertion failed and why.
  • The TestCase Log shows timings per step; Raw tabs show the full HTTP exchange.

Command line and CI

# SoapUI ships testrunner.sh / testrunner.bat in its bin folder
./testrunner.sh -s"Calculator Regression" -r -j -f ./reports Calculator-soapui-project.xml
#  -s  test suite to run      -r  print summary report
#  -j  JUnit-style XML reports -f  output folder
#  -P endpoint=https://qa.bank.example/CoreBanking   override a project property

The whole project is one XML file (Calculator-soapui-project.xml), so keep it in Git and run it in Jenkins or GitHub Actions.

Testing SOAP Without SoapUI

SOAP is just XML over HTTP POST, so Postman (raw XML body + headers), cURL and Playwright can call it too. Playwright example from the same service:

import { test, expect } from '@playwright/test';

const URL = 'http://www.dneonline.com/calculator.asmx';
const envelope = (op: string, a: number | string, b: number | string) =>
  `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tem="http://tempuri.org/">
     <soapenv:Body><tem:${op}><tem:intA>${a}</tem:intA><tem:intB>${b}</tem:intB></tem:${op}></soapenv:Body>
   </soapenv:Envelope>`;

async function call(request: any, op: string, a: number | string, b: number | string) {
  return request.post(URL, {
    headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: `"http://tempuri.org/${op}"` },
    data: envelope(op, a, b),
  });
}

test('Multiply 6 x 7 = 42', async ({ request }) => {
  const res = await call(request, 'Multiply', 6, 7);
  expect(res.status()).toBe(200);
  expect(await res.text()).toMatch(/<MultiplyResult>42<\/MultiplyResult>/);
});

test('Divide by zero returns a SOAP Fault', async ({ request }) => {
  const res = await call(request, 'Divide', 5, 0);
  expect(res.status()).toBe(500);
  expect(await res.text()).toContain('<soap:Fault>');
});

Use SoapUI when the team relies on WSDL-driven work and non-coders; use code when SOAP tests must live alongside REST and UI tests in one framework.

Advertisement

SOAP Testing Checklist

  • WSDL loads; operations match the specification.
  • Every operation: valid input → correct values (XPath), Not SOAP Fault, schema compliant, within SLA.
  • Invalid input → SOAP Fault with correct faultcode (Client vs Server) and a useful message.
  • No stack traces, file paths or versions in faults or headers.
  • Both SOAP 1.1 and 1.2 bindings if both are published.
  • WS-Security: unsigned, expired or tampered messages are rejected.
  • XML attacks: XXE and very large or deeply nested XML are rejected.
Advertisement

Interview Questions: SoapUI and SOAP Testing

What is SoapUI?

An open-source tool for functional testing of SOAP and REST APIs. It creates requests from WSDL or OpenAPI definitions, supports assertions, properties, test suites, Groovy scripting, data-driven tests and command-line execution. ReadyAPI is the commercial version.

What is a WSDL?

An XML document describing a SOAP service: its operations, input and output messages, data types (XSD), bindings (SOAP 1.1/1.2) and endpoint address. SoapUI uses it to generate request templates.

What is the hierarchy of a SoapUI project?

Project, then TestSuite, then TestCase, then TestStep. The project also holds interfaces generated from WSDL/OpenAPI and properties at each level.

Which assertions do you use in SoapUI?

SOAP Response, Not SOAP Fault or SOAP Fault, Contains/Not Contains, XPath Match, XQuery Match, Schema Compliance, Response SLA, Valid HTTP Status Codes and Script Assertion for custom Groovy logic.

What is an XPath Match assertion?

It evaluates an XPath expression against the response (with declared namespaces) and compares the result with an expected value, for example //ns1:AddResult equals 12.

How are errors returned in SOAP?

As a SOAP Fault element inside the Body with faultcode, faultstring and optional detail, usually with HTTP status 500. SOAP 1.2 uses Code, Reason and Detail elements.

What is the difference between SOAP 1.1 and SOAP 1.2?

SOAP 1.1 uses Content-Type text/xml with a separate SOAPAction header and the schemas.xmlsoap.org envelope namespace. SOAP 1.2 uses application/soap+xml with the action as a parameter, the w3.org 2003/05 namespace, and a different fault structure.

How do you run SoapUI tests in CI?

With the testrunner command-line tool included with SoapUI, passing the project file, suite name and options for JUnit reports and property overrides. CI tools such as Jenkins then publish the reports.

What is the difference between SoapUI and ReadyAPI?

SoapUI Open Source is free and covers core functional testing. ReadyAPI is commercial and adds data-source steps without scripting, advanced reporting, service virtualisation, performance testing and team collaboration features.

What would you check in a SOAP Fault during testing?

The correct HTTP status, the fault code (Client for bad input, Server for server errors), a clear and safe message, no stack traces or internal paths, and the detail element if the contract defines error codes.

Advertisement
View the full API testing syllabus

cURL for API Testing: Commands Every Tester Should Know

cURL for API Testing: Commands Every Tester Should Know

cURL is the universal language of API debugging. Developers paste cURL commands into bug tickets, browsers export failing requests as cURL, Postman imports and exports it, and every Linux server and Windows 10+ machine already has it installed. If you can read and write cURL, you can reproduce any API problem in seconds, on any machine, without opening a tool. This lesson gives you every cURL command a tester needs, each runnable against the live practice API.

Quick answercURL is a command-line tool for sending HTTP requests. The options testers use most: -X (method), -H (header), -d / --data (body), -i (show response headers), -v (verbose: full request and TLS details), -u (Basic auth), -L (follow redirects), -o (save output), -s (silent), -w (print status code and timing) and --max-time (timeout). Example: curl -i -X POST URL -H "Content-Type: application/json" -d '{"name":"A"}'.
Advertisement

Why Testers Should Learn cURL

Real-life example: “works on my machine” A tester reports “Create order fails”. The developer can’t reproduce it. The tester opens browser DevTools → Network, right-clicks the failing request → Copy as cURL, and pastes it into the ticket. The developer runs it and sees the exact 500 instantly: the app was sending quantity as a string. Ten minutes instead of two days of back-and-forth.
  • Exact, shareable reproduction steps in one line.
  • Available on servers and CI machines where there’s no GUI.
  • Shows the raw truth: no tool silently adding headers.
  • Imports straight into Postman (Import → Raw text) and other tools.

Check your install: curl --version. On Windows, use curl.exe in PowerShell (plain curl may be an alias for Invoke-WebRequest in older PowerShell), or use Git Bash.

Your First cURL Commands (GET)

BASE=https://sqatools.in/wp-json/sqa-api/v1

# Body only
curl $BASE/users/1

# Status line + headers + body  (use this by default when testing)
curl -i $BASE/users/1

# Headers only (HEAD request)
curl -I $BASE/users/1

# Query parameters - quote the URL so the shell doesn't treat & as "run in background"
curl -i "$BASE/users?role=admin&sort=age&order=desc"

# Let curl encode tricky values
curl -i -G "$BASE/users" --data-urlencode "q=gupta" --data-urlencode "per_page=5"
GEThttps://sqatools.in/wp-json/sqa-api/v1/users?role=admin&sort=age&order=desc
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/users?role=admin&sort=age&order=desc"
Advertisement

POST, PUT, PATCH, DELETE

# POST JSON (create)
curl -i -X POST "$BASE/objects" \
  -H "Content-Type: application/json" \
  -d '{"name":"cURL object","data":{"qty":1},"ttl":300}'

# POST JSON from a file (great for big payloads)
curl -i -X POST "$BASE/objects" -H "Content-Type: application/json" -d @order.json

# Newer curl (7.82+): --json sets Content-Type and Accept for you
curl -i --json '{"name":"short form","ttl":60}' "$BASE/objects"

# PUT (create/replace with your own id)
curl -i -X PUT "$BASE/objects/curl-demo-1" \
  -H "Content-Type: application/json" \
  -d '{"name":"Replaced","data":{"qty":5},"ttl":300}'

# PATCH (partial update)
curl -i -X PATCH "$BASE/objects/curl-demo-1" \
  -H "Content-Type: application/json" -d '{"data":{"qty":6}}'

# DELETE
curl -i -X DELETE "$BASE/objects/curl-demo-1"

# Form-encoded body (HTML forms, OAuth token endpoints)
curl -i -X POST "$BASE/echo" -d "name=Asha&city=Pune"

# Multipart file upload
curl -i -X POST https://your-app.test/api/upload -F "file=@invoice.pdf" -F "type=invoice"
Quoting on Windows In cmd.exe, single quotes don’t work. Use double quotes and escape inner ones: -d "{\"name\":\"A\"}", or put the JSON in a file and use -d @body.json. Git Bash and WSL accept the Linux style shown here.

Headers and Authentication

# Any header
curl -i "$BASE/users/1" -H "Accept: application/xml"

# Basic auth - curl builds the Base64 header for you
curl -i -u sqa_user:sqa@123 "$BASE/basic/profile"

# API key
curl -i -H "X-API-Key: sqa-key-12345" "$BASE/apikey/profile"

# Bearer token: log in, extract the token with jq, then call
TOKEN=$(curl -s -X POST "$BASE/auth/login" -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin@123"}' | jq -r .access_token)
curl -i -H "Authorization: Bearer $TOKEN" "$BASE/auth/me"

# Conditional request with an ETag
curl -i "$BASE/products/101" -H 'If-None-Match: "c2aee9e25c4daa9445361e1bb4a749f7"'

# Cookies: save them, then send them back
curl -c cookies.txt -i -X POST https://your-app.test/login -d "user=qa&pass=..."
curl -b cookies.txt -i https://your-app.test/account

jq is a small JSON command-line tool (jq .data.name, jq '.data | length') that pairs perfectly with cURL.

GEThttps://sqatools.in/wp-json/sqa-api/v1/apikey/profile
curl -i \
  -H "X-API-Key: sqa-key-12345" \
  "https://sqatools.in/wp-json/sqa-api/v1/apikey/profile"
Advertisement

Debugging with -v and -w

# Verbose: DNS, TLS handshake, every request and response header
curl -v "$BASE/health"
#  > GET /wp-json/sqa-api/v1/health HTTP/2       (lines with > are what you SENT)
#  < HTTP/2 200                                  (lines with < are what you RECEIVED)

# Print only the status code (perfect for scripts)
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/users/999"      # 404

# Timing breakdown: where is the time spent?
curl -s -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" "$BASE/delay/2"

# Timeouts
curl -i --max-time 2 "$BASE/delay/5"          # curl gives up after 2 s (exit code 28)
curl -i --connect-timeout 5 "$BASE/health"
Real-life example: “the API is slow” The -w timing breakdown shows connect=0.03 tls=0.08 ttfb=2.95. The network is fine; the server takes almost 3 seconds to produce the first byte. The bug goes to the backend team with evidence, not to the network team.
Advertisement

Redirects, Retries and Other Useful Flags

FlagMeaningExample use
-LFollow redirectscurl -iL $BASE/redirect (without -L you see the 302)
-o file / -OSave body to a fileDownload a report or export
-s / -SSilent (no progress) / still show errorsScripts
--retry 3Retry transient failuresFlaky test environments
-kSkip TLS verificationOnly self-signed test servers, never production
--compressedAsk for gzip/br and decodeCheck compression works
-x proxySend through a proxyRoute via Burp or ZAP for inspection
--http2 / --http1.1Force protocol versionProtocol compatibility tests
-f / --fail-with-bodyExit non-zero on HTTP errorsSmoke checks in CI scripts
Advertisement

A 10-Line Smoke Test Script

#!/usr/bin/env bash
BASE=https://sqatools.in/wp-json/sqa-api/v1
fail=0
check() {   # usage: check EXPECTED METHOD PATH [extra curl args...]
  local expected=$1 method=$2 path=$3; shift 3
  local code=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" "$BASE$path" "$@")
  if [ "$code" = "$expected" ]; then echo "PASS $method $path $code"; else echo "FAIL $method $path got $code expected $expected"; fail=1; fi
}
check 200 GET  /health
check 200 GET  /users/1
check 404 GET  /users/999
check 400 GET  "/users?per_page=51"
check 401 GET  /basic/profile
check 200 GET  /basic/profile -u sqa_user:sqa@123
check 405 POST /users
exit $fail

Run it after every deployment; a non-zero exit code fails the pipeline.

Converting cURL to Postman, Playwright and Back

  • Browser → cURL: DevTools → Network → right-click request → Copy → Copy as cURL (bash). Remove cookies and tokens before sharing.
  • cURL → Postman: Import → Raw text → paste.
  • Postman → cURL: the </> Code button → cURL.
  • cURL → Playwright: map flags to options:
// curl -i -X POST "$BASE/objects" -H "Content-Type: application/json" -d '{"name":"A","ttl":60}'
const res = await request.post(`${BASE}/objects`, { data: { name: 'A', ttl: 60 } });

// curl -u sqa_user:sqa@123 "$BASE/basic/profile"
await request.get(`${BASE}/basic/profile`, {
  headers: { Authorization: 'Basic ' + Buffer.from('sqa_user:sqa@123').toString('base64') },
});

// curl --max-time 2 "$BASE/delay/5"
await expect(request.get(`${BASE}/delay/5`, { timeout: 2000 })).rejects.toThrow();

// curl "$BASE/redirect"   (no -L)
await request.get(`${BASE}/redirect`, { maxRedirects: 0 });
Advertisement

Common cURL Mistakes

  • Forgetting -i and missing the status code and headers.
  • Unquoted URLs with &: the shell cuts the URL at the first &.
  • Sending JSON without Content-Type: application/json (cURL’s -d defaults to form-encoded); the practice API answers 415.
  • Using -X GET together with -d and expecting query parameters (use -G).
  • Pasting real tokens and cookies into tickets or chat.
  • Using -k in scripts that later run against production.

cURL Cheat Sheet

TaskCommand
GET with headers showncurl -i URL
POST JSONcurl -i -X POST URL -H "Content-Type: application/json" -d '{...}'
Body from file-d @file.json
Basic auth-u user:pass
Bearer token-H "Authorization: Bearer $TOKEN"
Status code only-s -o /dev/null -w "%{http_code}"
Response time-w "%{time_total}"
Follow redirects-L
Timeout--max-time 5
Verbose debug-v
Advertisement

Interview Questions: cURL

What is cURL and why do testers use it?

cURL is a command-line tool for transferring data with URLs, used to send HTTP requests. Testers use it to reproduce issues exactly, share requests in bug reports, test from servers without a GUI and write quick smoke scripts.

How do you send a POST request with a JSON body using cURL?

curl -X POST URL -H “Content-Type: application/json” -d ‘{“key”:”value”}’, or with a file using -d @body.json. Newer versions also support –json.

What is the difference between -i, -I and -v in cURL?

-i includes response headers with the body, -I sends a HEAD request and shows headers only, and -v prints verbose details including the request headers, TLS handshake and connection info.

How do you print only the HTTP status code?

curl -s -o /dev/null -w “%{http_code}” URL. The -w option also prints timing values such as %{time_total}.

How do you send Basic authentication with cURL?

Use -u username:password; cURL Base64-encodes it into the Authorization: Basic header.

How do you follow redirects?

Add -L. Without it, cURL shows the redirect response (3xx with a Location header) itself.

How do you set a timeout?

–max-time seconds limits the whole operation; –connect-timeout limits only the connection phase. A timeout gives exit code 28.

How do you get a cURL command for a request made by the browser?

In DevTools Network tab, right-click the request and choose Copy as cURL. Remove sensitive cookies and tokens before sharing it.

Why might a JSON POST via cURL return 415?

Because -d sends application/x-www-form-urlencoded by default. Add -H “Content-Type: application/json” or use –json.

How do you measure API response time with cURL?

Use -w with timing variables such as time_namelookup, time_connect, time_appconnect, time_starttransfer and time_total to see where time is spent.

Advertisement
View the full API testing syllabus