hex

package module
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Aug 2, 2021 License: MIT Imports: 9 Imported by: 0

README

= HEx - Http EXpectations
:toc:

HEx is a simple wrapper that extends `httptest.Server` with an expectation syntax, allowing you to create mock APIs using a simple and expressive DSL:

[source, go]
----
func TestUserClient(t*testing.T) {
	// A mock of some remote user service
	server := hex.NewServer(t, nil)	 // nil, or optional http.Handler

	server.ExpectReq("GET", "/users").
		WithHeader("Authorization", "Bearer xxyyzz")
		WithQuery("search", "foo").
		RespondWith(200, `{"id": 123, "name": "test_user"}`)

	// Actual client implementation would go here
	http.Get(server.URL + "/foo")

	// Output:
	// example_test.go:12: One or more HTTP expectations failed
	// example_test.go:12: Expectations
	// example_test.go:12: 	GET /users with header matching Authorization="Bearer xyz"query string matching search="foo" - failed, no matching requests
	// example_test.go:12: Unmatched Requests
	// example_test.go:12: 	GET /foo
}
----

== Getting Started

Hex  provides a higher level `Server` which embeds `http.Server`. Create one with `hex.NewServer`, and start making expectations.
The arguments to `hex.NewServer` are a `*testing.T`, and an optional `http.Handler` which may be `nil`.
If an existing `http.Handler` is passed to `NewServer`, hex will pass requests to it *after* checking them against its tree of expectations.

[source,go]
----
s := hex.NewServer(t, http.HandlerFunc(rw ResponseWriter, req *http.Request) {
	fmt.Fprintf(tw, "Ok")
})

s.ExpectReq("GET", "/users").WithQuery("page", "1")

http.Get(s.URL + "/users?page=1") // Match
----

If you have an existing mock, it can embed an `hex.Expecter`, which provides `ExpectReq` for setting up expectations, and `LogReq` for logging incoming requests so they can be matched against expectations. https://github.com/meagar/hex/blob/main/server.go([`Server`] does exactly this, and serves an an example of how to write up the necessary plumbing.

== Matching Requests

Expectations are setup via `ExpectReq`, which accepts an HTTP method (one of `"GET"`, `"POST"`, `"PATCH"`, etc) and a path (not including query string):

[source,go]
----
server.ExpectReq("GET", "/path/to/resource")
----

`ExpectReq` accepts one or two `interface{}` values, where each value is one of the following:
* A string, ie `"GET"` or `"/path/to/resource`
* A regular expression created via `regexp.MustCompile` or the convenience method `hex.R`
* A built-in matcher like `hex.Any` or `hex.None`
* A function of type `hex.MatchFn` (`func(req*http.Request) bool`)
* A `map[interface{}]interface{}` which can recursively contain any of the above (typically only useful for matching against header/body/query string)

=== Reporting Failure

hex will automatically report failures, and let you know which HTTP requests were made that didn't match any expectations:

[source,go]
----
func TestExample(t *testing.T) {
	s := hex.NewServer(t, nil)

	s.ExpectReq("GET", "/foo")

	http.Get(s.URL + "/bar")
}
----

Output:

[source,plain]
----
$ go test ./example
--- FAIL: TestExample (0.00s)
    server.go:29: One or more HTTP expectations failed
    print.go:205: Expectations
    print.go:205: 	GET /foo - failed, no matching requests
    print.go:205: Unmatched Requests
    print.go:205: 	GET /bar
FAIL
FAIL	github.com/meagar/hex/example	0.260s
FAIL
----

=== Matching against strings, regular expressions, functions and more

Any key or value given to `ExpectReq`, `WithQuery`, `WithHeader` or `WithBody` can one of:

* A string, in which case case-sensitive exact matching is used:
+
[source,go]
server.ExpectReq("GET", "/users") // matches GET /users?foo=bar

