HTTP Tests

Test your API endpoints end-to-end with client: a fluent HTTP client built specifically for tekir test suites.

Before you begin: Make sure you have read the Getting Started guide. This page assumes you already know how to write a basic test with test, describe, and expect.

Introduction

HTTP tests (sometimes called integration tests or feature tests) make real HTTP requests to your running application and assert on the response. If an HTTP test passes, you know the entire stack from routing to database is working correctly together.

The client(baseUrl) function from @tekir/testing creates a lightweight HTTP client. Every method (get, post, …) returns a TestResponse object that has chainable assertion methods built in, so there is no need to write expect(res.status).toBe(200) by hand.

Creating a Client

Call client with the base URL of your application. Create one client per file and share it across all tests. It holds no state between requests, so sharing is safe:

import { client } from '@tekir/testing'

// Point the client at your running application
const api = client('http://localhost:3000')

The recommended approach is createTestApp() in a shared setup file. It boots your app in test mode and returns a request client:

tests/integration/users.test.ts
import { createTestApp } from '@tekir/testing'

// Boot the app in test mode and get an HTTP client
export const { request } = await createTestApp(import.meta.dir)

// tests/users.test.ts
import { test, describe } from '@tekir/testing'
import { request } from './setup'

describe('Users API', () => {
  test('GET /api/users', async () => {
    const res = await request.get('/api/users')
    res.assertOk()
  })
})

Making Requests

The client exposes a method for each HTTP verb. All methods are async and return a TestResponse:

tests/integration/users.test.ts
import { test } from '@tekir/testing'
import { client } from '@tekir/testing'

const api = client('http://localhost:3000')

test('GET, fetch a resource', async () => {
  const res = await api.get('/api/users/1')
  res.assertOk()
})

test('POST, create a resource', async () => {
  const res = await api.post('/api/users', {
    body: { name: 'Ali', email: '[email protected]', password: 'secret123' }
  })
  res.assertCreated()
})

test('PUT, replace a resource', async () => {
  const res = await api.put('/api/users/1', {
    body: { name: 'Ali Updated', email: '[email protected]' }
  })
  res.assertOk()
})

test('PATCH, partially update a resource', async () => {
  const res = await api.patch('/api/users/1', {
    body: { name: 'Ali Patched' }
  })
  res.assertOk()
})

test('DELETE, remove a resource', async () => {
  const res = await api.delete('/api/users/1')
  res.assertStatus(204)
})

test('OPTIONS, CORS preflight', async () => {
  const res = await api.options('/api/users')
  res.assertStatus(204)
    .assertHeader('access-control-allow-origin', 'https://app.example')
})

Request Options

Every request method accepts an optional options object as its second argument. You can use it to send headers, a request body, query string parameters, and cookies.

headers

Merge extra headers into the request. The client always sends Accept: application/json by default; any headers you provide are merged on top:

// Send custom request headers
const res = await api.get('/api/admin/stats', {
  headers: {
    'X-Internal-Token': 'super-secret',
    'Accept-Language': 'en-US'
  }
})
res.assertOk()

body

Any object you pass as body is serialized to JSON automatically and Content-Type: application/json is set for you. Bodies are only sent on non-GET and non-HEAD requests:

// Send a JSON body, Content-Type: application/json is set automatically
const res = await api.post('/api/posts', {
  body: {
    title: 'My first post',
    body: 'Hello from the test suite!',
    published: true
  }
})
res.assertCreated()

query

Pass query string parameters as a plain object. The client encodes them with URLSearchParams and appends them to the URL:

// Append query string parameters
const res = await api.get('/api/posts', {
  query: {
    page: '2',
    perPage: '10',
    sort: 'createdAt'
  }
})
// Sends: GET /api/posts?page=2&perPage=10&sort=createdAt
res.assertOk()

Send a raw Cookie header. This is useful for testing routes that are protected by session-based authentication or that read cookie values directly:

// Send a cookie header
const res = await api.get('/api/profile', {
  cookie: 'session=abc123; theme=dark'
})
res.assertOk()

stream

For streaming endpoints (Server-Sent Events, long-polling, file downloads) the client would otherwise wait for the response body to flush before resolving, which never happens until the stream closes. Pass { stream: true } to skip the body drain. Status and headers come back immediately and the live ReadableStream is exposed on res.raw.body:

// Hit a streaming endpoint without waiting for the body to flush.
// Useful for SSE, long-polling, file downloads — anything that keeps
// the response open after the headers have been sent.
const res = await api.get('/api/events', { stream: true })
res.assertOk().assertHeader('content-type', 'text/event-stream')

// Read the live stream yourself when you need to:
const reader = res.raw.body!.getReader()
const { value } = await reader.read()
console.log(new TextDecoder().decode(value))
await reader.cancel()

Status Assertions

The most fundamental thing to check about a response is its HTTP status code. TestResponse provides named shortcuts for the most common codes, plus a generic assertStatus(code) for everything else:

const res = await api.get('/api/users/1')

