apitest

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package apitest tests an http.Handler through a real HTTP client and an in-memory server.

A test states a request and what a client must see:

func TestGetArticle(t *testing.T) {
	api := apitest.Make(t, handler)

	api.Get("/articles/42").Do().
		Status(http.StatusOK).
		Header("Content-Type", "application/json").
		JSONEqual(map[string]any{
			"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
		})
}

The request crosses a real HTTP connection, so routing, the headers net/http adds, the status and the bytes of the body are what a client gets in production. There is no network: Make starts a server from httptest.NewTestServer and stops it when the test ends. A panic in the handler fails the test.

Requests

Test starts a request with Get, Post, Put, Patch, Delete, Head or Options, or with any method through Test.Request. A Request sets headers, query parameters, cookies, the Host, a context and at most one body: Request.JSON encodes a value, and Request.Body sends raw bytes with any media type, for example malformed JSON. Nothing is sent before Request.Do, which reads and closes the whole response.

The client follows no redirects and keeps no cookies, so every response is the handler's own answer. A request fails after five seconds, or the duration set with WithTimeout, and always before the deadline of the test, so a handler that hangs fails with the request in the message.

Assertions

A Response checks the status, the headers and the body:

JSONEqual ignores object key order and whitespace, and nothing else. Extra and missing fields, array order and number literals count, so 1 and 1.0 differ as they do for a typed client. A body with a duplicate object key fails, because parsers disagree on which value wins. A mismatch lists up to ten differences by path, such as $.data.items[0].title.

Response.JSON decodes the body into a type for checks that need code, such as a generated identifier, and Response.Raw returns the buffered *http.Response.

Parallel tests

A Test reports through the testing.TB it was made with. Make one for each parallel subtest, so that a failure points at the subtest and its line.

Example
package main

import (
	"net/http"
	"testing"

	"github.com/uchaloop/httpx/apitest"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
	"github.com/uchaloop/httpx/response"
)

type exampleArticle struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))

			return
		}

		_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
	})

	mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
		input, err := request.DecodeJSON[exampleArticle](r)
		if err != nil {
			_ = problem.Write(w, mapper.Map(r, err))

			return
		}

		input.ID = 42
		_ = response.Write(w, response.Created("/articles/42", input))
	})

	return mux
}

func main() {
	var t *testing.T // the t of the test function

	api := apitest.Make(t, exampleHandler())

	api.Get("/articles/42").Do().
		Status(http.StatusOK).
		Header("Content-Type", "application/json").
		JSONEqual(map[string]any{
			"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
		})

	api.Get("/articles/7").Do().
		Status(http.StatusNotFound).
		Header("Content-Type", problem.ContentType).
		JSONEqual(map[string]any{
			"type":     "about:blank",
			"title":    "Not Found",
			"status":   404,
			"instance": "/articles/7",
		})
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Option

type Option func(*Test)

Option configures a Test.

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the maximum duration of each request, including reading the response body. The default is five seconds.

Example
package main

import (
	"net/http"
	"testing"
	"time"

	"github.com/uchaloop/httpx/apitest"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
	"github.com/uchaloop/httpx/response"
)

type exampleArticle struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))

			return
		}

		_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
	})

	mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
		input, err := request.DecodeJSON[exampleArticle](r)
		if err != nil {
			_ = problem.Write(w, mapper.Map(r, err))

			return
		}

		input.ID = 42
		_ = response.Write(w, response.Created("/articles/42", input))
	})

	return mux
}

func main() {
	var t *testing.T // the t of the test function

	// A slow endpoint gets more than the default five seconds.
	api := apitest.Make(t, exampleHandler(), apitest.WithTimeout(30*time.Second))

	api.Get("/articles/42").Do().Status(http.StatusOK)
}

type Request

type Request struct {
	// contains filtered or unexported fields
}

Request is a single-use request builder. Nothing is sent before Do. It is not safe for concurrent use.

func (*Request) Body

func (r *Request) Body(contentType string, body io.Reader) *Request

Body sets a raw body, for example malformed JSON, with media type contentType. An empty contentType sends no Content-Type. A request has at most one body.

Example
package main

import (
	"net/http"
	"strings"
	"testing"

	"github.com/uchaloop/httpx/apitest"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
	"github.com/uchaloop/httpx/response"
)

type exampleArticle struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))

			return
		}

		_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
	})

	mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
		input, err := request.DecodeJSON[exampleArticle](r)
		if err != nil {
			_ = problem.Write(w, mapper.Map(r, err))

			return
		}

		input.ID = 42
		_ = response.Write(w, response.Created("/articles/42", input))
	})

	return mux
}

func main() {
	var t *testing.T // the t of the test function

	api := apitest.Make(t, exampleHandler())

	// Malformed JSON is sent as is.
	api.Post("/articles").Body("application/json", strings.NewReader(`{"title":`)).Do().
		Status(http.StatusBadRequest).
		Header("Content-Type", problem.ContentType)

	// So is a body in a media type the endpoint does not read.
	api.Post("/articles").Body("text/plain", strings.NewReader("title=HTTP")).Do().
		Status(http.StatusUnsupportedMediaType).
		Header("Accept", "application/json")
}

func (*Request) Context

func (r *Request) Context(ctx context.Context) *Request

Context sets the parent context of the request, for example one that a mock cancels on an unexpected call. Cancellation reaches the handler; context values do not cross the HTTP connection.

func (*Request) Cookie