* A regular expression (via `regexp.MustCompile` or `hex.R`):
+
[source,go]
server.ExpectReq(hex.R("^(POST|PATCH)$", hex.R("^users/\d+$")

* One of several predefined constants like `hex.Any` or `hex.None`
+
[source,go]
----
server.ExpectReq("GET", hex.Any)                             // matches any GET request
server.ExpectReq(hex.Any, hex.Any)                           // matches *any* request
server.ExpectReq(hex.Any, hex.Any).WithQuery(hex.Any, "123") // Matches any request with any query string parameter having the value "123"
----

* A map of `interface{}`/`interface{}` pairs, where each `interface{}` value is itself a string/regex/map/
+
[source,go]
----
server.ExpectReq("GET", "/search").WithQuery(hex.P{
	"q": "test",
	"page": hex.R(`^\d+$`),
})
----

== Matching against the query string, header and body

You can make expectations about the query string, headers or form body with `WithQuery`, `WithHeader` and `WithBody` respectively:

[source,go]
----
func TestClientLibrary(t*testing.T) {
	t.Run("It includes the authorization header", func(t*testing.T) {
		server := hex.NewServer(t, nil)
		server.ExpectReq("GET", "/users").WithHeader("Authorization", hex.R("^Bearer .+$"))
		// ...
		client.GetUsers()
	})

	t.Run("It includes the user Id in the query string", func(t*testing.T) {
		server := hex.NewServer(t, nil)
		server.ExpectReq("GET", "/users").WithQuery("id", "123")
		// ...
		client.GetUser("123")
	})
}
----

When only one argument is given to any `With*` method, matching is done against the key, with any value being accepted:toc:

[source,go]
----
server.ExpectReq("GET", "/users").WithQuery("id")
// ...
http.Get(server.URL + "/users")              // fail
http.Get(server.URL + "/users?id")           // pass
http.Get(server.URL + "/users?id=1")         // pass
http.Get(server.URL + "/users?id=1&foo=bar") // pass
----

When no arguments are given, `WithQuery`, `WithHeader` and `WithBody` match any request with a non-empty query/header/body respectively.

[source,go]
----
server.ExpectReq("GET", "/users").WithQuery()
// ...
http.Get(server.URL + "/users")         // fail
http.Get(server.URL + "/users?foo")     // pass
http.Get(server.URL + "/users?foo=bar") // pass
http.Get(server.URL + "/users?foo=bar") // pass
----


== Mocking Responses

By default, hex will pass requests to the `http.Handler` object you provide through `NewServer` (if any).
You can override the response with `RespondWith(status int, body string)`, `ResponseWidthFn(func(http.ResponseWriter, *http.Request))` or `RespondWithHandler(http.Handler)`:

[source,go]
----
server := hex.NewServer(t, nil)
server.ExpectReq("GET", "/users").RespondWith("200", "OK")
----

By default, the `http.Handler` you provide to `NewServer` will not be invoked if a requests matches an expectation for which a mock response has been defined.
However, you can allow the request to "fall through" and reach your own handler with `AndCallThrough`.
Note that, if your handler writes a response, it will be concatenated to the mock response already produced, and any HTTP status you attempt to write will be silently discarded  if a mock response has already set one.:

[source,go]
----
server := hex.NewServer(t, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
	fmt.Fprintf("BBB")
}))

// Requests matching this expectation will receive a response of "AAABBB"
server.ExpectReq("GET", "/foo").RespondWith(200, "AAA").AndCallThrough()
----

== Scoping with `Do`

By default, a request issued at any point in a test after an `ExpectReq` expectation is made will match that expectation.

To limit the scope in which an expectation can be matched, use `Do`:

[source,go]
----
server := hex.NewServer(t, nil)
server.ExpectReq("GET", "/users").Do(func() {
	// This will match:
	http.Get(server.URL + "/users")
})
// This will fail, the previous expectation's scope has closed
http.Get(server.URL + "/users")
----

== `Once`, `Never`

If a request should only happen once (or not at all) in a given block of code, you can express this expectation with `Once` or `Never`:

[source,go]
----
func TestCaching(t*testing.T) {
	t.Run("The client caches the server's response", func(t*testing.t) {
		server := hex.NewServer(t, nil)
		server.ExpectReq("GET", "/countries").Once()
		// ...
		client.GetCountries()
		client.GetCountries()
		// Output:
		// Expectations
		// 	GET /countries - failed, expected 1 matches, got 2
	})

	t.Run("The client should not make a request if the arguments are invalid", func(t*testing.T) {
		server := hex.NewServer(t, nil)
		server.ExpectReq("GET", "/users").Never()
		// ...
		// Assume the client is not supposed to make requests unless the ID is an integer
		_, err := client.GetUser("foo")
		// assert that err is not nil
	})
})
----

== Helpers `R` and `P`

`hex.R` is a wrapper around `regexp.MustCompile`, and `hex.P` ("params") is an alias for `map[string]interface{}`.

These helpers allow for more succinct definition of matchers:

[source,go]
----
server := hex.NewServer(t, nil)
server.ExpectReq("GET", hex.R(`/users/\d+`)) // Matches /users/123
// ... 
server.ExpectReq("POST", "/users").WithBody(hex.P{
	"name": hex.R(`^[a-z]+$`),
	"age": hex.R(`^\d+$`),
})
----


