Documentation
¶
Overview ¶
Package testhelper provides the fakes and assertions a consumer of github.com/AtomiCloud/diene.go-api-engine would otherwise rebuild in every test suite.
Two kinds of pain motivate it. Testing a multi-backend client tree means standing up several backends that each answer differently, which is a fixture nobody wants to hand-roll twice: FakeBackend and NewFakeTree do it in a line. And testing the 3-case classification means producing RFC 9457 envelopes with the right `data` extension: ProblemResponse and Canned mint them, so a test can assert how a problem travels without first becoming an expert on the envelope's wire shape.
The assertions (AssertOutcome, AssertProblem, CheckProblem) exist for the same reason: every consumer would otherwise write the same errors.As dance followed by field-by-field comparison.
This package is for tests. It is a normal (non-test) package so consumers can import it, but nothing in the engine depends on it.
Index ¶
- func AssertCount(t TestingT, backend *FakeBackend, want int)
- func AssertOutcome(t TestingT, actual apiengine.Outcome, want apiengine.Outcome)
- func AssertProblem(t TestingT, err error, want ProblemOptions) problem.Problem
- func CheckCount(backend *FakeBackend, want int) error
- func CheckOutcome(actual apiengine.Outcome, want apiengine.Outcome) error
- func CheckProblem(err error, want ProblemOptions) (problem.Problem, error)
- func NewProblems(extra ...problem.Type) (*apiengine.Problems, error)
- func ProblemResponse(options ProblemOptions) problem.Problem
- func SampleErrorPortal() problem.ErrorPortal
- type FakeBackend
- type FakeBackendOptions
- type FakeRetriever
- type FakeTree
- type FakeTreeOptions
- type ProblemOptions
- type RecordedRequest
- type Route
- type TestingT
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssertCount ¶
func AssertCount(t TestingT, backend *FakeBackend, want int)
AssertCount fails t unless the backend received exactly want requests.
func AssertOutcome ¶
AssertOutcome fails t unless the response carries the expected 3-case classification.
func AssertProblem ¶
func AssertProblem(t TestingT, err error, want ProblemOptions) problem.Problem
AssertProblem fails t unless err carries the expected problem envelope, and returns the envelope it found.
Only the fields set on want are compared, so a test asserts the `data` extension it cares about without restating the whole envelope.
func CheckCount ¶
func CheckCount(backend *FakeBackend, want int) error
CheckCount reports whether a fake backend received the expected number of requests.
It is how the retry-once profile is asserted: a call that survived one transport failure leaves exactly two.
func CheckOutcome ¶
CheckOutcome reports whether outcome is the expected one.
func CheckProblem ¶
func CheckProblem(err error, want ProblemOptions) (problem.Problem, error)
CheckProblem reports whether err carries the expected problem envelope, returning the envelope it found.
It is the check half of AssertProblem, separated so a consumer can compose it into its own assertions without a *testing.T in hand.
func NewProblems ¶
NewProblems returns an api-engine problem factory on SampleErrorPortal, so a test that only wants to exercise the client need not build one.
Extra types are registered alongside the engine's, exactly as they would be in a consumer, so a test can raise its own problems through the same factory. A type whose id collides with an engine problem is rejected.
func ProblemResponse ¶
func ProblemResponse(options ProblemOptions) problem.Problem
ProblemResponse builds the canned envelope a fake backend returns.
It is the fixture the 3-case classification is proven against: a client gets a problem case only because a body like this came back, so producing one has to be a single call rather than a hand-written JSON literal per test.
func SampleErrorPortal ¶
func SampleErrorPortal() problem.ErrorPortal
SampleErrorPortal returns a deterministic error portal for tests.
Types ¶
type FakeBackend ¶
type FakeBackend struct {
// contains filtered or unexported fields
}
FakeBackend is an in-process HTTP backend for client-tree tests.
It answers by path, records every request it received, and can be told to fail the transport a fixed number of times — which is what makes the retry-once profile testable without a real network to break.
func NewFakeBackend ¶
func NewFakeBackend(options FakeBackendOptions) *FakeBackend
NewFakeBackend starts a fake backend. Close it with FakeBackend.Close.
func (*FakeBackend) Backend ¶
func (b *FakeBackend) Backend() apiengine.BackendConfig
Backend returns the engine config block entry pointing at this fake.
func (*FakeBackend) Count ¶
func (b *FakeBackend) Count() int
Count returns how many requests the backend received.
It is the assertion the retry profile is proven with: one call that succeeds after a single transport failure must leave a count of exactly two.
Example ¶
A backend can be told to fail the transport, which is how the retry-once-on-network-error profile is proven without a real network to break.
package main
import (
"context"
"fmt"
"net/http"
"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)
// Invoice is the payload the examples decode.
type Invoice struct {
ID string `json:"id"`
Total int `json:"total"`
}
func main() {
backend := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{
Routes: map[string]testhelper.Route{
"/v1/invoices/i-1": {Status: http.StatusOK, Body: Invoice{ID: "i-1", Total: 4200}},
},
TransportFailures: 1,
})
defer backend.Close()
tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
Backends: map[string]*testhelper.FakeBackend{"billing": backend},
Retry: apiengine.RetryConfig{Network: true},
})
if err != nil {
panic(err)
}
client, err := tree.Tree.Backend("billing")
if err != nil {
panic(err)
}
invoice, err := apiengine.Execute[Invoice](context.Background(), client,
apiengine.Request{Path: "/v1/invoices/i-1"})
if err != nil {
panic(err)
}
fmt.Println(invoice.ID)
// Exactly two: the original attempt and the one permitted retry.
fmt.Println(backend.Count())
}
Output: i-1 2
func (*FakeBackend) Requests ¶
func (b *FakeBackend) Requests() []RecordedRequest
Requests returns the requests received so far, oldest first.
func (*FakeBackend) SetRoute ¶
func (b *FakeBackend) SetRoute(path string, route Route)
SetRoute adds or replaces a route while the backend is running.
type FakeBackendOptions ¶
type FakeBackendOptions struct {
// Routes maps a request path to the answer for it.
Routes map[string]Route
// Fallback answers a path with no route. Defaults to 404 with an RFC 9457
// envelope, because a real C0-conformant backend does not answer a miss
// with a bare status.
Fallback *Route
// TransportFailures is how many leading requests die before a response is
// produced. One failure exercises the single retry; two exhaust it.
TransportFailures int
}
FakeBackendOptions configures a FakeBackend.
type FakeRetriever ¶
type FakeRetriever struct {
// contains filtered or unexported fields
}
FakeRetriever is an in-memory authengine.Retriever: it hands out a canned token per resource and records what was asked for.
A consumer testing the multi-backend tree needs to prove that backend A got A's token and backend B got B's — which needs a retriever that answers per resource and remembers, not a real IdP.
func NewFakeRetriever ¶
func NewFakeRetriever(tokens map[string]string) *FakeRetriever
NewFakeRetriever creates a retriever serving the given resource-to-token map.
func (*FakeRetriever) Asked ¶
func (r *FakeRetriever) Asked() []string
Asked returns the resources tokens were requested for, oldest first.
func (*FakeRetriever) FailResource ¶
func (r *FakeRetriever) FailResource(resource string, cause error)
FailResource makes the named resource fail to resolve, which is how a test reaches the credentials-unavailable path without breaking an IdP.
func (*FakeRetriever) Token ¶
func (r *FakeRetriever) Token(_ context.Context, resource string) (authengine.AccessToken, error)
Token implements authengine.Retriever.
type FakeTree ¶
type FakeTree struct {
// Tree is the client tree under test.
Tree *apiengine.ClientTree
// Backends maps each registered name to its fake backend.
Backends map[string]*FakeBackend
// Retriever is the per-backend token seam the tree was built with.
Retriever *FakeRetriever
// Problems is the factory the tree mints its errors through.
Problems *apiengine.Problems
}
FakeTree is a running client tree over fake backends.
func NewFakeTree ¶
func NewFakeTree(options FakeTreeOptions) (*FakeTree, error)
NewFakeTree assembles a client tree over the given fake backends.
The retry sleep is replaced with a no-op, so exercising the retry-once profile costs no wall-clock time.
Example ¶
A whole multi-backend tree over fakes, in one call — with each backend's own token attached through the auth-engine retriever seam.
package main
import (
"context"
"fmt"
"net/http"
"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)
// Invoice is the payload the examples decode.
type Invoice struct {
ID string `json:"id"`
Total int `json:"total"`
}
func main() {
route := map[string]testhelper.Route{
"/v1/invoices/i-1": {Status: http.StatusOK, Body: Invoice{ID: "i-1", Total: 4200}},
}
billing := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{Routes: route})
catalog := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{Routes: route})
tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
Backends: map[string]*testhelper.FakeBackend{
"billing": billing,
"catalog": catalog,
},
Tokens: map[string]string{
"billing": "token-billing",
"catalog": "token-catalog",
},
})
if err != nil {
panic(err)
}
defer tree.Close()
client, err := tree.Tree.Backend("billing")
if err != nil {
panic(err)
}
invoice, err := apiengine.Execute[Invoice](context.Background(), client,
apiengine.Request{Path: "/v1/invoices/i-1"})
if err != nil {
panic(err)
}
fmt.Println(invoice.ID, invoice.Total)
fmt.Println(billing.Requests()[0].Authorization())
}
Output: i-1 4200 Bearer token-billing
type FakeTreeOptions ¶
type FakeTreeOptions struct {
// Backends maps a logical backend name to the fake serving it.
Backends map[string]*FakeBackend
// Tokens maps a resource name to the token the retriever hands out. Nil
// means the tree is built without a retriever, i.e. unauthenticated.
Tokens map[string]string
// Retry is the resilience profile. Zero disables the retry, so a test that
// wants it says so.
Retry apiengine.RetryConfig
// ExtraProblems are the consumer's own problem types, registered on the
// tree's factory alongside the engine's.
ExtraProblems []problem.Type
}
FakeTreeOptions configures NewFakeTree.
type ProblemOptions ¶
type ProblemOptions struct {
// Type is the problem type URI.
Type string
// Title is the short human-readable summary.
Title string
// Status is the origin-generated HTTP status.
Status int
// Detail is the occurrence-specific explanation, omitted when blank.
Detail string
// Instance is the occurrence URI, omitted when blank.
Instance string
// Recoverable is the C0 retry-vs-fatal flag.
Recoverable bool
// Data is the typed payload extension. This is the member that must survive
// the whole journey to the caller, so tests set it and assert it.
Data map[string]any
}
ProblemOptions describes a canned RFC 9457 envelope.
type RecordedRequest ¶
type RecordedRequest struct {
// Method is the HTTP method.
Method string
// Path is the request path.
Path string
// Query is the raw query string.
Query string
// Header is the request header, including any Authorization the engine
// attached.
Header http.Header
// Body is the request body as received.
Body []byte
}
RecordedRequest is one request a FakeBackend received.
func (RecordedRequest) Authorization ¶
func (r RecordedRequest) Authorization() string
Authorization returns the request's Authorization header.
type Route ¶
type Route struct {
// Status is the HTTP status to return.
Status int
// Body is the response body. A []byte or string is sent verbatim; anything
// else is JSON-encoded.
Body any
// Header carries additional response headers.
Header http.Header
}
Route is one canned answer a FakeBackend gives.
func Canned ¶
func Canned(options ProblemOptions) Route
Canned returns a Route serving the envelope with the problem's own status and the `application/problem+json` content type RFC 9457 requires.
Example ¶
Canned mints the RFC 9457 envelope a 4xx test needs, so a test asserts how a problem travels without first becoming an expert on the wire shape.
package main
import (
"context"
"fmt"
"net/http"
"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)
// Invoice is the payload the examples decode.
type Invoice struct {
ID string `json:"id"`
Total int `json:"total"`
}
func main() {
backend := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{
Routes: map[string]testhelper.Route{
"/v1/invoices/i-9": testhelper.Canned(testhelper.ProblemOptions{
Type: "https://docs.example.com/docs/prod/api/billing/core/v1/quota-exceeded",
Title: "Quota exceeded",
Status: http.StatusTooManyRequests,
Detail: "the monthly quota is spent",
Recoverable: true,
Data: map[string]any{"limit": 100},
}),
},
})
defer backend.Close()
tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
Backends: map[string]*testhelper.FakeBackend{"billing": backend},
})
if err != nil {
panic(err)
}
client, err := tree.Tree.Backend("billing")
if err != nil {
panic(err)
}
_, err = apiengine.Execute[Invoice](context.Background(), client,
apiengine.Request{Path: "/v1/invoices/i-9"})
// CheckProblem is the *testing.T-free half, so it works in an example.
envelope, checkErr := testhelper.CheckProblem(err, testhelper.ProblemOptions{
Title: "Quota exceeded",
Status: http.StatusTooManyRequests,
})
fmt.Println(checkErr == nil)
fmt.Println(envelope.Data["limit"])
}
Output: true 100
type TestingT ¶
TestingT is the slice of *testing.T the assertions here use.
Taking an interface rather than *testing.T keeps this package importable from a consumer's own helper layer and lets the assertions be proven against a recording double in the meta tier — an assertion nobody has watched fail is an assertion nobody has tested.