From 5b0b87b0903d98b680400eaf0b2be5e0afe8eb31 Mon Sep 17 00:00:00 2001 From: steinfletcher Date: Thu, 3 Sep 2026 12:49:25 +0100 Subject: [PATCH] Rewrite the README as a complete guide to the library - Replace the dead CircleCI badge with the GitHub Actions one and the godoc badge with pkg.go.dev. - Organise the content by task: building requests, asserting on responses, mocking, sequence diagrams, networking, debugging, framework integration. - Document features that were missing: HttpRequest, GraphQL, JSON request bodies, HeaderPresent/NotPresent, custom assertions and the IsSuccess helpers, Result.JSON, mock request/response builders, Times/AnyTimes/UnmatchedMocks, FixedDelay/Timeout, ObserveMocks, standalone mocks, HttpClient and the parallel-test caveat, Meta participant names, x/db database recording, custom reporters, EnableNetworking, TestingT/Ginkgo and Verifier. - Fix examples that did not compile: apitest.Cookie is not a function (NewCookie), 'var x :=', and a malformed query collection snippet. - Add the websocket and database examples to the examples table. Every Go snippet in the README was extracted and compiled against the library. Co-Authored-By: Claude Fable 5.1 --- README.md | 828 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 563 insertions(+), 265 deletions(-) diff --git a/README.md b/README.md index dc94a78..6f5f9a3 100644 --- a/README.md +++ b/README.md @@ -3,208 +3,400 @@

-Godoc -Build Status +Go Reference +Build Status Go Report Card Mentioned in Awesome Go

