Testing is where the architecture of this book pays off. Because an Iris
application is assembled by a newApp function that receives its
configuration and its dependencies as parameters, a test can build the real
application (real router, real middleware, real error map) and swap only
the edges: an in-memory store instead of a database, a fixed password
instead of a secret from the environment. The httptest package then
drives that application entirely in memory: no ports, no network, no
sleeps. A suite of full HTTP tests runs in milliseconds. This chapter
covers the httptest API, the request-and-assertion chain, designing
applications so they are testable in the first place, and the specific
tests every service should have: guards, validation, the central error
map, and the proof that internal errors never reach a client.
Background
An automated test is a small Go function that runs a piece of your
application and checks the result against what you expect; the go test
command runs every such function and reports which ones failed. Its value
is not proving today’s build works; you can click through the app for
that. Its value is that the proof re-runs on every future change. When a
change six months from now quietly breaks note creation, the suite says
so within seconds, names the exact expectation that no longer holds, and
points at the change that broke it. That failure mode (old behavior
broken by new code) is called a regression, and catching regressions
automatically is the reason test suites exist. Each individual check
inside a test (“the status must be 201”) is an assertion.
Tests are usually named by how much of the system they exercise: a unit test checks one function or type in isolation, an integration test checks that several pieces work when wired together, and an end-to-end test drives the assembled application the way a real client would. In one breath: unit tests prove each part works, integration tests prove the parts fit, end-to-end tests prove the whole does the job. This chapter leans on end-to-end-style HTTP tests because in Iris they are as cheap as unit tests, plus plain unit tests for domain logic.
Real applications depend on things a test does not want: a production
database, a mail provider, the actual passage of time. A test double is
a stand-in for such a dependency. Two kinds appear in this chapter: a
fake is a genuinely working but lightweight implementation, like an
in-memory map pretending to be a database; a stub is even simpler: it
returns canned answers, often from a single method, such as “every query
fails with this error”. In many languages test doubles require mocking
frameworks and code generation. In Go they are ordinary structs, because
of how interfaces work: an interface names a set of methods, and any type
with those methods satisfies it implicitly. If your handler depends on a
Repository interface rather than a concrete database type, then any
five-line struct with the same methods can step in, and the handler
cannot tell the difference. That is the whole trick, and this chapter
uses it constantly.
Finally, in-memory HTTP testing. The naive way to test a web server is
to actually start it: listen on a port, send requests over the network,
wait for responses. Every item in that sentence is a liability: ports
collide when tests run in parallel, network buffers add timing you do not
control, and waits turn into time.Sleep calls that make the suite slow
and flaky (failing occasionally without any code change, the most
corrosive property a test can have). The in-memory approach removes the
transport entirely: the test hands a request object directly to the
router, in the same process, through the same code path a real request
would take once past the socket. No port, no network, no waiting. When
the call returns, the handler has run. Tests become deterministic and
fast enough to run on every save, which is what makes them actually get
run.
How Testing Works in Iris
httptest.New(t, app) prepares an application for testing and returns an
*httpexpect.Expect value, a fluent client from the httpexpect library
that Iris integrates:
import "github.com/kataras/iris/v14/httptest"
e := httptest.New(t, app)Three things happen inside that call, and each one matters:
- The application is built.
httptest.Newcallsapp.Build()for you and reports a build failure throught.Fatalf: a misconfigured route or an unresolvable dependency fails the test immediately with the real error message, instead of panicking. - Requests bypass the network. The returned client’s transport is
bound directly to the application.
e.GET("/x")invokes the router in-process; nothing listens on a port, so tests are fast, parallel-safe and free of port conflicts. - Logs are silenced. The application’s log level is set to
disableby default, sogo testoutput stays readable. Passhttptest.LogLevel("debug")orhttptest.Debug(true)when you need to see what a failing test is doing.
The first argument is not literally a *testing.T: it is the
httptest.IrisTesty interface, which *testing.T, *testing.B and
Ginkgo’s GinkgoT() all satisfy, so the same helper serves tests,
benchmarks and BDD suites.
The httptest package also re-exports every HTTP status code
(httptest.StatusOK, httptest.StatusNotFound, …), so test files do
not need to import net/http just for constants.
Your First Test
The smallest possible round trip:
package main
import (
"testing"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/httptest"
)
func TestHello(t *testing.T) {
app := iris.New()
app.Get("/hello", func(ctx iris.Context) {
ctx.JSON(iris.Map{"message": "hello"})
})
e := httptest.New(t, app)
e.GET("/hello").Expect().
Status(httptest.StatusOK).
JSON().Object().
HasValue("message", "hello")
}Every request follows the same shape: build the request (e.GET(...),
optionally with body, query and headers), execute it with Expect(),
then chain assertions on the response. A failed assertion reports through
the t you passed to httptest.New, with the expected and actual values.
Designing for Testability
The single most important testing decision is not in a test file at all. It is the signature of your application factory:
func newApp(cfg Config, repo store.Repository) *iris.Applicationmain calls it with production values; tests call it with their own.
Everything else follows from this:
- Seams are parameters. Anything a test may want to replace, such as
the repository, a mail sender or a clock, arrives as an argument
(usually an interface) and is handed to the builder’s
Servicesstep, which the dependency container uses to satisfy controller constructors (Chapter 8). - Tests exercise the real application. Because
newAppis the same functionmainuses, every test request passes through the real router, the real middleware chain and the real central error map. There is no “test router” that behaves differently from production. - Configuration is a value. A
Configstruct built in the test replaces files and environment variables; nothing global to reset between tests.
The Bookshelf service in the framework’s TUTORIAL.md is a complete,
compile-verified worked example of this pattern. This chapter uses a
smaller application in the same shape.
The Application Under Test
A notes service: a domain package that owns the entities, the validation
and the storage interface, and a main package that wires controllers
behind iris.NewBuilder (Chapter 3).
// store/store.go
package store
import (
"context"
"errors"
"sync"
"uuid"
"github.com/kataras/iris/v14/rest/errors/validation"
)
// ErrNotFound is a domain sentinel; the HTTP layer maps it to 404 once.
var ErrNotFound = errors.New("store: note not found")
type Note struct {
ID uuid.UUID `json:"id"`
Text string `json:"text"`
}
// NoteInput is the request payload. Implementing Validate makes
// ctx.BindJSON run it automatically after decoding (Chapter 5).
type NoteInput struct {
Text string `json:"text"`
}
func (in NoteInput) Validate() error {
return validation.Join(
validation.String("text", in.Text).NotEmpty().Length(1, 280),
)
}
// Repository is the storage seam: production wires a database
// implementation, tests wire MemStore or a one-method stub.
type Repository interface {
Insert(ctx context.Context, n Note) error
Notes(ctx context.Context) ([]Note, error)
Note(ctx context.Context, id uuid.UUID) (Note, error) // ErrNotFound
}
// MemStore is the in-memory Repository: this chapter's storage and the
// test suite's default fake.
type MemStore struct {
mu sync.RWMutex
notes map[uuid.UUID]Note
}
var _ Repository = (*MemStore)(nil)
func NewMemStore() *MemStore {
return &MemStore{notes: make(map[uuid.UUID]Note)}
}
func (s *MemStore) Insert(_ context.Context, n Note) error {
s.mu.Lock()
defer s.mu.Unlock()
s.notes[n.ID] = n
return nil
}
func (s *MemStore) Notes(_ context.Context) ([]Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]Note, 0, len(s.notes))
for _, n := range s.notes {
list = append(list, n)
}
return list, nil
}
func (s *MemStore) Note(_ context.Context, id uuid.UUID) (Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
n, ok := s.notes[id]
if !ok {
return Note{}, ErrNotFound
}
return n, nil
}The HTTP layer: two controllers and the composition root.
// main.go
package main
import (
"os"
"uuid"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/basicauth"
"github.com/kataras/iris/v14/rest/errors"
"notes/store"
)
// NotesAPI serves /api/notes. Its constructor declares its dependency;
// the container calls it with the Repository registered in Services.
type NotesAPI struct {
repo store.Repository
}
func NewNotesAPI(repo store.Repository) *NotesAPI {
return &NotesAPI{repo: repo}
}
func (api *NotesAPI) Routing(r iris.Router) {
r.Get("/", api.list)
r.Post("/", api.create)
r.Get("/{id:uuid}", api.get)
}
func (api *NotesAPI) list(ctx iris.Context) {
notes, err := api.repo.Notes(ctx)
ctx.List(notes, err)
}
func (api *NotesAPI) create(ctx iris.Context) {
input, ok := ctx.BindJSON[store.NoteInput]()
if !ok {
return // 400 already written: malformed JSON or failed Validate.
}
n := store.Note{ID: uuid.New(), Text: input.Text}
ctx.Created(n.ID, api.repo.Insert(ctx, n))
}
func (api *NotesAPI) get(ctx iris.Context) {
id := ctx.Params().GetUUID("id")
n, err := api.repo.Note(ctx, id)
ctx.OK(n, err) // store.ErrNotFound renders 404 (central map).
}
// AdminAPI is mounted behind basic auth in newApp.
type AdminAPI struct {
repo store.Repository
}
func NewAdminAPI(repo store.Repository) *AdminAPI {
return &AdminAPI{repo: repo}
}
func (api *AdminAPI) Routing(r iris.Router) {
r.Get("/stats", api.stats)
}
func (api *AdminAPI) stats(ctx iris.Context) {
notes, err := api.repo.Notes(ctx)
ctx.OK(iris.Map{"notes": len(notes)}, err)
}
// Config carries what tests want to control. A real application loads
// it from YAML and the environment (Chapter 11).
type Config struct {
AdminUser string
AdminPassword string
}
// newApp is the composition root and the test seam: main passes the
// real configuration and store, tests pass their own.
func newApp(cfg Config, repo store.Repository) *iris.Application {
return iris.NewBuilder().
Prefix("/api").
AllowOrigin("none").
Compression(false).
LogRequests(false).
NoHealth().
Errors(errors.NewOptions().
MapErrors(errors.NotFound, store.ErrNotFound)).
Services(repo).
RouterMiddlewares().
Middlewares().
API("/notes", NewNotesAPI).
API("/admin", basicauth.Default(map[string]string{
cfg.AdminUser: cfg.AdminPassword,
}), NewAdminAPI).
Build()
}
func main() {
cfg := Config{
AdminUser: "admin",
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
}
app := newApp(cfg, store.NewMemStore())
app.Listen(":8080")
}Note what newApp does not do: it does not read files, does not touch
os.Getenv, does not construct its own storage. Those are main’s job.
That separation is the entire trick.
End-to-End Tests
The first test walks the happy path and the two most common failure paths in one function. Each test builds its own application and store, so tests share no state and can run in parallel.
// main_test.go
package main
import (
"testing"
"uuid"
"github.com/kataras/iris/v14/httptest"
"notes/store"
)
func testConfig() Config {
return Config{AdminUser: "admin", AdminPassword: "test-password"}
}
func TestNotesCRUD(t *testing.T) {
app := newApp(testConfig(), store.NewMemStore())
e := httptest.New(t, app)
// Create; extract the generated id from the response.
id := e.POST("/api/notes").
WithJSON(store.NoteInput{Text: "buy milk"}).
Expect().Status(httptest.StatusCreated).
JSON().Object().Value("id").String().NotEmpty().Raw()
// Read back.
e.GET("/api/notes/"+id).Expect().Status(httptest.StatusOK).
JSON().Object().HasValue("text", "buy milk")
// The listing contains exactly one note.
e.GET("/api/notes").Expect().Status(httptest.StatusOK).
JSON().Array().Length().IsEqual(1)
// Validation: ctx.BindJSON runs NoteInput.Validate -> 400.
e.POST("/api/notes").
WithJSON(store.NoteInput{Text: ""}).
Expect().Status(httptest.StatusBadRequest)
// The central error map turns store.ErrNotFound into 404.
e.GET("/api/notes/" + uuid.New().String()).
Expect().Status(httptest.StatusNotFound)
}Notice .Raw(): most chain methods keep asserting, but Raw() steps out
of the chain and hands you the underlying Go value: here the created id
as a string, immediately reused to build the next request.
The Request and Assertion Chain
Requests are built by chaining, executed by Expect(), and verified by
chaining again. The methods you will use constantly:
Building the request:
| Method | Purpose |
|---|---|
e.GET/POST/PUT/PATCH/DELETE(path) | start a request |
e.GET("/users/{id}", "42") | fill path placeholders positionally |
WithJSON(v) | JSON-encode v as the body, set the content type |
WithQuery("page", 2) | add a query parameter |
WithHeader("Authorization", "Bearer "+tok) | add a header |
WithBasicAuth(user, pass) | add an Authorization: Basic header |
WithFormField(k, v) | add a form field |
WithMultipart().WithFile(field, name) | multipart file upload |
Asserting on the response:
| Method | Purpose |
|---|---|
Status(httptest.StatusOK) | status code |
Body().IsEqual("ok"), Body().Contains("<h1>") | raw body |
Header("X-Request-Id").NotEmpty() | response header |
JSON().Object() | parse the body as a JSON object |
JSON().Array() | parse the body as a JSON array |
HasValue("name", "John") | object has key with exact value |
ContainsKey("id") / NotContainsKey("password") | key presence |
NotContainsValue(v) | no key holds this value |
Value("address").Object() | descend into a nested value |
Array().Length().IsEqual(3) / Array().NotEmpty() | array size |
Array().Value(0).Object() | descend into an element |
Raw() | leave the chain, return the Go value |
Typed value assertions hang off Value:
obj := e.GET("/api/report").Expect().
Status(httptest.StatusOK).JSON().Object()
obj.Value("count").Number().IsEqual(42)
obj.Value("active").Boolean().IsTrue()
obj.Value("tags").Array().NotEmpty()By default all failed assertions in a test are collected and reported
together. Pass httptest.Strict(true) to stop at the first failure
instead, useful when later assertions only produce noise once the first
one fails.
Fakes and One-Method Stubs
Because the repository is an interface, a test double is ordinary Go.
The full in-memory MemStore covers most tests, but the sharpest tool
is the one-method stub: embed the interface, override a single method,
inherit everything else.
import (
stdContext "context"
"fmt"
)
// failingRepo overrides one method of a working Repository; everything
// else is inherited. Interface embedding makes one-method fakes
// one-liners.
type failingRepo struct {
store.Repository
}
func (failingRepo) Notes(stdContext.Context) ([]store.Note, error) {
return nil, fmt.Errorf("pg: connection refused on 10.0.0.3:5432")
}
// Internal error details must never reach the client.
func TestInternalErrorsDoNotLeak(t *testing.T) {
app := newApp(testConfig(), failingRepo{store.NewMemStore()})
e := httptest.New(t, app)
body := e.GET("/api/notes").
Expect().Status(httptest.StatusInternalServerError).
JSON().Object()
body.HasValue("message", "server error")
body.NotContainsValue("10.0.0.3") // stays server-side.
}This is the test to internalize. Five lines of stub prove a property of the whole system: an unmapped error (a database outage, a bug) renders as a generic 500 while the real cause (with its connection string) is logged server-side, never serialized to the client (Chapter 9). No mocking framework, no code generation: the seam is an interface, so the stub is a struct literal.
Testing Guards and Authentication
Protected surfaces get three tests: no credentials, wrong credentials, valid credentials. With the in-memory client this is three requests:
func TestAdminRequiresCredentials(t *testing.T) {
app := newApp(testConfig(), store.NewMemStore())
e := httptest.New(t, app)
// No credentials: the basicauth middleware replies 401.
e.GET("/api/admin/stats").Expect().
Status(httptest.StatusUnauthorized)
// Wrong credentials: still 401.
e.GET("/api/admin/stats").WithBasicAuth("admin", "wrong").
Expect().Status(httptest.StatusUnauthorized)
// Valid credentials: through to the handler.
e.GET("/api/admin/stats").WithBasicAuth("admin", "test-password").
Expect().Status(httptest.StatusOK).
JSON().Object().HasValue("notes", 0)
}The password is not a secret here: testConfig() chose it. That is the
newApp(cfg, ...) seam again: authentication is tested against the real
middleware with credentials the test controls.
Token-protected routes follow the same pattern with a header instead:
e.GET("/api/profile").
WithHeader("Authorization", "Bearer "+token).
Expect().Status(httptest.StatusOK)Generate token with the same signer configuration the application
verifies with, a test-only key pair in the config, exactly like the
test-only password (Chapter 10).
For guards you wrote yourself (the “shelf must exist”, “shelf must be unlocked” middlewares of Chapter 4), assert the pair of behaviors that defines a guard: the request it blocks and the request it lets through. A locked-resource guard, for example, gets one test proving writes return 403 and one proving reads still return 200.
Testing Validation and the Error Map
Do not stop at status codes: your API’s error body is a contract too
(Chapter 9). Clients parse http_error_code and
validation, so pin the wire shape in a test.
func TestValidationWireFormat(t *testing.T) {
app := newApp(testConfig(), store.NewMemStore())
e := httptest.New(t, app)
body := e.POST("/api/notes").
WithJSON(store.NoteInput{Text: ""}).
Expect().Status(httptest.StatusBadRequest).
JSON().Object()
body.Value("http_error_code").Object().
HasValue("canonical_name", "INVALID_ARGUMENT").
HasValue("status", 400)
body.Value("validation").Array().NotEmpty()
body.Value("validation").Array().Value(0).Object().
HasValue("field", "text")
}The central error map deserves its own test per mapped sentinel: return the sentinel from the seam (or trigger it naturally, as here with an unknown id) and assert the canonical rendering:
func TestNotFoundMapping(t *testing.T) {
app := newApp(testConfig(), store.NewMemStore())
e := httptest.New(t, app)
e.GET("/api/notes/" + uuid.New().String()).
Expect().Status(httptest.StatusNotFound).
JSON().Object().
Value("http_error_code").Object().
HasValue("canonical_name", "NOT_FOUND").
HasValue("status", 404)
}These tests are cheap and catch a whole class of regressions: someone
removing a MapErrors line, a sentinel silently replaced by a new error
value, a handler that starts writing its own ad-hoc 404 body.
Testing Middleware in Isolation
To test one middleware, build a throwaway application around it: the middleware, one trivial handler, nothing else. The test then observes the middleware’s effect from the outside, like a client would.
package middleware_test
import (
"testing"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/httptest"
)
func TestVersionHeader(t *testing.T) {
app := iris.New()
app.Use(func(ctx iris.Context) {
ctx.SetHeader("X-API-Version", "v1")
ctx.Next()
})
app.Get("/", func(ctx iris.Context) {
ctx.Text("ok")
})
e := httptest.New(t, app)
e.GET("/").Expect().Status(httptest.StatusOK).
Header("X-API-Version").IsEqual("v1")
// Root-level Use runs for every request, even unmatched ones —
// the header appears on the 404 too (Chapter 7).
e.GET("/missing").Expect().Status(httptest.StatusNotFound).
Header("X-API-Version").IsEqual("v1")
}A middleware that makes decisions gets the same blocked/allowed pair as
any guard. Test the middleware you actually ship: the key check below is
apikey.New from Chapter 12, not a
hand-written one, so the test covers the code that will run in
production:
import (
"testing"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/httptest"
"github.com/kataras/iris/v14/middleware/apikey"
)
func TestAPIKeyGuard(t *testing.T) {
app := iris.New()
app.Use(apikey.New(apikey.Options{
Header: "X-API-Key",
Scheme: apikey.NoScheme, // a bare value, no "Bearer" prefix.
Keys: map[string]string{"reporting": "secret"},
}))
app.Get("/", func(ctx iris.Context) {
principal, _ := apikey.Get(ctx)
ctx.Text(principal)
})
e := httptest.New(t, app)
// Blocked: no key, and a key that matches nothing.
e.GET("/").Expect().Status(httptest.StatusUnauthorized)
e.GET("/").WithHeader("X-API-Key", "nope").
Expect().Status(httptest.StatusUnauthorized)
// Allowed: the handler runs and can name the caller.
e.GET("/").WithHeader("X-API-Key", "secret").
Expect().Status(httptest.StatusOK).
Body().IsEqual("reporting")
}The third assertion is the one worth writing. A guard test that only
checks 401 and 200 proves the door opens; asserting the body is
"reporting" proves the request arrived carrying the caller’s identity,
which is what every downstream audit line and rate-limit bucket depends
on.
Both tests are deterministic by construction: nothing waits, nothing
depends on timing. Prefer designs like this over time.Sleep: a test
that waits for wall-clock time is slow on every run and flaky on busy
CI machines.
Unit Testing Domain Logic
Not everything needs an HTTP request. Domain rules (validation,
calculations, state transitions) live in packages that import nothing
from Iris, so they get plain Go tests, usually table-driven.
“Table-driven” is the standard Go idiom for testing many cases without
repeating the machinery: the cases are rows of data (a name, an input,
an expected outcome) and one loop runs the same check over every row.
Adding the next edge case is adding a line to the table, and t.Run
gives each row its own name in the failure output:
// store/store_test.go
package store
import (
"strings"
"testing"
)
func TestNoteInputValidate(t *testing.T) {
tests := []struct {
name string
input NoteInput
wantErr bool
}{
{"valid", NoteInput{Text: "buy milk"}, false},
{"empty", NoteInput{Text: ""}, true},
{"too long", NoteInput{Text: strings.Repeat("x", 281)}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.input.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v",
err, tt.wantErr)
}
})
}
}Keep the split intentional: unit tests pin the domain’s edge cases
cheaply and exhaustively; the HTTP tests of the previous sections pin
the wiring: that a failed Validate becomes a 400 with the right body.
One wire-format test per input type is enough; the case matrix belongs
here.
httptest Options and Variants
httptest.New accepts options after the application:
e := httptest.New(t, app,
httptest.Debug(true), // print requests and responses
httptest.LogLevel("debug"), // re-enable application logs
httptest.Strict(true), // stop at the first failed assertion
httptest.URL("http://example.com"), // base URL for the client
)| Option | Default | Effect |
|---|---|---|
Debug(bool) | false | dumps each request/response through t.Logf |
LogLevel(string) | "disable" | the application’s logger level while testing |
Strict(bool) | false | require-style reporting: fail fast |
URL(string) | "" | base URL; enables cookie handling for that host |
The test client always carries a cookie jar, but the jar needs a host to
file cookies under. When a flow depends on cookies surviving across
requests (sessions, CSRF tokens), set a base URL
(httptest.URL("http://example.com") or the shorthand
httptest.Domain), and Set-Cookie responses are resent automatically
on subsequent requests within the same e.
Four variants cover the remaining cases:
httptest.NewController builds a throwaway application around one
controller, which is what a controller test wants and what httptest.New
cannot give it: New takes a built application, and the unit under test
is a Routing value.
func TestNotesController(t *testing.T) {
e := httptest.NewController(t, "/notes", &NotesAPI{Store: store.NewMemory()})
e.GET("/notes").Expect().Status(httptest.StatusOK).
JSON().Array().IsEmpty()
}The variadic middleware is registered on the controller’s own group, so
it runs for its routes and not for the 404 and 405 chains:
httptest.NewController(t, "/notes", api, requireAdmin). A failure
answers what a builder application answers, so the test asserts the body
production sends. The path parameter type and context error handlers are
installed, so a bad path parameter renders the rest/errors body.
errors.Handler runs before the router, so a bare failure status, such
as a requireAdmin that stops with 403, answers the JSON envelope and
not an empty body. It is also the 404 handler, so an unknown path under
the prefix answers the NOT_FOUND envelope. A method the controller
does not serve answers that same 404, not a 405, because
Options.Router.FireMethodNotAllowed is off by default and
NewController takes no options. Nothing else the builder chain adds is
there: no CORS, no access log, no recovery, no compression. When a test
needs those, or needs httptest.URL, it builds the application itself
and calls New.
httptest.Do executes a single handler with no application and no
routing, using the standard recorder (the package aliases
httptest.NewRecorder and httptest.NewRequest for convenience):
func TestHandlerAlone(t *testing.T) {
handler := func(ctx iris.Context) {
ctx.JSON(iris.Map{"status": "ok"})
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
httptest.Do(w, r, handler)
if w.Code != httptest.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}Because no routing happens, dynamic path parameters are never populated:
ctx.Params() is empty. A handler that reads path parameters must be
tested through a real application with httptest.New.
httptest.NewServer(t, app) builds the application and wraps it in
a standard net/http/httptest.Server, a real listener on a random
local port. Reach for it only when something genuinely needs a network
endpoint, such as testing an HTTP client:
srv := httptest.NewServer(t, app)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/notes")httptest.NewInsecure(t, ...) returns a client that skips TLS
certificate verification, for asserting against an already-running
HTTPS server: httptest.NewInsecure(t, httptest.URL("https://localhost:8443")).
Running the Suite
Nothing about Iris tests is special to the go tool:
go test ./... # the whole module
go test -run CRUD ./... # a subset by test name
go test -race ./... # with the race detector
go test -cover ./... # with coverageA word on -race: a data race is two goroutines (Go’s lightweight
concurrent threads) touching the same memory at the same time with at
least one writing, and it corrupts data in ways that surface far from
the cause, often only under production load. Go ships a race detector
that watches memory accesses while tests run and reports races with full
stack traces the moment they happen.
Run -race in CI at minimum. Handlers execute concurrently in
production, and shared fakes like MemStore are exactly where an
unguarded map write hides; the race detector finds it while the suite is
still milliseconds long.
Determinism is a design property, and the patterns of this chapter buy it for free, so keep them:
- Fresh state per test. Every test calls
newAppwith its own store. No shared globals means no ordering dependencies, andt.Parallel()is safe. - No ports.
httptest.Newneeds none; reserveNewServerfor the rare test that truly requires a listener. - No sleeps. The in-memory transport is synchronous: when
Expect()returns, the handler has finished. If a test wants time to pass, inject a clock through the same seam as every other dependency instead of waiting for the real one. - In-memory stores by default. Real databases belong to a separate,
explicitly tagged integration suite
(Chapter 13); the everyday suite must
run anywhere
go testdoes.
Summary
httptest.New(t, app)builds the application (failures reacht.Fatalf), binds an httpexpect client directly to it (no ports, no network) and silences logs. Options:Debug,LogLevel,Strict,URL.- Requests chain builders (
WithJSON,WithQuery,WithHeader,WithBasicAuth), execute withExpect(), and assert withStatus,JSON().Object()/Array(),HasValue,Value,Length,Body,Header;Raw()returns the underlying Go value. - Testability is an architecture decision:
newApp(cfg, deps)takes its seams as parameters, so tests run the real router, middleware and error map with fakes only at the edges. - Interface embedding turns one-method stubs into one-liners; the
failingRepotest proves internal error details never leak to clients. - Test guards as pairs (blocked and allowed), pin the validation and error-map wire format, and test middleware by building a tiny application around it.
- Domain logic gets plain table-driven Go tests;
go test ./...runs everything in milliseconds,-racein CI, no sleeps anywhere.
Further Reading
- Go tutorial: Add a test:
the official first-test walkthrough; start here if
go testitself is new. - The Go testing package:
*testing.T, subtests witht.Run,t.Parallel, benchmarks and fuzzing. - The Go net/http/httptest package:
the standard library’s recorder and test server that Iris’s
httptestbuilds upon. - httpexpect: the
full assertion-chain API behind the client
httptest.Newreturns. - The Go Data Race Detector:
what
-racecatches, its cost, and how to read its reports.