== TODO

- [ ] Better support for matching JSON requests
- [ ] Higher level helpers
	- [ ] `WithBearer`
	- [ ] `WithJsonResponse`
	- [ ] `WithType("json"|"html")`

Documentation

Overview

Package hex defines an Expecter class for making expect-style assertions about HTTP calls in your test suite.

Its intended use in in mock services, typically those that power httptest.Server instances.

Example
package main

import (
	"fmt"
	"net/http"
	"net/url"
	"testing"

	"github.com/meagar/hex"
)

// MockUserService is a mock we dependency-inject into our Client library
// It embeds a hex.Server so we can make HTTP requests of it, and use ExpectReq to set up
// HTTP expectations.
type MockUserService struct {
	*hex.Server
}

func NewMockUserService(t *testing.T) *MockUserService {
	s := MockUserService{}
	s.Server = hex.NewServer(t, &s)
	return &s
}

func (m *MockUserService) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
	path := req.Method + " " + req.URL.Path

	switch path {
	case "GET /users":
		// TODO: Generate the response a client would expect
	case "POST /users":
		// TODO: Generate the response a client would expect
	default:
		rw.WriteHeader(http.StatusNotFound)
		fmt.Fprintf(rw, "Not found")
	}
}

// SearchClient is our real HTTP client for the given service
type UsersClient struct {
	Host   string
	Client *http.Client
}

type User struct {
	Name  string
	Email string
}

// Search hits the "search" endpoint for the given host, with an id query string parameter
func (c *UsersClient) Find(userID int) (User, error) {
	_, err := c.Client.Get(fmt.Sprintf("%s/users/%d", c.Host, userID))
	// TODO: Decode mock service response
	return User{}, err
}

func (c *UsersClient) Create(u User) error {
	data := url.Values{}
	data.Set("name", u.Name)
	data.Set("email", u.Email)
	_, err := c.Client.PostForm(c.Host+"/users", data)
	return err
}

func main() {
	t := testing.T{}
	service := NewMockUserService(&t)

	// Client is our real client implementation
	client := UsersClient{
		Client: service.Client(),
		Host:   service.URL,
	}

	// Make expectations about the client library
	service.ExpectReq("GET", "/users/123").Once().Do(func() {
		client.Find(123)
	})

	service.ExpectReq("POST", "/users").WithBody("name", "User McUser").WithBody("email", "user@example.com").Do(func() {
		client.Create(User{
			Name:  "User McUser",
			Email: "user@example.com",
		})
	})

	fmt.Println(service.Summary())
}
Output:

Expectations
	GET /users/123 - passed
	POST /users with body matching name="User McUser"body matching email="user@example.com" - passed

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func R

func R(pattern string) *regexp.Regexp

R is a convenience wrapper for regexp.MustCompile

Types

type Expectation

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

Expectation captures details about an ExpectReq call and subsequent conditions chained to it.

func (*Expectation) AndCallThrough

func (e *Expectation) AndCallThrough() *Expectation

AndCallThrough instructs the expectation to run both a registered mock response handler, and then additionally run the original handler

func (*Expectation) Do

func (e *Expectation) Do(fn func())

Do opens a scope. Expectations in the current scope may be matched by requests in the current or nested scopes, but requests in higher scopes cannot fulfill expections in lower scopes.

For example:

expector.ExpectReq("POST", "/foo")
expector.ExpectReq("GET", "/bar").Do(func() {
  // matches POST expectation in parent scope
  expector.LogReq(httptest.NewRequest("GET" "/foo", nil))
})

// Does NOT match GET expectation in previous scope
expector.LogReq(httptest.NewRequest("GET" "/foo", nil)) // does not match

The current expectation becomes the first expectation within the new scope

func (*Expectation) Never

func (e *Expectation) Never() *Expectation

Never asserts that the expectation is matched zero times

Example
e := Expecter{}

e.ExpectReq("GET", "/users").Never()
e.LogReq(httptest.NewRequest("GET", "/users", nil))

fmt.Println(e.Summary())
Output:

Expectations
	GET /users - failed, expected 0 matches, got 1

func (*Expectation) Once

func (e *Expectation) Once() *Expectation

Once adds a quantity condition that requires exactly one request to be matched

Example
e := Expecter{}

e.ExpectReq("GET", "/status").Once()

e.LogReq(httptest.NewRequest("GET", "/status", nil))
e.LogReq(httptest.NewRequest("GET", "/status", nil))

