Chapter 16

Testing

In-process HTTP tests against the real router, the real middleware chain and the real error map. No ports, no network and no sleeps, from unit to suite.

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:

Go
import "github.com/kataras/iris/v14/httptest"

e := httptest.New(t, app)

Three things happen inside that call, and each one matters:

  1. The application is built. httptest.New calls app.Build() for you and reports a build failure through t.Fatalf: a misconfigured route or an unresolvable dependency fails the test immediately with the real error message, instead of panicking.
  2. 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.
  3. Logs are silenced. The application’s log level is set to disable by default, so go test output stays readable. Pass httptest.LogLevel("debug") or httptest.Debug(true) when you need to see what a failing test is doing.
The three httptest entry points httptest.New binds the client transport straight to your built application so requests never touch the network, NewServer puts a real port in front of it, and Do calls a single handler with no router at all. httptest.New(t, app) THE DEFAULT *testing.T or *testing.B, or GinkgoT() httpexpect.Expect fluent request and assertion chain http.Client with NewBinder(app) and NewCookieJar() app.ServeHTTP the full router: chain, mux, handlers app.Build() is called for you, and a build error becomes t.Fatalf rather than a panic. The logger is silenced unless you pass LogLevel("debug") or Debug(true). No listener, no port, no network: the binder calls the handler in the same process. URL("http://example.com") also sets the application host, which is what lets the cookie jar file cookies across requests. httptest.NewServer(t, app) WHEN SOMETHING NEEDS A REAL URL app.Build() same as above httptest.Server a random local port real TCP a real dial, a real handshake use for a client not for testing your own handlers httptest.Do(w, r, handler) ONE HANDLER, NO APPLICATION iris.New(...) a throwaway app, logging disabled ContextPool a pooled Context, bound to w and r handler(ctx) your handler, called directly NewRecorder() inspect Code, Body, Header No routing happens, so ctx.Params() is always empty: a handler that reads a path parameter must go through httptest.New instead. httptest.NewInsecure(t, httptest.URL("https://localhost:8443")) is given no application at all: it skips certificate verification against a server that is already running.
httptest.New binds the client transport straight to your built application so requests never touch the network, NewServer puts a real port in front of it, and Do calls a single handler with no router at all.

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:

Go
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:

Go
func newApp(cfg Config, repo store.Repository) *iris.Application

main 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 Services step, which the dependency container uses to satisfy controller constructors (Chapter 8).
  • Tests exercise the real application. Because newApp is the same function main uses, 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 Config struct 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).

Go
// 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.

Go
// 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.

Go
// 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:

MethodPurpose
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:

MethodPurpose
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:

Go
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.

Go
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:

Go
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:

Go
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.

Go
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:

Go
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.

Go
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:

Go
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:

Go
// 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:

Go
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
)
OptionDefaultEffect
Debug(bool)falsedumps each request/response through t.Logf
LogLevel(string)"disable"the application’s logger level while testing
Strict(bool)falserequire-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.

Go
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):

Go
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:

Go
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:

Shell
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 coverage

A 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 newApp with its own store. No shared globals means no ordering dependencies, and t.Parallel() is safe.
  • No ports. httptest.New needs none; reserve NewServer for 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 test does.

Summary

  • httptest.New(t, app) builds the application (failures reach t.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 with Expect(), and assert with Status, 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 failingRepo test 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, -race in CI, no sleeps anywhere.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.