# Testing & Production

You've built the whole tasks API - routing, binding, middleware, CRUD, error handling, a tidy package layout. Now comes the part that decides whether anyone trusts it: proving it works, and running it somewhere real without it falling over at 3am. Both turn out to be small, once you see the one fact that makes them small.

## The mental model: your router is just an `http.Handler`, so testing is calling it in memory

Here's the thing that makes Gin pleasant to test. A `*gin.Engine` - the thing you get from `gin.Default()` - satisfies Go's `http.Handler` interface. It has a `ServeHTTP(w, r)` method. That's the *exact* same interface the standard library's `http.Server` uses to feed it real requests.

> 💡 If your router is an `http.Handler`, then a test is nothing more than calling `ServeHTTP` yourself with a fake request and a fake response writer. No network. No port. No `go func` running a server in the background. You hand the engine a request, it fills in a response, you read it back - all in memory, in microseconds.

The standard library hands you the two fakes you need in `net/http/httptest`:

- `httptest.NewRequest(method, target, body)` builds a `*http.Request` without a real connection.
- `httptest.NewRecorder()` gives you a `*httptest.ResponseRecorder` - a response writer that records the status, headers, and body into fields you can inspect (`w.Code`, `w.Body`).

Wire those together and you've tested a route end to end without opening a socket.

```go
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gin-gonic/gin"
)

func TestListTasks(t *testing.T) {
    gin.SetMode(gin.TestMode)
    r := setupRouter()

    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
    r.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Fatalf("got status %d, want 200", w.Code)
    }

    var got []Task
    if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
        t.Fatalf("response wasn't valid JSON: %v", err)
    }
}
```

*What just happened:* we built the same router the real app uses (`setupRouter()` - more on that in a second), created a recorder and a GET request for `/api/v1/tasks`, and called `r.ServeHTTP(w, req)`. That single call runs the *entire* chain - middleware, routing, your handler - exactly as a live request would, except nothing left the process. Afterward `w.Code` is the status the handler set and `w.Body` is a `*bytes.Buffer` holding the response body, which we unmarshal to confirm it's the JSON shape we expect. This test runs in well under a millisecond and never touches the network.

Testing a **POST** is the same shape with two additions: you pass a body and set the `Content-Type` header so Gin's `ShouldBindJSON` knows it's JSON.

```go
func TestCreateTask(t *testing.T) {
    gin.SetMode(gin.TestMode)
    r := setupRouter()

    body := `{"title":"write tests"}`
    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", bytes.NewBufferString(body))
    req.Header.Set("Content-Type", "application/json")
    r.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Fatalf("got status %d, want 201", w.Code)
    }
}
```

*What just happened:* `bytes.NewBufferString(body)` turns our JSON string into an `io.Reader`, which is what the request body wants. Setting `Content-Type: application/json` matters - without it, binding can reject the body or skip it, and you'd be testing the wrong path. We assert a `201 Created`, the status your create handler returns. Same recorder, same `ServeHTTP`, same in-memory speed. (For testing inputs you *expect* to fail - a missing title, a malformed body - send the bad payload and assert the `400` and error message your validation produces.)

This is the heart of testing a web app. The rest - table-driven cases, golden files, running it all in CI on every push - is general Go testing, covered in [testing in CI](/guides/testing-in-ci).

## `gin.TestMode` and the `setupRouter()` you build once

Two small disciplines make the tests above clean.

First, **`gin.SetMode(gin.TestMode)`**. Gin runs in one of three modes - debug (the noisy default), test, and release. Debug mode prints a wall of colored startup output and per-request logging; in a test run that's pure noise drowning your actual failures. `gin.TestMode` silences it. Set it at the top of each test (or once in a `TestMain`).

Second - and this is the structural move that makes everything testable - **factor your router construction into a function**, conventionally `setupRouter()`, that returns the configured `*gin.Engine`. Both `main` and your tests call it, so they exercise the *same* wiring.

```go
func setupRouter() *gin.Engine {
    r := gin.Default()

    v1 := r.Group("/api/v1")
    {
        v1.GET("/tasks", listTasks)
        v1.POST("/tasks", createTask)
        v1.GET("/tasks/:id", getTask)
        v1.PUT("/tasks/:id", updateTask)
        v1.DELETE("/tasks/:id", deleteTask)
    }

    return r
}

func main() {
    r := setupRouter()
    r.Run(":8080")
}
```

*What just happened:* all the route registration lives in one place. `main` builds the router and runs it; a test builds the *same* router and pokes it with `httptest`. There's no second, slightly-different set of routes that "should match production" but quietly drifts - there's one source of truth. The moment you find yourself copy-pasting route setup into a test, stop and pull out a `setupRouter()`. (If your handlers need a database or config, have `setupRouter(deps)` take them as a parameter so tests can pass fakes.)