fmt.Println(e.Summary())
Output:

Expectations
	GET /status - failed, expected 1 matches, got 2

func (*Expectation) RespondWith

func (e *Expectation) RespondWith(status int, body string) *Expectation

RespondWith accepts a status code and string respond body

Example
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"testing"

	"github.com/meagar/hex"
)

func main() {
	server := hex.NewServer(&testing.T{}, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
		// The default behavior of the mock service
		fmt.Fprintf(rw, "ok")
	}))

	// Override handler for any requests that match this expectation
	server.ExpectReq("GET", "/foo").Once().RespondWith(200, "mock response")

	if resp, err := http.Get(server.URL + "/foo"); err != nil {
		panic(err)
	} else {
		// Verify the mock response was received
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)

		if string(body) != "mock response" {
			log.Panicf(`Expected body to match "mock response", got %s (%v)`, body, err)
		}
	}

	// Should hit the server's default handler
	server.ExpectReq("GET", "/bar")
	if resp, err := http.Get(server.URL + "/bar"); err != nil {
		log.Panicf("Unexpected error contacting mock service: %v", err)
	} else {
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)

		if string(body) != "ok" {
			log.Panicf(`Expected body to match "ok", got %s (%v)`, body, err)
		}
	}

	fmt.Println(server.Summary())
}
Output:

Expectations
	GET /foo - passed
	GET /bar - passed

func (*Expectation) RespondWithFn

func (e *Expectation) RespondWithFn(fn func(http.ResponseWriter, *http.Request)) *Expectation

RespondWithFn adds a mock response using a function that can be passed to http.HandlerFunc

func (*Expectation) RespondWithHandler

func (e *Expectation) RespondWithHandler(handler http.Handler) *Expectation

RespondWithHandler registers an alternate handler to use when the expectation matches a request. Use AndCallThrough to additionally run the original handler, after the new handler is called

func (*Expectation) String

func (e *Expectation) String() string

func (*Expectation) With

func (e *Expectation) With(fn func(req *http.Request) bool)

With adds a generic condition callback that must return true if the request matched, and false otherwise

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"

	"github.com/meagar/hex"
)

func main() {
	e := hex.Expecter{}

	// With allows custom matching through a callback function
	e.ExpectReq("POST", "/users").With(func(req *http.Request) bool {
		if err := req.ParseForm(); err != nil {
			panic(err)
		}
		return req.Form.Get("user_id") == "123"
	})

	body := strings.NewReader(url.Values{
		"user_id": []string{"123"},
	}.Encode())
	req := httptest.NewRequest("POST", "/users", body)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	e.LogReq(req)

	fmt.Println(e.Summary())
}
Output:

Expectations
	POST /users with <custom With matcher> - passed

func (*Expectation) WithBody

func (e *Expectation) WithBody(args ...interface{}) *Expectation

WithBody adds matching conditions against a request's body. See WithQuery for usage instructions

Example
e := Expecter{}

e.ExpectReq("POST", "/posts").WithBody("title", "My first blog post").Once()

