HTTP basics for testers
Updated 2026-11-13 · 4 min read
Run a local HTTP lab and check methods, paths, query parameters, headers, JSON bodies and success and error responses.
A useful API bug report includes the method, URL, request headers and body, expected result, and actual response. This lab gives you those pieces without an account or a shared environment. Tested on Linux with Bash and Node v24.22.2.
1. Create an isolated lab
Run the blocks in order in one Bash terminal. Port 0 asks the operating system for an available port; the script only connects to its own server.
lab=$(mktemp -d /tmp/qa-http-XXXXXX)
cd "$lab"
node --version
Save this complete file as http-lab.mjs:
// http-lab.mjs
import http from 'node:http';
import assert from 'node:assert/strict';
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const send = (status, body, headers = {}) => {
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
res.end(JSON.stringify(body));
};
if (url.pathname !== '/tickets') return send(404, { error: 'not found' });
if (req.method === 'GET') {
return send(200, { status: url.searchParams.get('status') ?? 'all' });
}
if (req.method !== 'POST') {
return send(405, { error: 'method not allowed' }, { Allow: 'GET, POST' });
}
if (req.headers['content-type'] !== 'application/json') {
return send(415, { error: 'send JSON' });
}
let raw = '';
for await (const chunk of req) raw += chunk;
let body;
try { body = JSON.parse(raw); }
catch { return send(400, { error: 'invalid JSON' }); }
if (typeof body?.title !== 'string' || !body.title.trim()) {
return send(422, { error: 'title required' });
}
send(201, { id: 1, title: body.title });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
async function check(method, path, headers, body, status, expected) {
const response = await fetch(base + path, {
method, headers, body, signal: AbortSignal.timeout(5000),
});
const actual = await response.json();
assert.equal(response.status, status);
assert.match(response.headers.get('content-type'), /application\/json/);
assert.deepEqual(actual, expected);
if (status === 405) assert.equal(response.headers.get('allow'), 'GET, POST');
console.log(method, path, status, JSON.stringify(actual));
}
try {
await check('GET', '/tickets?status=open', {}, undefined, 200, { status: 'open' });
await check('POST', '/tickets', { 'Content-Type': 'application/json' },
'{"title":"Fake booking issue"}', 201, { id: 1, title: 'Fake booking issue' });
await check('POST', '/tickets', { 'Content-Type': 'application/json' },
'{', 400, { error: 'invalid JSON' });
await check('POST', '/tickets', { 'Content-Type': 'application/json' },
'{}', 422, { error: 'title required' });
await check('POST', '/tickets', { 'Content-Type': 'text/plain' },
'hello', 415, { error: 'send JSON' });
await check('GET', '/missing', {}, undefined, 404, { error: 'not found' });
await check('DELETE', '/tickets', {}, undefined, 405, { error: 'method not allowed' });
} finally {
await new Promise(resolve => server.close(resolve));
}
2. Run and interpret the responses
node http-lab.mjs
You should see seven lines with statuses 200, 201, 400, 422, 415, 404, 405, in that order. An assertion failure exits unsuccessfully. The server closes even when an assertion fails.
| Request detail | What it means in this lab | Useful check |
|---|---|---|
| Method | GET reads; POST submits a ticket | Unsupported DELETE returns 405 and an Allow header |
| Path | /tickets selects the handler | A different path returns 404 |
| Query | status=open supplies a filter value | The response echoes open |
| Header | Content-Type describes the sent representation | text/plain is rejected here |
| Body | JSON contains the title | Broken JSON and missing title produce different errors |
HTTP defines the status meanings; the API contract decides which validation failure maps to which status. This example chooses 422 for valid JSON with an unusable title. Other APIs use 400. Do not file a bug solely because another service uses a different convention.
3. Understand what the green run proves
The lab checks routing and response shape. Its ticket ID is hard-coded and it stores nothing: 201 here does not prove database persistence. A production creation test should read the newly created record and verify its fields. This tiny server also accepts only the exact JSON media type string shown; it is a teaching fixture, not a production HTTP parser or secure service.
The common mistake is asserting only the status. A response can be 200 with the wrong customer's data. In an interview, explain the request contract first, then the status, headers, body and persisted state you would check. Follow-up: would retrying a POST be safe? Only if the service contract provides protection against duplicate effects; do not assume it.
The lab folder contains only this script and may be deleted after you finish. No server stays running.
Quick answers
Does a 200 response prove the feature works?
No. Check the response data and the intended side effect as well as the status.
Does this lab test HTTPS or browser CORS?
No. It uses plain HTTP on loopback. TLS and browser enforcement require separate tests.
Sources
Want a project you can show in interviews?
The portfolio project kit walks you through testing a deliberately buggy app with Selenium or Playwright and API tests, then explaining it. The app itself is free.