func (r *Request) Cookie(cookie *http.Cookie) *Request

Cookie adds a copy of cookie to the request.

func (*Request) Do

func (r *Request) Do() *Response

Do sends the request once, then reads and closes the complete response body. Transport, timeout and read failures stop the test.

func (*Request) Header

func (r *Request) Header(name string, values ...string) *Request

Header sets all values of a request header, replacing earlier ones. An explicit Content-Type overrides the media type set by JSON or Body. Use Host for the Host header: net/http ignores it in Header.

func (*Request) Host

func (r *Request) Host(host string) *Request

Host sets the Host header, for example for host-based routing. The default is example.com.

func (*Request) JSON

func (r *Request) JSON(value any) *Request

JSON sets the body to value encoded by encoding/json, with media type application/json. A request has at most one body.

Example
package main

import (
	"net/http"
	"testing"

	"github.com/uchaloop/httpx/apitest"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
	"github.com/uchaloop/httpx/response"
)

type exampleArticle struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))

			return
		}

		_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
	})

	mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
		input, err := request.DecodeJSON[exampleArticle](r)
		if err != nil {
			_ = problem.Write(w, mapper.Map(r, err))

			return
		}

		input.ID = 42
		_ = response.Write(w, response.Created("/articles/42", input))
	})

	return mux
}

func main() {
	var t *testing.T // the t of the test function

	apitest.Make(t, exampleHandler()).Post("/articles").
		JSON(map[string]any{"title": "HTTP boundaries"}).
		Do().
		Status(http.StatusCreated).
		Header("Location", "/articles/42").
		JSONEqual(map[string]any{
			"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
		})
}

func (*Request) Query

func (r *Request) Query(name string, values ...string) *Request

Query appends values of a query parameter after the query written in the path. Parameters added here are encoded in name order.

type Response

type Response struct {
	// contains filtered or unexported fields
}

Response holds a buffered response. Assertions and Raw never consume it.

func (*Response) BodyEqual

func (r *Response) BodyEqual(expected string) *Response

BodyEqual asserts the exact body. BodyEqual("") asserts an empty body.

func (*Response) Header

func (r *Response) Header(name string, values ...string) *Response

Header asserts all values of a response header, in order.

func (*Response) HeaderAbsent

func (r *Response) HeaderAbsent(name string) *Response

HeaderAbsent asserts that the response has no such header.

func (*Response) JSON

func (r *Response) JSON[T any]() T

JSON decodes the body into T as a client using encoding/json would: unknown fields are allowed and the body must hold exactly one JSON value. A decode failure stops the test.

Example
package main

import (
	"net/http"
	"testing"

	"github.com/uchaloop/httpx/apitest"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
	"github.com/uchaloop/httpx/response"
)

type exampleArticle struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))

			return
		}

		_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
	})

	mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
		input, err := request.DecodeJSON[exampleArticle](r)
		if err != nil {
			_ = problem.Write(w, mapper.Map(r, err))

			return
		}

		input.ID = 42
		_ = response.Write(w, response.Created("/articles/42", input))
	})

	return mux
}

func main() {
	var t *testing.T // the t of the test function

	type envelope struct {
		Data exampleArticle `json:"data"`
	}

	created := apitest.Make(t, exampleHandler()).Post("/articles").
		JSON(map[string]any{"title": "HTTP boundaries"}).
		Do().
		Status(http.StatusCreated).
		JSON[envelope]()

	if created.Data.ID <= 0 {
		t.Errorf("id = %d, want a positive id", created.Data.ID)
	}
}

func (*Response) JSONEqual

func (r *Response) JSONEqual(expected any) *Response

JSONEqual asserts that the body is the same JSON document as expected encoded by encoding/json. Object key order and whitespace are ignored. Array order, extra and missing fields, null and number literals are not: 1 and 1.0 differ, as they do for a typed client. A body that is not exactly one JSON document, or has duplicate object keys, stops the test.

func (*Response) Raw

func (r *Response) Raw() *http.Response

Raw returns a copy of the response with its own reader over the buffered body.

func (*Response) Status

func (r *Response) Status(code int) *Response

Status asserts the status code. A mismatch stops the test and prints the body: a response with another status has another shape, so later assertions would only add noise.

type Test

type Test struct {
	// contains filtered or unexported fields
}

Test executes requests through an isolated in-memory HTTP server. Make a separate Test for each parallel subtest.

func Make

func Make(t testing.TB, h http.Handler, opts ...Option) *Test

Make starts an in-memory server for h and registers its shutdown with t. The client follows no redirects and stores no cookies.

func (*Test) Delete

func (api *Test) Delete(path string) *Request

Delete starts a DELETE request.

func (*Test) Get

func (api *Test) Get(path string) *Request

Get starts a GET request.

func (*Test) Head

func (api *Test) Head(path string) *Request

Head starts a HEAD request. A client never receives a HEAD response body.

func (*Test) Options

func (api *Test) Options(path string) *Request

Options starts an OPTIONS request.

func (*Test) Patch

func (api *Test) Patch(path string) *Request

Patch starts a PATCH request.

func (*Test) Post

func (api *Test) Post(path string) *Request

Post starts a POST request.

func (*Test) Put

func (api *Test) Put(path string) *Request

Put starts a PUT request.

func (*Test) Request

func (api *Test) Request(method, path string) *Request

Request starts a request with any method. The path must be local and absolute. A query string in the path is sent unchanged, so it may be malformed on purpose.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL