HTTP, REST and API testing technology

How HTTP really works - methods, status codes, headers, cookies, redirects and TLS - then REST and OpenAPI, the .NET HttpClient stack, how a test engine fills variables and judges JSONPath and JSON Schema checks, how to stop SSRF, mock servers, load-testing math, breaking-change detection and API tests in CI.

In short

An API test is an ordinary HTTP request with a judgement attached. A test engine fills in variables, sends the request, reads the answer and runs checks on it - a status code, a field found by JSONPath, a whole body against a JSON Schema. Around that core sit the hard parts. One is never letting a user-typed address reach your own network (SSRF). Another is measuring speed honestly under load. A third is spotting breaking changes between two OpenAPI files, and failing a CI build with the right exit code. In {{brand}}, one shared run engine builds and judges every request, suite and project run, on the server and in the command line alike.

After this lesson you can Read any HTTP exchange line by line and explain each status, header and redirect; build a small API test runner with variables, JSONPath and JSON Schema checks; defend an outbound .NET HttpClient against SSRF; read load-test percentiles without being fooled by coordinated omission; and wire API tests into a CI pipeline.

Words you need

HTTP in depth: one request, start to finish

When a test sends GET https://api.example.com/orders/42, six things happen in order. Knowing them tells you where a slow or failed request lost its time - and which numbers a tool is really showing you.

HTTP/2 sends the same meaning in binary frames and runs many requests at once over one connection; HTTP/3 does the same over QUIC on UDP. Methods, status codes and headers are unchanged, so everything below applies to all three. The version is agreed during the TLS handshake through ALPN.

Safe and idempotent, in two sentences Safe means the request changes nothing on the server. Idempotent means sending it twice leaves the same state as sending it once, so a client may retry it after a timeout - POST is neither, which is why a retried POST can create two orders.

Some headers belong to the connection, not the request Host, Content-Length, Connection, Transfer-Encoding and Keep-Alive describe one connection. Copied from a browser and replayed against a different body, they cause hangs or 400 answers that look like the server's fault. A good test client drops them and lets the transport set them.

Cookies, redirects and TLS: where tests quietly go wrong

These three features exist to make browsing pleasant. In a test they can hide what really happened, so a test client handles each one deliberately instead of leaving it to defaults.

A browser keeps one cookie jar for everything. A test tool should keep one jar per run: the session cookie from the login step reaches the steps after it, and nothing leaks between runs of different people. {{brand}} keeps a fresh jar for every suite run and every data row.

A redirect can carry your credentials away Careful clients drop the Authorization header when a redirect leaves the origin, but custom code, cookies on a wide domain and some options (curl --location-trusted) can still carry credentials away. A test client should follow redirects one hop at a time, drop credentials and cookies when the origin (scheme, host and port) changes, record every hop, and check each new address as if it were a new request.

Turning off certificate checks is a decision, not a fix A test server with a self-signed certificate fails verification. Accept it only for that one test environment, scope the switch to a single environment, never in global settings, and never for production - otherwise anyone between you and the server can read your tokens.

REST and OpenAPI: the shape of an API and its contract

REST comes from Roy Fielding's 2000 dissertation. In practice it means: things are resources with URLs such as /orders/42, the HTTP method says what to do with them, and each request carries everything the server needs - no hidden session state.

OpenAPI (formerly Swagger) writes that interface down. Version 3.1 describes bodies with JSON Schema 2020-12, so the same schema both documents the API and checks a real response. Swagger 2.0 files still exist - they name a host and a base path where 3.x has servers - and good tools read both, in JSON or YAML.

From one file like this a tool can make a request per operation, a schema check per documented answer, a mock that answers from the examples and - comparing two versions - a list of breaking changes. That is why the contract sits at the centre of API testing.

The options in the market

Two decisions shape an API testing tool: how tests are written and run, and which HTTP client sends them. Here are the honest choices for each.

What {{brand}} chose, and why

{{brand}} wrote its own run engine: a small library that builds requests, fills variables, reads answers with a size cap and judges declarative checks. The server and the command line both use it, so a check cannot pass in one and fail in the other.

The trade-off accepted There is no scripting escape hatch. A test that needs real logic - signing a request with a custom algorithm, say - has to be done another way. The team accepted that, because the alternative is running strangers' code on shared servers.

When another option is right: a team of developers who keep tests next to the code may be happier with code-first tests in xUnit or pytest. One person exploring a new API is quicker in Postman or Bruno. A shared engine pays off when many people, a server and CI pipelines must agree on one verdict.

.NET HttpClient, IHttpClientFactory and message handlers

HttpClient is only a front door. The real work happens in a chain of message handlers: each one may change the request, answer it itself, or pass it on. The last one, SocketsHttpHandler, owns the connection pool and the sockets.

The two classic HttpClient bugs Socket exhaustion: creating and disposing an HttpClient per request leaves closed sockets waiting in TIME_WAIT until the machine runs out of ports. Stale DNS: one HttpClient kept forever keeps talking to an old IP after a fail-over. IHttpClientFactory fixes the first by pooling handlers; PooledConnectionLifetime fixes the second by recycling connections.

Read bodies with HttpCompletionOption.ResponseHeadersRead and a byte cap of your own. By default HttpClient buffers the whole body in memory first, so a huge or endless answer costs that much memory. A test engine reads up to its limit and stops. Remember the consequence: a body cut at the cap is usually invalid JSON, so a JSON check on it fails - raise the cap, or check only headers for very large answers.

Building a test engine: variables, extraction and checks

Most API testing tools run roughly the same pipeline for each request. The details differ; the stages do not.

Whatever the tool, checks come in a few kinds. A good engine offers each of these and lets one request carry several.