body := strings.NewReader(url.Values{
	"title": []string{"My first blog post"},
}.Encode())
req := httptest.NewRequest("POST", "/posts", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

e.LogReq(req)

fmt.Println(e.Summary())
Output:

Expectations
	POST /posts with body matching title="My first blog post" - passed

func (*Expectation) WithHeader

func (exp *Expectation) WithHeader(args ...interface{}) *Expectation

WithHeader adds matching conditions against a request's headers

Example
e := Expecter{}

e.ExpectReq("GET", "/foo").WithHeader("Authorization", R("^Bearer .+$"))

req := httptest.NewRequest("GET", "/foo", nil)
req.Header.Set("Authorization", "Bearer foobar")

e.LogReq(req)

fmt.Println(e.Summary())
Output:

Expectations
	GET /foo with header matching Authorization="^Bearer .+$" - passed

func (*Expectation) WithQuery

func (exp *Expectation) WithQuery(args ...interface{}) *Expectation

WithQuery matches against the query string. It has several forms:

WithQuery() // passes if any query string is present
WithQuery("key") // passes ?key and ?key=<any value>
WithQuery("key", "value") // passes ?key=value or &key=value&key=value2
WithQuery(hex.R(`^key$`)) // find keys using regular expressions
WithQuery(hex.P{"key1": "value1", "key2": "value2"}) // match against multiple key/value pairs
WithQuery(hex.P{"key1": hex.R(`^value\d$`)}) // mix-and-match strings, regular expressions and key/value maps
Example
e := Expecter{}

e.ExpectReq("GET", "/search").WithQuery("q", "cats")

e.LogReq(httptest.NewRequest("GET", "/search?q=cats", nil))

fmt.Println(e.Summary())
Output:

Expectations
	GET /search with query string matching q="cats" - passed
Example (InvalidArgument)
defer func() {
	if err := recover(); err != nil {
		fmt.Println("Panic:", err)
	}
}()

e := Expecter{}
// Unrecognized arguments to WithQuery produce a panic.
e.ExpectReq("GET", "/search").WithQuery(123)
Output:

Panic: WithQuery: Cannot use value 123 when matching against url.Values

type Expecter

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

Expecter is the top-level object onto which expectations are made

func (*Expecter) ExpectReq

func (e *Expecter) ExpectReq(method, path interface{}) (exp *Expectation)

ExpectReq adds an Expectation to the stack

func (*Expecter) Fail

func (e *Expecter) Fail() bool

Fail returns true if any expectation has failed

func (*Expecter) FailedExpectations

func (e *Expecter) FailedExpectations() (failed []*Expectation)

FailedExpectations returns a list of currently failing

func (*Expecter) HexReport

func (e *Expecter) HexReport(t TestingT)

HexReport logs a summary of passes/fails to the given testing object, and calls t.Errorf with an error message if any expectations failed

func (*Expecter) LogReq

func (e *Expecter) LogReq(req *http.Request) *Expectation

LogReq matches an incoming request against he current tree of Expectations, and returns the matched Expectation if any

func (*Expecter) Pass

func (e *Expecter) Pass() bool

Pass returns true if all expectations have passed

func (*Expecter) PassedExpectations

func (e *Expecter) PassedExpectations() (passed []*Expectation)

PassedExpectations returns all passing expectations

func (*Expecter) Summary

func (e *Expecter) Summary() string

Summary returns a summary of all passed/failed expectations and any requests that didn't match

Example
e := Expecter{}

e.ExpectReq("GET", "/status")
e.ExpectReq("POST", "/users")

// Matches one of above expectations, leaving the other unmatched (failing)
e.LogReq(httptest.NewRequest("GET", "/status", nil))

// Extraneous request matches no expectations
e.LogReq(httptest.NewRequest("PATCH", "/items", nil))

fmt.Println(e.Summary())
Output:

Expectations
	GET /status - passed
	POST /users - failed, no matching requests
Unmatched Requests
	PATCH /items

func (*Expecter) UnmatchedRequests

func (e *Expecter) UnmatchedRequests() []*http.Request

UnmatchedRequests returns a list of all http.Request objects that didn't match any expectation

type MatchConst

type MatchConst int

MatchConst is used to define some built-in matchers with predefined behavior, namely All or None

const (
	// Any matches anything, matching against Any will always return true
	Any MatchConst = 1

	// None matches nothing, matching against None will always return false
	None MatchConst = 2
)

type P

type P map[interface{}]interface{}

P is a convenience alias for a map of interface{} to interface{}

It's used to add header/body/query string conditions to an expectation:

server.Expect("GET", "/foo").WithQuery(hex.P{"name": "bob", "age": hex.R("^\d+$")})

type Server

type Server struct {
	*httptest.Server

	Expecter
	// contains filtered or unexported fields
}

Server wraps around (embeds) an httptest.Server, and also embeds an Expecter for making expectations The simplest way of using hex is to use NewServer or NewTLSServer.

func NewServer

func NewServer(t TestingT, handler http.Handler) *Server

NewServer returns a new hex.Server object, wrapping an httptest.Server. Its first argument should be a testing.T, used to report failures. Its second argument is an http.Handler that may be nil.

func NewTLSServer

func NewTLSServer(t TestingT, handler http.Handler) *Server

NewTLSServer returns a new hex.Server object, wrapping na httptest.Server created via NewTLSServer Its first argument should be a testing.T, used to report failures. Its second argument is an http.Handler that may be nil.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request)

ServeHTTP logs requests that come through the server so they can be matched against expectations, and evalutes any mock responses defined for matched expectations.

type StringMatcher added in v0.0.5

type StringMatcher func(string) bool

StringMatcher is used for matching parts of a request that can only ever be strings, such as the HTTP Method, path, query string/header/form keys (not values, which can be arrays), etc.

type TestingT

type TestingT interface {
	Helper()
	Cleanup(func())
	Logf(format string, args ...interface{})
	Errorf(format string, args ...interface{})
}

TestingT covers the minimal interface we consume from a testing.T

Jump to

Keyboard shortcuts

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