HTTP API testing for TypeScript (Supertest) and Python (httpx, pytest). Covers REST APIs, GraphQL, request/response validation, authentication, and error handling. Use when user mentions API testing, Supertest, httpx, REST testing, endpoint testing, HTTP response validation, or testing API routes.
/plugin marketplace add laurigates/claude-plugins/plugin install api-plugin@lgates-claude-pluginsThis skill is limited to using the following tools:
Expert knowledge for testing HTTP APIs with Supertest (TypeScript/JavaScript) and httpx/pytest (Python).
API Testing Capabilities
# Using Bun
bun add -d supertest @types/supertest
# Using npm
npm install -D supertest @types/supertest
// app.ts
import express from 'express'
export const app = express()
app.use(express.json())
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' })
})
app.post('/api/users', (req, res) => {
const { name, email } = req.body
if (!name || !email) {
return res.status(400).json({ error: 'Missing required fields' })
}
res.status(201).json({ id: 1, name, email })
})
// app.test.ts
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import { app } from './app'
describe('API Tests', () => {
it('returns health status', async () => {
const response = await request(app)
.get('/api/health')
.expect(200)
expect(response.body).toEqual({ status: 'ok' })
})
it('creates a user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John Doe', email: 'john@example.com' })
.expect(201)
expect(response.body).toMatchObject({
id: expect.any(Number),
name: 'John Doe',
email: 'john@example.com',
})
})
it('validates required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John Doe' })
.expect(400)
expect(response.body.error).toBeDefined()
})
})
import request from 'supertest'
import { app } from './app'
// GET request
await request(app)
.get('/api/users')
.expect(200)
// POST request with body
await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com' })
.expect(201)
// PUT request
await request(app)
.put('/api/users/1')
.send({ name: 'Jane' })
.expect(200)
// PATCH request
await request(app)
.patch('/api/users/1')
.send({ email: 'jane@example.com' })
.expect(200)
// DELETE request
await request(app)
.delete('/api/users/1')
.expect(204)
// Set headers
await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer token123')
.set('Content-Type', 'application/json')
.expect(200)
// Query parameters
await request(app)
.get('/api/users')
.query({ page: 1, limit: 10 })
.expect(200)
// Multiple query parameters
await request(app)
.get('/api/search')
.query({ q: 'john', sort: 'name', order: 'asc' })
.expect(200)
describe('Response validation', () => {
it('validates status code', async () => {
await request(app)
.get('/api/users')
.expect(200)
})
it('validates headers', async () => {
await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200)
})
it('validates response body', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200)
expect(response.body).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com',
createdAt: expect.any(String),
})
})
it('validates array responses', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toBeInstanceOf(Array)
expect(response.body).toHaveLength(5)
expect(response.body[0]).toHaveProperty('id')
})
})
describe('Authentication', () => {
let authToken: string
beforeAll(async () => {
// Login to get token
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password123' }) // pragma: allowlist secret
.expect(200)
authToken = response.body.token
})
it('accesses protected endpoint with token', async () => {
await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
})
it('rejects requests without token', async () => {
await request(app)
.get('/api/protected')
.expect(401)
})
it('rejects requests with invalid token', async () => {
await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer invalid-token')
.expect(401)
})
})
import fs from 'fs'
import path from 'path'
it('uploads a file', async () => {
const response = await request(app)
.post('/api/upload')
.attach('file', path.resolve(__dirname, 'test-file.pdf'))
.field('description', 'Test document')
.expect(200)
expect(response.body).toMatchObject({
filename: expect.any(String),
size: expect.any(Number),
})
})
describe('Cookie handling', () => {
it('sets and reads cookies', async () => {
// Login sets cookie
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password' }) // pragma: allowlist secret
.expect(200)
const cookies = loginResponse.headers['set-cookie']
// Use cookie in subsequent request
await request(app)
.get('/api/profile')
.set('Cookie', cookies)
.expect(200)
})
})
describe('Error handling', () => {
it('handles validation errors', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid-email' })
.expect(400)
expect(response.body).toMatchObject({
error: 'Validation failed',
details: expect.arrayContaining([
expect.objectContaining({
field: 'email',
message: expect.any(String),
}),
]),
})
})
it('handles not found errors', async () => {
await request(app)
.get('/api/users/999999')
.expect(404)
})
it('handles server errors gracefully', async () => {
const response = await request(app)
.post('/api/error-prone-endpoint')
.expect(500)
expect(response.body).toHaveProperty('error')
})
})
# Using uv
uv add --dev httpx pytest-asyncio
# Using pip
pip install httpx pytest-asyncio
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
name: str
email: str
@app.get("/api/health")
def health_check():
return {"status": "ok"}
@app.post("/api/users", status_code=201)
def create_user(user: User):
return {"id": 1, "name": user.name, "email": user.email}
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
if user_id == 999:
raise HTTPException(status_code=404, detail="User not found")
return {"id": user_id, "name": "John Doe", "email": "john@example.com"}
# test_main.py
import pytest
from httpx import AsyncClient
from fastapi.testclient import TestClient
from main import app
# Synchronous testing
client = TestClient(app)
def test_health_check():
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_create_user():
response = client.post(
"/api/users",
json={"name": "John Doe", "email": "john@example.com"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "John Doe"
assert data["email"] == "john@example.com"
assert "id" in data
def test_validation_error():
response = client.post("/api/users", json={"name": "John"})
assert response.status_code == 422 # FastAPI validation error
def test_not_found():
response = client.get("/api/users/999")
assert response.status_code == 404
import pytest
from httpx import AsyncClient
from main import app
@pytest.mark.asyncio
async def test_async_health_check():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_async_create_user():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
"/api/users",
json={"name": "Jane Doe", "email": "jane@example.com"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Jane Doe"
import pytest
from httpx import AsyncClient
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
"""Synchronous test client"""
return TestClient(app)
@pytest.fixture
async def async_client():
"""Async test client"""
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.fixture
def auth_token(client):
"""Get authentication token"""
response = client.post(
"/api/auth/login",
json={"email": "user@example.com", "password": "password123"} # pragma: allowlist secret
)
return response.json()["token"]
# Usage
def test_protected_endpoint(client, auth_token):
response = client.get(
"/api/protected",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
def test_with_headers(client):
response = client.get(
"/api/protected",
headers={
"Authorization": "Bearer token123",
"Content-Type": "application/json"
}
)
assert response.status_code == 200
def test_with_query_params(client):
response = client.get(
"/api/users",
params={"page": 1, "limit": 10, "sort": "name"}
)
assert response.status_code == 200
assert len(response.json()) <= 10
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def authenticated_client():
client = TestClient(app)
# Login
response = client.post(
"/api/auth/login",
json={"email": "user@example.com", "password": "password123"} # pragma: allowlist secret
)
token = response.json()["token"]
# Add token to client headers
client.headers["Authorization"] = f"Bearer {token}"
return client
def test_access_protected_resource(authenticated_client):
response = authenticated_client.get("/api/protected")
assert response.status_code == 200
def test_reject_unauthenticated(client):
response = client.get("/api/protected")
assert response.status_code == 401
def test_file_upload(client, tmp_path):
# Create temporary test file
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
with open(test_file, "rb") as f:
response = client.post(
"/api/upload",
files={"file": ("test.txt", f, "text/plain")},
data={"description": "Test file"}
)
assert response.status_code == 200
assert response.json()["filename"] == "test.txt"
def test_cookie_handling(client):
# Login sets cookie
response = client.post(
"/api/auth/login",
json={"email": "user@example.com", "password": "password"} # pragma: allowlist secret
)
assert "session" in response.cookies
# Cookie automatically included in subsequent requests
response = client.get("/api/profile")
assert response.status_code == 200
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db, Base
# Test database
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture
def db():
"""Create test database"""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def client(db):
"""Override database dependency"""
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
return TestClient(app)
def test_create_and_retrieve_user(client):
# Create user
response = client.post(
"/api/users",
json={"name": "John", "email": "john@example.com"}
)
assert response.status_code == 201
user_id = response.json()["id"]
# Retrieve user
response = client.get(f"/api/users/{user_id}")
assert response.status_code == 200
assert response.json()["name"] == "John"
import Ajv from 'ajv'
const ajv = new Ajv()
const userSchema = {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
createdAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'name', 'email'],
}
it('validates user schema', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200)
const validate = ajv.compile(userSchema)
expect(validate(response.body)).toBe(true)
})
from pydantic import BaseModel, EmailStr, validator
class UserResponse(BaseModel):
id: int
name: str
email: EmailStr
created_at: str
@validator('name')
def name_must_not_be_empty(cls, v):
if not v.strip():
raise ValueError('Name cannot be empty')
return v
def test_user_response_schema(client):
response = client.get("/api/users/1")
assert response.status_code == 200
# Validate response against schema
user = UserResponse(**response.json())
assert user.id == 1
assert isinstance(user.email, str)
it('responds within acceptable time', async () => {
const start = Date.now()
await request(app)
.get('/api/users')
.expect(200)
const duration = Date.now() - start
expect(duration).toBeLessThan(100) // 100ms threshold
})
import time
def test_response_time(client):
start = time.time()
response = client.get("/api/users")
duration = time.time() - start
assert response.status_code == 200
assert duration < 0.1 # 100ms threshold
Test Organization
describe blocksbeforeEach for common setupDatabase State
Assertions
Mocking External Services
import { vi } from 'vitest'
// Mock external API
vi.mock('./externalAPI', () => ({
fetchUserData: vi.fn(() => Promise.resolve({ status: 'ok' })),
}))
from unittest.mock import patch
@patch('main.external_api.fetch_user_data')
def test_with_mocked_external_service(mock_fetch, client):
mock_fetch.return_value = {"status": "ok"}
response = client.get("/api/users/1")
assert response.status_code == 200
Common Patterns
// Test factory for creating test data
function createTestUser(overrides = {}) {
return {
name: 'Test User',
email: 'test@example.com',
...overrides,
}
}
// Reusable authentication helper
async function authenticateUser(app: Express) {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password' }) // pragma: allowlist secret
return response.body.token
}
it('queries GraphQL endpoint', async () => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`
const response = await request(app)
.post('/graphql')
.send({ query, variables: { id: '1' } })
.expect(200)
expect(response.body.data.user).toMatchObject({
id: '1',
name: expect.any(String),
email: expect.any(String),
})
})
def test_graphql_query(client):
query = """
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
"""
response = client.post(
"/graphql",
json={"query": query, "variables": {"id": "1"}}
)
assert response.status_code == 200
data = response.json()["data"]
assert data["user"]["id"] == "1"
assert "name" in data["user"]
Port already in use
// Use random port for testing
const server = app.listen(0) // 0 = random available port
const port = server.address().port
Database connection issues
# Use separate test database
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("sqlite:///./test.db")
yield engine
engine.dispose()
Slow tests
// Mock expensive operations
vi.mock('./slowService', () => ({
processData: vi.fn(() => Promise.resolve('mocked')),
}))
vitest-testing - Unit testing frameworkpython-testing - Python pytest patternsplaywright-testing - E2E API testingtest-quality-analysis - Test quality patterns