// 200 OK
res.assertOk()

// 201 Created
res.assertCreated()

// 404 Not Found
res.assertNotFound()

// 401 Unauthorized
res.assertUnauthorized()

// 403 Forbidden
res.assertForbidden()

// 422 Unprocessable Entity (validation errors)
res.assertUnprocessable()

// 3xx redirect (optionally check the Location header)
res.assertRedirect()
res.assertRedirect('/login')

// Any specific status code
res.assertStatus(429)

assertRedirect(to?) passes for any status in the 3xx range. If you supply a URL, it also checks that the Location response header matches.

JSON Assertions

The response body is automatically parsed from JSON. You can access it as res.body (already a JavaScript object) or use the assertion helpers:

const res = await api.get('/api/users/1')

// Assert the full JSON body matches exactly
res.assertJson({
  id: 1,
  name: 'Ali',
  email: '[email protected]',
  role: 'user'
})

// Assert the body contains at least these keys/values
// (other keys in the response are ignored)
res.assertJsonContains({ name: 'Ali' })
res.assertJsonContains({ id: 1, role: 'user' })

// Assert a nested value using dot-notation path
res.assertJsonPath('name', 'Ali')
res.assertJsonPath('address.city', 'Istanbul')
res.assertJsonPath('roles.0', 'editor')

assertJsonPath uses dot-notation to reach into nested objects. Array elements are accessed by their zero-based index:

// Asserting against an array response
const res = await api.get('/api/users')

// The body is an array: check properties on the first element
res.assertJsonPath('0.name', 'Ali')
res.assertJsonPath('0.email', '[email protected]')

// Or read the body directly for custom assertions
import { expect } from '@tekir/testing'

const users = res.body   // already parsed, no need to call .json()
expect(users).toHaveLength(3)
expect(users[0].name).toBe('Ali')

Error Assertions

Errors thrown via HttpException serialise as { error: { message, statusCode, code } }. assertError peels that envelope automatically so you can match the inner shape without caring whether the route returned a wrapped exception or a flat error body:

// tekir's HttpException serializes as { error: { message, statusCode, code } }.
// assertError unwraps that envelope so you can match the inner shape directly.
const res = await api.get('/api/admin/secrets')

res
  .assertUnauthorized()
  .assertError({ message: 'Missing token', statusCode: 401 })

// Works the same on routes that return a flat { message, statusCode } body.
const flat = await api.post('/api/users', { body: {} })
flat.assertError({ message: 'Validation failed', statusCode: 400 })

Header Assertions

Use header assertions to verify that your application sets the correct response headers, for caching, content negotiation, security policies, and so on:

const res = await api.get('/api/users')

// Assert a header is present (any value)
res.assertHeader('content-type')

// Assert a header has an exact value
res.assertHeader('content-type', 'application/json; charset=utf-8')

// Assert a header is NOT present
res.assertHeaderMissing('x-internal-token')
res.assertHeaderMissing('x-powered-by')

assertCookie inspects the Set-Cookie headers on the response. This is the right way to test that a login endpoint sets a session cookie or that a preference is persisted:

const res = await api.post('/api/auth/login', {
  body: { email: '[email protected]', password: 'secret123' }
})

// Assert a cookie was set (any value)
res.assertCookie('session')

// Assert a cookie was set with a specific value
res.assertCookie('theme', 'dark')

// Combine with a status assertion
res.assertOk().assertCookie('session')

Body Assertions

assertBodyContains(text) checks the raw response text for a substring. Use it when the response is not JSON, for example, when testing an endpoint that returns HTML, plain text, CSV, or any other format:

// assertBodyContains checks the raw response text.
// Useful for HTML, plain text, or CSV responses where JSON assertions
// are not applicable.

const res = await api.get('/api/export/users.csv')

res.assertBodyContains('name,email,role')
res.assertBodyContains('Ali,[email protected],user')

Auth Helpers

The client provides three helpers for attaching authentication credentials. Each one returns a new client instance with the credential baked in for every subsequent request. The original client is never modified, so you can keep both an authenticated and an unauthenticated client in the same test file.

withToken(token)

Adds an Authorization: Bearer <token> header to every request. Use this for JWT-based authentication:

tests/integration/profile.test.ts
import { client } from '@tekir/testing'

const api = client('http://localhost:3000')

// withToken() returns a new client that sends:
//   Authorization: Bearer <token>
// on every request. The original client is unchanged.
const authed = api.withToken('eyJhbGciOiJIUzI1NiJ9...')

test('authenticated users can see their profile', async () => {
  const res = await authed.get('/api/profile')
  res.assertOk()
})

test('unauthenticated requests are rejected', async () => {
  const res = await api.get('/api/profile')
  res.assertUnauthorized()
})

withBasicAuth(username, password)

Adds an Authorization: Basic <encoded> header. The credentials are base64-encoded automatically:

tests/integration/admin.test.ts
import { client } from '@tekir/testing'

const api = client('http://localhost:3000')