Why a dynamic value is made once per request If {{$uuid}} appears in the address and again in a check, both must see the same value - otherwise the check compares against a different random id. Make it once per request, and a new one for the next request.

The order used above is: status shipped, and two items - A1 at price 10 and B7 at price 25.5. JSONPath was described by Stefan Goessner in 2007 and became a standard, RFC 9535, in 2024; many tools still speak their own dialect. {{brand}} supports a deliberate subset - names, indexes, wildcards and a one-comparison filter. Whatever a tool does not support often raises no error: a path outside its subset can quietly match nothing, or the wrong thing. So test the exact paths you depend on.

A path can reach many items, and then a check must say whether every item must pass or any one is enough. 'Every price is above 0' and 'some item is in stock' are different tests. A good engine checks every item by default for a wildcard and reports how many failed.

A validator reports each failing place as a path, so one schema replaced three hand-written checks. An OpenAPI file already holds a schema for every documented answer. {{brand}} validates with JsonSchema.Net and lists the first places that do not match.

Two traps inside checks ReDoS: a pattern such as ^(a+)+$ can take exponential time on a crafted text, so every regular expression a user supplies must run with a timeout. Remote $ref: a schema that points at an outside URL would make your server download whatever it names - resolve references only inside the schema, and treat anything else as a failed check.

Authentication and other things real API tests meet

Almost every real API needs a credential. The test engine's job is to obtain it once, keep it out of logs and send it on every request that needs it.

SSRF: when users choose the address

An API tester sends requests to addresses its users type. That is exactly the shape of server-side request forgery: an attacker types an address the server can reach but the attacker cannot - an admin page on the private network, or the cloud metadata service at 169.254.169.254 that hands out credentials. SSRF has its own place in the OWASP Top 10 (A10:2021), and a well-known 2019 breach of a large bank started this way.

This list is illustrative, not complete: a production guard also covers reserved, multicast and cloud-platform addresses. It should be one tested, shared helper, not code pasted into a handler. It also does not check ports; refuse the ones no API test needs.

Check at connection time, not at parse time A check on the URL text only catches honest mistakes. The real guard sits where the socket opens, because only that place knows the address actually used - for the first request, every redirect hop and every pooled connection. Public-facing testers should apply the same rule to every outbound call, and bind heavy traffic only to a target whose owner has proved control of it.

Private APIs on localhost are still testable from the person's own machine: a command-line runner or a browser extension sends the request from where it is allowed to go, so a shared server never has to reach into a private network.

Mock servers

A public mock is a public web page Anything a mock serves to a browser can run there. A safe mock serves only inert types such as JSON and plain text, sends nosniff and a sandboxing Content-Security-Policy, never sets cookies, and keeps redirects inside itself - so it cannot become a phishing page on your domain.

Load testing: the math behind the numbers

They are tied together by Little's law: concurrency = throughput x latency. With 50 requests in flight taking 200 ms each, the most you can complete is 50 / 0.2 = 250 requests a second. If latency doubles to 400 ms, throughput halves to 125 - the server did not get busier, it got slower.

Three rules for reading percentiles Never average percentiles across runs - merge the raw samples instead. You need well over 100 samples before p99 means anything. And the tail compounds: a page that calls 100 services, each with a 1% chance of a slow answer, is slow for about 63% of visitors (1 - 0.99 to the power 100).

{{brand}} computes percentiles by nearest rank with no interpolation, so a reported p95 is always a duration that really happened. It stores the distribution - p50, p90, p95 and p99, a latency histogram, status counts and a per-second timeline - not every single response, and compares a run with a baseline on p95, throughput and error rate.

Coordinated omission, a name coined by Gil Tene, is the blind spot of a generator that waits: when the server stalls, it stops sending, so the stall is never measured. Example: one worker should send every 100 ms for 100 seconds, and the server freezes once for 2 seconds.

Same server - a hundred times apart. Pick the model on purpose: a closed model answers 'how long does a request take with N in flight', an open model answers 'what do users see at R requests a second'.

Spotting breaking changes between two OpenAPI files

A breaking change makes a working client fail. Comparing two versions of the contract catches most of them before a deploy. The rule of thumb is Postel's robustness principle: a change is safe when the server accepts at least what it accepted before and promises at least what it promised before - in type language, contravariant inputs and covariant outputs.

Request and response are mirror images Widening is safe in a request and dangerous in a response; narrowing is the opposite. If you remember one thing about API compatibility, remember this.

Open-source tools such as oasdiff and openapi-diff apply the same idea in CI. {{brand}} runs its own rules - reading files with Microsoft.OpenApi - so that a breaking change can be linked to the tests and the people it affects, and wait until someone verifies it.

Running API tests in CI

Telling tests failed (1) from could not run (2) matters: the first is a bug in the API, the second a problem with the pipeline. A runner that returns 1 for both sends people to debug the wrong thing.

JUnit XML started with the Java tool Ant and became the format every CI system reads: Azure Pipelines, GitHub Actions, GitLab and Jenkins. A failure means an assertion did not hold; an error means the test could not run. {{brand}}'s CLI writes one test suite per data row and reports a step that was not sent as an error, never as a pass.

Pipeline logs read commands Azure Pipelines treats a log line containing ##vso[ as a command, and GitHub Actions treats lines starting with :: the same way. A test runner prints names and values other people wrote - a step name, a response field - so it must escape them, or a hostile API answer could set pipeline variables.

Try it yourself: a tiny API test runner

Build the core of a test engine against a local fake API. You need Node.js 20 or newer and nothing else installed; npx fetches json-server and tsx on the fly.

Common mistakes

Interview questions

Related