## Production mode: flip the switch

When you deploy, get Gin out of debug mode. **Release mode** drops the debug logging and the startup warnings, and is the mode Gin expects in production. Two ways to set it:

```go
// In code, before you build the router:
gin.SetMode(gin.ReleaseMode)

// Or via environment variable, no code change:
//   GIN_MODE=release ./your-binary
```

*What just happened:* `gin.ReleaseMode` (or the `GIN_MODE=release` env var, which Gin reads on startup) turns off the per-request debug log lines and the "running in debug mode" warning. The env-var form is usually nicer for deploys - the same binary runs in debug locally and release in production, controlled entirely by the environment. Prefer the env var unless you have a reason to hard-code it.

> 📝 Release mode is about Gin's own chatter, not your application logging. Your `gin.Recovery()` middleware still catches panics, and any logging *you* added still runs. You're silencing the framework's debug noise, not going dark.

## Graceful shutdown: why `r.Run()` isn't enough for a real deploy

You've used `r.Run(":8080")` everywhere, and for learning it's perfect. For a real deploy it has two gaps, and both come from the same root: `r.Run()` is a convenience wrapper that builds an `http.Server` with **default settings** and gives you no handle on it.

The two things you lose:

1. **No timeouts.** A client that opens a connection and sends bytes slowly - or never finishes - can tie up a server goroutine indefinitely. With enough of them (accidental or malicious), you run out of resources. Real servers set read/write/idle timeouts as a baseline defense.
2. **No clean drain.** When your platform restarts the service (a deploy, a scale-down, a `SIGTERM`), `r.Run()` just gets killed mid-flight. Requests in progress are cut off, and clients see broken connections. You want the server to *stop accepting new requests, finish the ones in flight, then exit* - that's a **graceful shutdown**.

You get both by constructing the `http.Server` yourself and handing it your Gin engine as the handler (because - say it with me - the engine is an `http.Handler`):

```go
func main() {
    gin.SetMode(gin.ReleaseMode)
    r := setupRouter()

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      r, // the gin.Engine, used as a plain http.Handler
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Run the server in its own goroutine so main can wait for a shutdown signal.
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("server failed: %v", err)
        }
    }()

    // Block until we get an interrupt or terminate signal.
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    <-ctx.Done()
    log.Println("shutting down...")

    // Give in-flight requests up to 5 seconds to finish, then force-close.
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("forced shutdown: %v", err)
    }
    log.Println("server stopped cleanly")
}
```

*What just happened:* a lot of small, deliberate pieces. We build an `http.Server` with our engine as `Handler` and real `ReadTimeout`/`WriteTimeout`/`IdleTimeout` values, closing the slow-client gap. We start it with `ListenAndServe()` in a goroutine - note the `err != http.ErrServerClosed` check, because a *clean* shutdown returns that exact error and we don't want to treat it as a crash. Then `signal.NotifyContext` gives us a context that cancels when the OS sends `SIGINT` (Ctrl+C) or `SIGTERM` (what platforms send on restart). `<-ctx.Done()` blocks `main` until that happens. Once it fires, `srv.Shutdown(shutdownCtx)` does the graceful part: it stops accepting new connections and waits for in-flight requests to finish, but only up to the 5-second deadline we set with `context.WithTimeout` - past that, it gives up and forces them closed so a stuck request can't block the deploy forever.

> ⚠️ The `http.ErrServerClosed` check is not optional decoration. `Shutdown` causes `ListenAndServe` to return `http.ErrServerClosed`. If your goroutine does a blanket `log.Fatalf` on *any* error, every clean shutdown will look like a crash and exit non-zero - which your orchestrator may then report as a failed restart. Treat that one error as success.

This is more code than `r.Run(":8080")`, and that's the point: you've traded one line for control over timeouts and a clean exit, which is exactly the trade a production service needs to make.

## Deploy shape: a static binary, env config, a small container

Go's superpower for shipping is the **static binary**. A Go program compiles to a single executable with no runtime to install - no interpreter, no `node_modules`, no virtualenv. For a Linux container, build it fully static:

```bash
CGO_ENABLED=0 GOOS=linux go build -o tasks-api .
```

*What just happened:* `CGO_ENABLED=0` disables cgo so the binary doesn't dynamically link against the system C library - it's fully self-contained and will run on a bare `scratch` or `alpine` image with nothing else installed. `GOOS=linux` cross-compiles for Linux even if you're building on a Mac or Windows machine. The output is one file, `tasks-api`, that you can copy somewhere and run.

Read configuration - at minimum the **port** - from the environment, not a hard-coded constant. Most platforms (and the 12-factor convention) tell your app which port to bind via a `PORT` env var:

```go
addr := ":8080"
if p := os.Getenv("PORT"); p != "" {
    addr = ":" + p
}
srv := &http.Server{Addr: addr, Handler: r /* ...timeouts... */}
```