// withBasicAuth() base64-encodes "username:password" and sends:
//   Authorization: Basic <encoded>
const authed = api.withBasicAuth('admin', 'secret')

test('admin can access protected route', async () => {
  const res = await authed.get('/api/admin/users')
  res.assertOk()
})

withHeader(name, value)

Adds any arbitrary header to every request. Useful for API key authentication or internal service tokens:

tests/integration/internal.test.ts
import { client } from '@tekir/testing'

const api = client('http://localhost:3000')

// withHeader() returns a new client with a fixed header on every request
const internal = api.withHeader('X-Internal-Token', 'shared-secret')

test('internal token grants access', async () => {
  const res = await internal.get('/api/internal/metrics')
  res.assertOk()
})

Chaining Assertions

Every assertion method on TestResponse returns the same TestResponse object, so you can chain multiple assertions together into a single readable expression. If any assertion in the chain fails, an error is thrown immediately and the remaining assertions are skipped:

const res = await api.post('/api/users', {
  body: { name: 'Ali', email: '[email protected]', password: 'secret' }
})

// Every assertion method returns the same TestResponse object,
// so you can chain as many as you need on a single response.
res
  .assertCreated()
  .assertHeader('content-type', 'application/json; charset=utf-8')
  .assertJsonContains({ name: 'Ali', email: '[email protected]' })
  .assertJsonPath('role', 'user')

Full Example: CRUD API

The following is a complete, self-contained test file for a posts API. It demonstrates the full lifecycle: starting the server, seeding data, testing each endpoint, asserting on authentication, and cleaning up after every test.

tests/integration/posts.test.ts
import {
  test,
  describe,
  beforeEach,
  afterEach,
  expect
} from '@tekir/testing'
import { setupTestDb } from '@tekir/testing'
import { request } from './setup'
import { User } from '~/models/user'
import { Post } from '~/models/post'

describe('Posts CRUD API', () => {
  let cleanup: () => void
  let authToken: string

  beforeEach(async () => {
    // Fresh database for every test
    cleanup = setupTestDb([User, Post])

    // Create a user and log in to get a token
    await User.create({
      name: 'Test User',
      email: '[email protected]',
      password: 'secret123'
    })

    const loginRes = await request.post('/api/auth/login', {
      body: { email: '[email protected]', password: 'secret123' }
    })
    loginRes.assertOk()
    authToken = loginRes.body.token
  })

  afterEach(() => cleanup())
tests/integration/posts.test.ts (continued)
test('GET /api/posts, returns an empty list', async () => {
    const res = await request.withToken(authToken).get('/api/posts')

    res.assertOk()
    const posts = res.body
    expect(posts).toHaveLength(0)
  })

  test('POST /api/posts, creates a post', async () => {
    const res = await request.withToken(authToken).post('/api/posts', {
      body: { title: 'Hello World', body: 'My first post content.' }
    })

    res
      .assertCreated()
      .assertJsonContains({ title: 'Hello World' })
      .assertJsonPath('published', false)   // default value
  })

  test('POST /api/posts, validates required fields', async () => {
    const res = await request.withToken(authToken).post('/api/posts', {
      body: {}   // missing title and body
    })

    res.assertUnprocessable()
    expect(res.body.errors).toBeDefined()
  })

  test('GET /api/posts/:id, returns a single post', async () => {
    // Create a post directly in the database
    const post = await Post.create({ title: 'Test Post', body: 'Content', userId: 1 })

    const res = await request.withToken(authToken).get(`/api/posts/${post.id}`)

    res
      .assertOk()
      .assertJsonContains({ id: post.id, title: 'Test Post' })
  })

  test('GET /api/posts/:id, returns 404 for unknown post', async () => {
    const res = await request.withToken(authToken).get('/api/posts/99999')
    res.assertNotFound()
  })

  test('PATCH /api/posts/:id, updates a post', async () => {
    const post = await Post.create({ title: 'Old Title', body: 'Content', userId: 1 })

    const res = await request.withToken(authToken).patch(`/api/posts/${post.id}`, {
      body: { title: 'New Title' }
    })

    res
      .assertOk()
      .assertJsonContains({ id: post.id, title: 'New Title' })
  })

  test('DELETE /api/posts/:id, deletes a post', async () => {
    const post = await Post.create({ title: 'To Delete', body: 'Content', userId: 1 })

    const res = await request.withToken(authToken).delete(`/api/posts/${post.id}`)
    res.assertStatus(204)

    // Verify it is gone
    const check = await request.withToken(authToken).get(`/api/posts/${post.id}`)
    check.assertNotFound()
  })

  test('unauthenticated requests are rejected', async () => {
    // No token: use the plain request client
    const res = await request.get('/api/posts')
    res.assertUnauthorized()
  })
})

Notice how the database is reset in beforeEach / afterEach so every test starts with a known, predictable state. The authenticated client is constructed fresh for each test using the token from the login response, making the auth flow itself part of the test coverage.