# apitest -A simple and extensible behavioural testing library. Supports mocking external http calls and renders sequence diagrams on completion. +A simple and extensible behavioural testing library for Go HTTP services. Tests are written the way a client uses the +API: build a request, send it to the handler, and assert on the response. External HTTP calls can be mocked, and every +test can render a sequence diagram of what happened. -In behavioural tests the internal structure of the app is not known by the tests. Data is input to the system and the outputs are expected to meet certain conditions. +In behavioural tests the internal structure of the app is not known by the tests. Data is input to the system and the +outputs are expected to meet certain conditions. + +- Fluent builders for requests and expectations +- Works with anything that implements `http.Handler`, or against a running server +- Declarative mocks for outbound HTTP calls, with matchers for every part of the request +- Sequence diagrams of each test, including mocked calls and database queries +- No third party dependencies; integrates with `testing`, Ginkgo and custom assertion libraries + +The API is stable. The library is maintained and issues are addressed; feature requests are considered. Join the conversation at #apitest on [https://gophers.slack.com](https://gophers.slack.com). Logo by @egonelbre -Note: The API for apitest is stable and complete - despite the lack of activity this repository is still actively maintained. Any new issues will be addressed. Feature requests will be considered. - ## Documentation -Please visit [https://apitest.dev](https://apitest.dev) for the latest documentation. +This README covers the whole library. The same material with more narrative is at +[https://apitest.dev](https://apitest.dev), and the API reference is on +[pkg.go.dev](https://pkg.go.dev/github.com/steinfletcher/apitest). ## Installation ```bash -go get -u github.com/steinfletcher/apitest +go get github.com/steinfletcher/apitest ``` +## Quick start + +```go +func TestGetUser(t *testing.T) { + apitest.New(). + Handler(handler). + Get("/user/1234"). + Expect(t). + Status(http.StatusOK). + Body(`{"id": "1234", "name": "Tate"}`). + End() +} +``` + +`Handler` takes any `http.Handler`, so a `http.ServeMux`, a gin engine, an echo instance and so on all work. Everything +before `Expect(t)` describes the request; everything after it describes the expected response. `End()` runs the test. +When the expected body is valid JSON it is compared structurally, so key order and whitespace do not matter. + ## Demo ![animated gif](./apitest.gif) -## Examples +## Building the request -### Framework and library integration examples +### Method and URL -| Example | Comment | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| [gin](https://github.com/steinfletcher/apitest/tree/master/examples/gin) | popular martini-like web framework | -| [graphql](https://github.com/steinfletcher/apitest/tree/master/examples/graphql) | using gqlgen.com to generate a graphql server | -| [gorilla](https://github.com/steinfletcher/apitest/tree/master/examples/gorilla) | the gorilla web toolkit | -| [iris](https://github.com/steinfletcher/apitest/tree/master/examples/iris) | iris web framework | -| [echo](https://github.com/steinfletcher/apitest/tree/master/examples/echo) | High performance, extensible, minimalist Go web framework | -| [fiber](https://github.com/steinfletcher/apitest/tree/master/examples/fiber) | Express inspired web framework written in Go | -| [httprouter](https://github.com/steinfletcher/apitest/tree/master/examples/httprouter) | High performance HTTP request router that scales well | -| [mocks](https://github.com/steinfletcher/apitest/tree/master/examples/mocks) | example mocking out external http calls | -| [sequence diagrams](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams) | generate sequence diagrams from tests. See the [demo](http://demo-html.apitest.dev.s3-website-eu-west-1.amazonaws.com/) | -| [Ginkgo](https://github.com/steinfletcher/apitest/tree/master/examples/ginkgo) | Ginkgo BDD test framework| +`Get`, `Post`, `Put`, `Patch` and `Delete` set the method and URL. Each has an `f` variant that formats the URL, and +`Method` covers anything else. -### Companion libraries +```go +apitest.Handler(handler).Getf("/user/%s", id) -| Library | Comment | -| ----------------------------------------------------------------------- | -----------------------------------------------| -| [JSONPath](https://github.com/steinfletcher/apitest-jsonpath) | JSONPath assertion addons | -| [CSS Selectors](https://github.com/steinfletcher/apitest-css-selector) | CSS selector assertion addons | -| [PlantUML](https://github.com/steinfletcher/apitest-plantuml) | Export sequence diagrams as plantUML | -| [DynamoDB](https://github.com/steinfletcher/apitest-dynamodb) | Add DynamoDB interactions to sequence diagrams | +apitest.Handler(handler).Method(http.MethodOptions).URL("/user") +``` -### Credits +A ready-made `*http.Request` can be used instead of the builder. -This library was influenced by the following software packages: +```go +req := httptest.NewRequest(http.MethodGet, "/user/1234", nil) -* [YatSpec](https://github.com/bodar/yatspec) for creating sequence diagrams from tests -* [MockMVC](https://spring.io) and [superagent](https://github.com/visionmedia/superagent) for the concept and behavioural testing approach -* [Gock](https://github.com/h2non/gock) for the approach to mocking HTTP services in Go -* [Baloo](https://github.com/h2non/baloo) for API design +apitest.Handler(handler). + HttpRequest(req). + Expect(t). + Status(http.StatusOK). + End() +``` -### Code snippets +### Headers -#### JSON body matcher +```go +apitest.Handler(handler). + Get("/hello"). + Header("Authorization", "Bearer token"). + Headers(map[string]string{"X-Request-Id": "12345"}). + ContentType("application/json"). + Expect(t). + Status(http.StatusOK). + End() +``` + +### Query parameters + +`Query`, `QueryParams` and `QueryCollection` can be combined. `QueryCollection` sets repeated parameters, so +`map[string][]string{"a": {"b", "c", "d"}}` is encoded as `a=b&a=c&a=d`. ```go -func TestApi(t *testing.T) { - apitest.New(). - Handler(handler). - Get("/user/1234"). - Expect(t). - Body(`{"id": "1234", "name": "Tate"}`). - Status(http.StatusOK). - End() -} +apitest.Handler(handler). + Get("/hello"). + QueryParams(map[string]string{"a": "1", "b": "2"}). + Query("c", "d"). + QueryCollection(map[string][]string{"e": {"f", "g"}}). + Expect(t). + Status(http.StatusOK). + End() ``` -#### JSONPath +### Cookies + +```go +apitest.Handler(handler). + Get("/hello"). + Cookie("session", "12345"). + Cookies(apitest.NewCookie("theme").Value("dark").Path("/")). + Expect(t). + Status(http.StatusOK). + End() +``` -For asserting on parts of the response body JSONPath may be used. A separate module must be installed which provides these assertions - `go get -u github.com/steinfletcher/apitest-jsonpath`. This is packaged separately to keep this library dependency free. +### Body -Given the response is `{"a": 12345, "b": [{"key": "c", "value": "result"}]}` +`Body` sets a raw body. `JSON` sets the body and the `Content-Type` header; it accepts a string, a `[]byte`, or any +value that can be marshalled. `BodyFromFile` and `JSONFromFile` read the body from disk. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - Expect(t). - Assert(jsonpath.Contains(`$.b[? @.key=="c"].value`, "result")). - End() -} +apitest.Handler(handler). + Post("/user"). + JSON(map[string]any{"name": "jan", "age": 32}). + Expect(t). + Status(http.StatusCreated). + End() ``` -and `jsonpath.Equals` checks for value equality +GraphQL requests are built with `GraphQLQuery`, or `GraphQLRequest` when an operation name is needed. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - Expect(t). - Assert(jsonpath.Equal(`$.a`, float64(12345))). - End() -} +apitest.Handler(handler). + Post("/graphql"). + GraphQLQuery(`query User($id: ID!) { user(id: $id) { name } }`, map[string]any{"id": "1234"}). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Custom assert functions +### Form data + +`FormData` sends an `application/x-www-form-urlencoded` body. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - Expect(t). - Assert(func(res *http.Response, req *http.Request) error { - assert.Equal(t, http.StatusOK, res.StatusCode) - return nil - }). - End() -} +apitest.Handler(handler). + Post("/hello"). + FormData("a", "1"). + FormData("b", "2", "3"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Assert cookies +`MultipartFormData` and `MultipartFile` send `multipart/form-data`. The two form styles cannot be combined in one +request. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Patch("/hello"). - Expect(t). - Status(http.StatusOK). - Cookies(apitest.Cookie("ABC").Value("12345")). - CookiePresent("Session-Token"). - CookieNotPresent("XXX"). - Cookies( - apitest.Cookie("ABC").Value("12345"), - apitest.Cookie("DEF").Value("67890"), - ). - End() +apitest.Handler(handler). + Post("/upload"). + MultipartFormData("description", "holiday photos"). + MultipartFile("file", "testdata/beach.jpg", "testdata/sunset.jpg"). + Expect(t). + Status(http.StatusOK). + End() +``` + +Files are read from the OS by default. `UseFS` swaps in any `fs.FS`, such as an in-memory `fstest.MapFS`. + +```go +inMemFS := fstest.MapFS{ + "audio.wav": &fstest.MapFile{Data: []byte{19, 2, 123, 12, 35, 1}}, } + +apitest.Handler(handler). + UseFS(inMemFS). + Post("/upload"). + MultipartFile("file", "audio.wav"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Assert headers +### Basic auth ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - Expect(t). - Status(http.StatusOK). - Headers(map[string]string{"ABC": "12345"}). - End() +apitest.Handler(handler). + Get("/hello"). + BasicAuth("username", "password"). + Expect(t). + Status(http.StatusOK). + End() +``` + +### Context + +`WithContext` sets the request context, which is how deadlines, cancellation and context values reach the handler. + +```go +ctx, cancel := context.WithTimeout(context.Background(), time.Second) +defer cancel() + +apitest.Handler(handler). + Get("/hello"). + WithContext(ctx). + Expect(t). + Status(http.StatusOK). + End() +``` + +### Intercept + +`Intercept` receives the built `*http.Request` just before it is sent, for changes the builder does not cover. + +```go +apitest.Handler(handler). + Intercept(func(req *http.Request) { + req.URL.RawQuery = "a[]=xxx&a[]=yyy" + }). + Get("/hello"). + Expect(t). + Status(http.StatusOK). + End() +``` + +## Asserting on the response + +### Status and body + +```go +apitest.Handler(handler). + Get("/user/1234"). + Expect(t). + Status(http.StatusOK). + Body(`{"id": "1234", "name": "Tate"}`). + End() +``` + +A JSON body is compared structurally. Any other body is compared as an exact string. `Bodyf` formats the expected +body and `BodyFromFile` reads it from disk. + +### Headers + +```go +apitest.Handler(handler). + Get("/hello"). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "application/json"). + Headers(map[string]string{"X-Request-Id": "12345"}). + HeaderPresent("Etag"). + HeaderNotPresent("X-Powered-By"). + End() +``` + +### Cookies + +Only the fields set on the expected cookie are compared, so `NewCookie("session").Value("12345")` ignores the path, +expiry and other attributes of the actual cookie. + +```go +apitest.Handler(handler). + Patch("/hello"). + Expect(t). + Status(http.StatusOK). + Cookie("session", "12345"). + Cookies(apitest.NewCookie("theme").Value("dark").HttpOnly(true)). + CookiePresent("csrf"). + CookieNotPresent("legacy"). + End() +``` + +### Custom assertions + +`Assert` takes a function that receives copies of the response and request and returns an error on failure. It can +be called several times. + +```go +apitest.Handler(handler). + Get("/hello"). + Expect(t). + Assert(func(res *http.Response, req *http.Request) error { + if res.Header.Get("X-Rate-Limit") == "" { + return errors.New("expected a rate limit header") + } + return nil + }). + End() +``` + +`apitest.IsSuccess`, `apitest.IsClientError` and `apitest.IsServerError` are ready-made assertions on the status code +range. + +### JSONPath + +For asserting on parts of the response body, the separate +[apitest-jsonpath](https://github.com/steinfletcher/apitest-jsonpath) module provides JSONPath assertions. It is +packaged separately to keep this library dependency free. + +Given the response `{"a": 12345, "b": [{"key": "c", "value": "result"}]}`: + +```go +apitest.Handler(handler). + Get("/hello"). + Expect(t). + Assert(jsonpath.Contains(`$.b[? @.key=="c"].value`, "result")). + Assert(jsonpath.Equal(`$.a`, float64(12345))). + End() +``` + +### The result + +`End()` returns a `Result` holding the response, so further checks can be made after the test has run. + +```go +result := apitest.Handler(handler). + Get("/user/1234"). + Expect(t). + Status(http.StatusOK). + End() + +var user struct { + Name string `json:"name"` } +result.JSON(&user) ``` -#### Mocking external http calls +## Mocking external HTTP calls + +If the handler under test calls other services, those calls can be answered by mocks. A mock describes the request to +match and the response to return. ```go var getUser = apitest.NewMock(). - Get("/user/12345"). + Get("http://users/api/user/12345"). RespondWith(). Body(`{"name": "jon", "id": "1234"}`). Status(http.StatusOK). End() var getPreferences = apitest.NewMock(). - Get("/preferences/12345"). + Get("http://preferences/api/preferences/12345"). + Header("Authorization", "Bearer .*"). RespondWith(). - Body(`{"is_contactable": true}`). + JSON(map[string]any{"is_contactable": true}). Status(http.StatusOK). End() -func TestApi(t *testing.T) { +func TestGetUser(t *testing.T) { apitest.New(). Mocks(getUser, getPreferences). Handler(handler). - Get("/hello"). + Get("/user/12345"). Expect(t). Status(http.StatusOK). - Body(`{"name": "jon", "id": "1234"}`). + Body(`{"name": "jon", "is_contactable": true}`). End() } ``` -It is possible to configure the mock for using `AnyTimes` feature, it allows a mock to be invoked any number of times -without failing the asserts if it is not used the expected number of times. -This is very useful in scenarios where the exact number of invocations is not known or not important. +The request side supports `Header`, `Headers`, `Query`, `QueryParams`, `QueryCollection`, `Body`, `Bodyf`, +`BodyFromFile`, `BodyRegexp`, `JSON`, `FormData`, `Cookie` and `BasicAuth`, plus `Present` and `NotPresent` +variants for headers, query parameters, form data and cookies. The response side supports `Status`, `Body`, `Bodyf`, +`BodyFromFile`, `JSON`, `Header`, `Headers`, `Cookie` and `Cookies`. When no `Content-Type` is set on the response, +`application/json` is used for a JSON body and `text/plain` otherwise. + +Mocks work by replacing the transport of `http.DefaultClient` for the duration of the test. If the code under test +uses its own `http.Client`, pass it with `HttpClient` so its transport is replaced instead. Because the default +transport is process-wide, tests that use mocks should not run in parallel unless each provides its own client. ```go -var getUser := apitest.NewMock(). - Get("http://localhost:8080"). - RespondWith(). - Status(http.StatusOK). - AnyTimes(). - End() +apitest.New(). + HttpClient(client). + Mocks(getUser). + Handler(handler). + Get("/user/12345"). + Expect(t). + Status(http.StatusOK). + End() ``` -Note: The `AnyTimes` method can be combined with other methods such as `Times`, but if `AnyTimes` is set, the `Times` setting will have no effect. -#### How mocks are matched +### How mocks are matched Mocks are tried in the order they are passed to `Mocks`, and the first mock whose matchers all pass is used. Each mock is used once unless `Times` or `AnyTimes` is set. The built-in matchers behave as follows: @@ -219,234 +411,340 @@ is used once unless `Times` or `AnyTimes` is set. The built-in matchers behave a * **Body** must equal the mock body, or be equivalent JSON. `BodyRegexp` matches the body against a regular expression. * **Cookies** and **basic auth** must equal the mock's. -Custom matchers can be added with `AddMatcher`. +When no mock matches, the call fails with an error listing why each mock was rejected. Run the test with `Debug()` to +see it. -#### Generating sequence diagrams from tests +### Custom matchers -```go +`AddMatcher` adds a matcher alongside the built-in ones. It receives the actual request and the mock's request +specification, and returns an error when the request should not match. -func TestApi(t *testing.T) { - apitest.New(). - Report(apitest.SequenceDiagram()). - Mocks(getUser, getPreferences). - Handler(handler). - Get("/hello"). - Expect(t). - Status(http.StatusOK). - Body(`{"name": "jon", "id": "1234"}`). - End() -} +```go +var getPreferences = apitest.NewMock(). + Get("/preferences/12345"). + AddMatcher(func(r *http.Request, mr *apitest.MockRequest) error { + if r.URL.Scheme != "https" { + return errors.New("expected an https request") + } + return nil + }). + RespondWith(). + Status(http.StatusOK). + End() ``` -It is possible to override the default storage location by passing the formatter instance `Report(apitest.SequenceDiagram(".sequence-diagrams"))`. -You can bring your own formatter too if you want to produce custom output. By default a sequence diagram is rendered on a html page. See the [demo](http://demo-html.apitest.dev.s3-website-eu-west-1.amazonaws.com/) +### Times and unused mocks -#### Debugging http requests and responses generated by api test and any mocks +By default a mock answers one request. `Times(n)` makes it answer `n` requests and fails the test if it is called +fewer times. `AnyTimes` lets a mock answer any number of calls, including none, and takes precedence over `Times`. ```go -func TestApi(t *testing.T) { - apitest.New(). - Debug(). - Handler(handler). - Get("/hello"). - Expect(t). - Status(http.StatusOK). - End() -} +var getUser = apitest.NewMock(). + Get("http://users/api/user/12345"). + RespondWith(). + Status(http.StatusOK). + Times(2). + End() + +var healthCheck = apitest.NewMock(). + Get("http://users/health"). + RespondWith(). + Status(http.StatusOK). + AnyTimes(). + End() ``` -#### Provide basic auth in the request +Mocks that were never called are reported on the result. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - BasicAuth("username", "password"). - Expect(t). - Status(http.StatusOK). - End() +result := apitest.New(). + Mocks(getUser, getPreferences). + Handler(handler). + Get("/user/12345"). + Expect(t). + Status(http.StatusOK). + End() + +for _, unmatched := range result.UnmatchedMocks() { + t.Logf("mock not called: %s", unmatched.URL.String()) } ``` -#### Pass a custom context to the request +### Slow and failing dependencies + +`Timeout` makes the mocked call fail with a timeout error. `FixedDelay` waits the given number of milliseconds before +responding, and only takes effect when the test enables delays with `EnableMockResponseDelay`. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - WithContext(context.TODO()). +var slowUsers = apitest.NewMock(). + Get("http://users/api/user/12345"). + RespondWith(). + FixedDelay(500). + Status(http.StatusOK). + End() + +var brokenPreferences = apitest.NewMock(). + Get("http://preferences/api/preferences/12345"). + RespondWith(). + Timeout(). + End() + +func TestGetUser_WhenDependenciesAreSlow(t *testing.T) { + apitest.New(). + EnableMockResponseDelay(). + Mocks(slowUsers, brokenPreferences). + Handler(handler). + Get("/user/12345"). Expect(t). - Status(http.StatusOK). + Status(http.StatusGatewayTimeout). End() } ``` -#### Test handler timeouts +### Observing mock calls -Wrap the handler in the standard library's `http.TimeoutHandler` when passing it to apitest. A handler that -overruns the timeout produces the `503` status and body that `TimeoutHandler` generates, which can be asserted on -as usual. To check that a handler honours a deadline instead, pass a context with a deadline using `WithContext`. +`ObserveMocks` is called with the request and response of every mocked call. ```go -func TestApi(t *testing.T) { - handler := http.TimeoutHandler(slowHandler, 50*time.Millisecond, "request timed out") - - apitest.Handler(handler). - Get("/slow"). - Expect(t). - Status(http.StatusServiceUnavailable). - Body("request timed out"). - End() -} +apitest.New(). + ObserveMocks(func(res *http.Response, req *http.Request, apiTest *apitest.APITest) { + t.Logf("%s %s -> %d", req.Method, req.URL, res.StatusCode) + }). + Mocks(getUser). + Handler(handler). + Get("/user/12345"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Provide cookies in the request +### Standalone mocks + +Mocks can also be used outside an apitest test, for example in a unit test of an HTTP client. `EndStandalone` and +`NewStandaloneMocks(...).End()` install the mocks and return a function that removes them. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - Cookies(apitest.Cookie("ABC").Value("12345")). - Expect(t). +func TestUserClient(t *testing.T) { + reset := apitest.NewMock(). + Get("http://users/api/user/12345"). + RespondWith(). + Body(`{"name": "jon"}`). Status(http.StatusOK). - End() + EndStandalone() + defer reset() + + user, err := userClient.Get("12345") + if err != nil { + t.Fatal(err) + } + if user.Name != "jon" { + t.Fatalf("unexpected user %+v", user) + } } ``` -#### Provide headers in the request +Use `HttpClient` on the mock to target a specific client, and `Debug` on the mock to log the matching. + +## Sequence diagrams and reports + +`Report` renders every test into a report once it completes. The built-in `SequenceDiagram` formatter writes an HTML +page per test with a sequence diagram of the request, any mocked calls, and the response, along with the full wire +representation of each message. See the +[demo](http://demo-html.apitest.dev.s3-website-eu-west-1.amazonaws.com/). ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Delete("/hello"). - Headers(map[string]string{"My-Header": "12345"}). +func TestGetUser(t *testing.T) { + apitest.New("gets the user"). + Report(apitest.SequenceDiagram()). + Mocks(getUser, getPreferences). + Handler(handler). + Get("/user/12345"). Expect(t). Status(http.StatusOK). End() } ``` -#### Provide query parameters in the request +The name passed to `New` appears in the report. Diagrams are written to `.sequence` by default; pass a path to +`SequenceDiagram` to change that. The participants are labelled `cli` and `sut` unless `Meta` provides names. + +```go +apitest.New("gets the user"). + Report(apitest.SequenceDiagram(".sequence-diagrams")). + Meta(map[string]any{ + "consumerName": "web-app", + "systemUnderTestName": "user-api", + }). + Handler(handler). + Get("/user/12345"). + Expect(t). + Status(http.StatusOK). + End() +``` + +### Database calls in diagrams -`Query`, `QueryParams` and `QueryCollection` can all be used in combination +The `x/db` package wraps a `database/sql` driver so that queries made during the test appear in the diagram. Wrap the +driver with a `Recorder`, register the wrapped driver, open the database through it, and pass the same recorder to the +test. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - QueryParams(map[string]string{"a": "1", "b": "2"}). - Query("c", "d"). +var recorder = apitest.NewTestRecorder() + +func init() { + sql.Register("recorded-sqlite3", apitestdb.WrapWithRecorder("sqlite3", recorder)) +} + +func TestGetUser(t *testing.T) { + db, err := sql.Open("recorded-sqlite3", "./users.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + apitest.New("gets the user"). + Recorder(recorder). + Report(apitest.SequenceDiagram()). + Handler(newApp(db)). + Get("/user/12345"). Expect(t). Status(http.StatusOK). End() } ``` -Providing `{"a": {"b", "c", "d"}` results in parameters encoded as `a=b&a=c&a=d`. -`QueryCollection` can be used in combination with `Query` +`WrapConnectorWithRecorder` does the same for a `driver.Connector`. See the +[sqlite](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-sqlite-database), +[mysql](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-mysql-database) and +[postgres](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-postgres-database) +examples. + +### Custom reports + +Anything implementing `ReportFormatter` can be passed to `Report`. It receives the `Recorder`, which holds the title, +the meta data and the ordered list of events, so reports can be rendered in any format. The +[PlantUML](https://github.com/steinfletcher/apitest-plantuml) companion library is one example. Custom events can be +added to the recorder from your own code with `AddMessageRequest` and `AddMessageResponse`. + +## Testing a running server + +`EnableNetworking` sends the request over the network instead of to a handler. Pass a client to control timeouts, +cookies and redirects. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Get("/hello"). - QueryCollection(map[string][]string{"a": {"b", "c", "d"}}). - Expect(t). - Status(http.StatusOK). - End() -} +client := &http.Client{Timeout: 5 * time.Second} + +apitest.New(). + EnableNetworking(client). + Get("http://localhost:8080/health"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Provide a url encoded form body in the request +## Debugging + +`Debug` prints the wire representation of the inbound request, the final response, and every mocked call, together +with the reasons a request failed to match any mock. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Post("/hello"). - FormData("a", "1"). - FormData("b", "2"). - FormData("b", "3"). - FormData("c", "4", "5", "6"). - Expect(t). - Status(http.StatusOK). - End() -} +apitest.New(). + Debug(). + Handler(handler). + Get("/hello"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Provide a multipart/form-data +`Observe` gives programmatic access to the same data. ```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Post("/hello"). - MultipartFormData("a", "1", "2"). - MultipartFile("file", "path/to/some.file1", "path/to/some.file2"). - Expect(t). - Status(http.StatusOK). - End() -} +apitest.New(). + Observe(func(res *http.Response, req *http.Request, apiTest *apitest.APITest) { + // inspect the copies of res and req + }). + Handler(handler). + Get("/hello"). + Expect(t). + Status(http.StatusOK). + End() ``` -#### Provide a multipart/form-data with custom filesystem +## Testing handler timeouts + +Wrap the handler in the standard library's `http.TimeoutHandler` when passing it to apitest. A handler that overruns +the timeout produces the `503` status and body that `TimeoutHandler` generates, which can be asserted on as usual. To +check that a handler honours a deadline instead, pass a context with a deadline using `WithContext`. ```go -inMemFS := fstest.MapFS{ - "audio.wav": &fstest.MapFile{ - Data: []byte{19,2,123,12,35,1}, - Mode: fs.FileMode(0644), - ModTime: time.Now(), - }, - "audio.mp3": &fstest.MapFile{ - Data: []byte{21,13,88,123,9,8}, - Mode: fs.FileMode(0644), - ModTime: time.Now(), - }, -} +func TestSlowEndpoint(t *testing.T) { + handler := http.TimeoutHandler(slowHandler, 50*time.Millisecond, "request timed out") -func TestApi(t *testing.T) { apitest.Handler(handler). - UseFS(inMemFS). - Post("/hello"). - MultipartFormData("a", "1", "2"). - MultipartFile("file", "audio.wav", "audio.mp3"). + Get("/slow"). Expect(t). - Status(http.StatusOK). + Status(http.StatusServiceUnavailable). + Body("request timed out"). End() } ``` +## Test framework integration + +`Expect` accepts any value implementing `TestingT`, which is the subset of `*testing.T` that apitest needs: +`Errorf`, `Fatal` and `Fatalf`. Ginkgo's `GinkgoT()` satisfies it, so apitest works inside Ginkgo specs; see the +[Ginkgo example](https://github.com/steinfletcher/apitest/tree/master/examples/ginkgo). -#### Capture the request and response data +Assertions are performed through the `Verifier` interface. `Verifier` swaps in a different implementation, for example +`NoopVerifier` to run a test without failing it, or an adapter over your preferred assertion library. ```go -func TestApi(t *testing.T) { - apitest.New(). - Observe(func(res *http.Response, req *http.Request, apiTest *apitest.APITest) { - // do something with res and req - }). - Handler(handler). - Get("/hello"). - Expect(t). - Status(http.StatusOK). - End() -} +apitest.New(). + Verifier(apitest.NoopVerifier{}). + Handler(handler). + Get("/hello"). + Expect(t). + Status(http.StatusTeapot). + End() ``` -#### Intercept the request +## Examples -This is useful for mutating the request before it is sent to the system under test. +### Framework and library integration examples -```go -func TestApi(t *testing.T) { - apitest.Handler(handler). - Intercept(func(req *http.Request) { - req.URL.RawQuery = "a[]=xxx&a[]=yyy" - }). - Get("/hello"). - Expect(t). - Status(http.StatusOK). - End() -} -``` +| Example | Comment | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| [gin](https://github.com/steinfletcher/apitest/tree/master/examples/gin) | popular martini-like web framework | +| [graphql](https://github.com/steinfletcher/apitest/tree/master/examples/graphql) | using gqlgen.com to generate a graphql server | +| [gorilla](https://github.com/steinfletcher/apitest/tree/master/examples/gorilla) | the gorilla web toolkit | +| [iris](https://github.com/steinfletcher/apitest/tree/master/examples/iris) | iris web framework | +| [echo](https://github.com/steinfletcher/apitest/tree/master/examples/echo) | High performance, extensible, minimalist Go web framework | +| [fiber](https://github.com/steinfletcher/apitest/tree/master/examples/fiber) | Express inspired web framework written in Go | +| [httprouter](https://github.com/steinfletcher/apitest/tree/master/examples/httprouter) | High performance HTTP request router that scales well | +| [Ginkgo](https://github.com/steinfletcher/apitest/tree/master/examples/ginkgo) | Ginkgo BDD test framework | +| [mocks](https://github.com/steinfletcher/apitest/tree/master/examples/mocks) | mocking out external http calls, including a custom matcher | +| [websockets](https://github.com/steinfletcher/apitest/tree/master/examples/websockets) | a websocket handler under test | +| [sequence diagrams](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams) | generate sequence diagrams from tests. See the [demo](http://demo-html.apitest.dev.s3-website-eu-west-1.amazonaws.com/) | +| [sqlite](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-sqlite-database), [mysql](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-mysql-database), [postgres](https://github.com/steinfletcher/apitest/tree/master/examples/sequence-diagrams-with-postgres-database) | database queries recorded in sequence diagrams | + +### Companion libraries + +| Library | Comment | +| ----------------------------------------------------------------------- | -----------------------------------------------| +| [JSONPath](https://github.com/steinfletcher/apitest-jsonpath) | JSONPath assertion addons | +| [CSS Selectors](https://github.com/steinfletcher/apitest-css-selector) | CSS selector assertion addons | +| [PlantUML](https://github.com/steinfletcher/apitest-plantuml) | Export sequence diagrams as plantUML | +| [DynamoDB](https://github.com/steinfletcher/apitest-dynamodb) | Add DynamoDB interactions to sequence diagrams | + +### Credits + +This library was influenced by the following software packages: + +* [YatSpec](https://github.com/bodar/yatspec) for creating sequence diagrams from tests +* [MockMVC](https://spring.io) and [superagent](https://github.com/visionmedia/superagent) for the concept and behavioural testing approach +* [Gock](https://github.com/h2non/gock) for the approach to mocking HTTP services in Go +* [Baloo](https://github.com/h2non/baloo) for API design ## Contributing