*What just happened:* we default to `:8080` for local dev but let `PORT` override it, so the same binary runs unchanged whether you run it on your laptop or a platform that injects `PORT=10000`. Same principle applies to database URLs, secrets, and the `GIN_MODE` we set earlier - configuration comes from the environment so the artifact stays identical across environments.

A minimal multi-stage Dockerfile builds the binary and copies *only* it into a tiny final image:

```bash
# Build stage
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /tasks-api .

# Run stage - tiny image, just the binary
FROM gcr.io/distroless/static
COPY --from=build /tasks-api /tasks-api
EXPOSE 8080
ENV GIN_MODE=release
ENTRYPOINT ["/tasks-api"]
```

*What just happened:* the first stage has the whole Go toolchain and compiles the binary; the second stage is a `distroless/static` image - basically nothing but the files needed to run a static binary, no shell, no package manager - and we copy just the one executable into it. The result is a container measured in single-digit megabytes instead of hundreds, with a tiny attack surface. We also bake in `GIN_MODE=release` so the container always runs in production mode.

In front of it, put a **reverse proxy** - nginx, Caddy, or whatever your platform provides (a load balancer, an ingress controller). The proxy terminates TLS (HTTPS), can serve static assets, and load-balances across multiple instances of your binary. Your Gin app speaks plain HTTP on its port; the proxy handles the public-facing internet. You generally don't terminate TLS in Gin itself - let the proxy do it.

That's the whole deploy shape: one static binary, configured by env vars, in a small container, behind a proxy. Taking it the rest of the way to a live URL - picking a host, wiring CI, the domain and TLS specifics - is covered in [ship your side project](/guides/ship-your-side-project).

## Recap

- A `*gin.Engine` is an `http.Handler`, so you **test it in memory** with `net/http/httptest`: build a request with `httptest.NewRequest`, a recorder with `httptest.NewRecorder`, call `r.ServeHTTP(w, req)`, then inspect `w.Code` and `w.Body`. No network, no ports. For POST, pass a `bytes.NewBufferString` body and set `Content-Type: application/json`.
- Call `gin.SetMode(gin.TestMode)` in tests to silence debug output, and factor route construction into a `setupRouter()` that both `main` and tests call - one source of truth, no drift.
- For production, switch to **release mode** via `gin.SetMode(gin.ReleaseMode)` or `GIN_MODE=release` (the env var is nicer for deploys).
- `r.Run()` gives you no timeouts and no clean drain. Build your own `http.Server{Addr, Handler: r, ReadTimeout, WriteTimeout, IdleTimeout}`, run it in a goroutine, and on `SIGINT`/`SIGTERM` call `srv.Shutdown(ctx)` for a **graceful shutdown** - treating `http.ErrServerClosed` as success.
- Ship a **static binary** (`CGO_ENABLED=0 go build`) in a small container, read config like `PORT` from the environment, and put a reverse proxy (nginx/Caddy/platform LB) in front to terminate TLS and load-balance.

## Quick check

Lock in the core fact (the handler interface) and the two production must-haves:

```quiz
[
  {
    "q": "Why can you test a Gin router with net/http/httptest and no real network?",
    "choices": ["Gin spins up a hidden test server on a random port", "A *gin.Engine implements http.Handler, so a test just calls its ServeHTTP method directly in memory", "httptest mocks the TCP stack at the OS level", "You can't - Gin tests always need a running server"],
    "answer": 1,
    "explain": "Because the engine satisfies http.Handler, ServeHTTP(w, req) runs the entire middleware-and-handler chain in-process. httptest gives you a fake request and a recording response writer; nothing touches a socket."
  },
  {
    "q": "What does r.Run(\":8080\") NOT give you that a real deploy needs?",
    "choices": ["Routing and middleware", "JSON responses", "Configurable read/write timeouts and a graceful shutdown", "The ability to set a port"],
    "answer": 2,
    "explain": "r.Run is a convenience wrapper over a default http.Server with no exposed handle. To set ReadTimeout/WriteTimeout/IdleTimeout and to drain in-flight requests on SIGTERM via srv.Shutdown(ctx), construct the http.Server yourself with your engine as the Handler."
  },
  {
    "q": "During a graceful shutdown, srv.ListenAndServe() returns a specific error. How should you treat it?",
    "choices": ["As a fatal crash - log.Fatal and exit non-zero", "As http.ErrServerClosed, which signals a clean shutdown and should NOT be treated as a failure", "Ignore the return value of ListenAndServe entirely", "Retry ListenAndServe in a loop"],
    "answer": 1,
    "explain": "srv.Shutdown causes ListenAndServe to return http.ErrServerClosed. Check for it explicitly; a blanket log.Fatal on any error would make every clean shutdown look like a crash."
  }
]
```
