Skip to content

Mocking an LLM API With gock in Go

9 min read · updated August 11, 2026

gock intercepts by swapping out an http.Transport. That one implementation detail decides whether your mock is consulted at all, and every OpenAI-compatible Go SDK is on the wrong side of it by default.

gock patches a transport, not a URL

When you call gock.New("https://api.openai.com"), gock installs its own http.RoundTripper into http.DefaultTransport. Anything using http.DefaultClient, or a client that left Transport nil, is now intercepted.

Provider SDKs do not do that. They build their own *http.Client so they can set timeouts and connection pooling, and a client with its own transport never touches the default one. Your mock sits unmatched, the SDK makes a real request, and the test fails on a missing API key rather than on anything to do with your code.

The fix is one line, and it needs the client the SDK is actually using — which means constructing the SDK with a client you hold a reference to:

import (
    "net/http"
    "testing"

    "github.com/h2non/gock"
)

func TestSummarise(t *testing.T) {
    httpClient := &http.Client{}
    gock.InterceptClient(httpClient)
    defer gock.RestoreClient(httpClient)
    defer gock.Off()

    // Hand httpClient to the SDK's config so the SDK uses this exact client.
    client := newLLMClient(httpClient)

    gock.New("https://api.openai.com").
        Post("/v1/chat/completions").
        MatchType("json").
        Reply(200).
        JSON(map[string]any{
            "id":    "chatcmpl-abc",
            "model": "gpt-4o-mini",
            "choices": []map[string]any{{
                "index":         0,
                "finish_reason": "stop",
                "message": map[string]any{
                    "role":    "assistant",
                    "content": "Three bullet points.",
                },
            }},
        })

    got, err := client.Summarise("some input")
    if err != nil {
        t.Fatal(err)
    }
    if got != "Three bullet points." {
        t.Fatalf("got %q", got)
    }
    if !gock.IsDone() {
        t.Fatal("pending mocks were never matched")
    }
}

gock.RestoreClient matters in a package with more than one test: leave a client intercepted and a later test that expected real behaviour gets gock’s transport instead. Pair every InterceptClient with a deferred restore.

If threading an *http.Client through your constructor feels awkward, that is the design pressure described on injecting the LLM client. Being able to hand the transport in is the same seam that lets you add tracing and a per-request timeout later.

What to assert on

Because the mock is a compiled value rather than a recording, gock is strongest on the request side. Assert that the model id in your config reached the wire, that a tool definition serialised with the field names the provider expects, that a context deadline produced a cancelled request rather than a hung goroutine, and that a retry produced exactly two calls and not five.

On the response side, treat the JSON you write in the test as a fixture for your decoder. The most valuable ones are the awkward shapes: a message with content null and a tool call present, a finish_reason of length, an error envelope with a type your switch statement does not know. A Go decoder into a typed struct will silently zero anything it did not expect, so the assertion has to be on the value your code produced, not on the absence of an error.

That last point deserves more weight than it usually gets in Go. A struct with json tags will decode a response that is missing half its fields without complaint, leaving zero values behind, so a test asserting only err == nil passes against a fixture that bears no resemblance to a real completion. If the field genuinely must be present, either decode into a type with pointer fields and check for nil, or add an explicit validation step after decoding and test that. Otherwise a provider quietly renaming a field surfaces as an empty string in your product rather than as an error anywhere.

The other assertion worth writing early is about context. Pass a context.Context with a deadline into your client and have gock return a response your code will never read in time; the test proves that cancellation propagates to the request and that the goroutine waiting on it actually returns. That is a concurrency property, not an HTTP one, and it is the kind of thing that is trivially testable here and untestable once the call is buried three layers down.

Matching the request body

MatchType("json") constrains the content type. JSON(...) matches the body against a structure, BodyString(...) against a string or pattern, and MatchHeader(name, pattern) takes a regular expression, which is how you assert that an Authorization header was present without hard-coding a key.

As with every library in this cluster, structural body matching is close to equality and an LLM request body is a poor candidate for it: add one optional parameter and the mock stops matching. For partial matching gock supports registering a custom matcher function; check gock’s README for the exact function name in your version rather than copying one from a blog post, because the matcher API has moved.

There is a simpler route that avoids the question. Use gock.Observe to capture the intercepted request, let the mock match on method and path only, and do your assertions in plain Go afterwards. You get a real diff from your test framework instead of a no-match error, which is worth more than terseness.

One habit makes debugging a no-match much faster whichever route you take: register the mock with the loosest matcher that could work, get the test passing, then tighten it one condition at a time. Starting from a fully specified mock means the first run fails and you have no way to tell whether the transport interception, the path, the content type or the body was the problem — gock reports all of them the same way, as a request that reached the real network.

An SSE response

A streaming completion is a normal response whose body is server-sent events, so gock can serve one directly — set the content type and give it the text:

sse := "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n" +
    "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n" +
    "data: [DONE]\n\n"

gock.New("https://api.openai.com").
    Post("/v1/chat/completions").
    Reply(200).
    SetHeader("Content-Type", "text/event-stream").
    BodyString(sse)

Be clear about what this does and does not test. The bytes are correct, so a parser reading bufio.Scanner over the body will work. But the whole string is available at once, so it does not exercise the case where a data: line arrives split across two reads. That case is real over a network and is where SSE parsers break; recording streaming responses as fixtures covers constructing a fixture that splits on purpose.

Verifying and cleaning up

  1. defer gock.Off() in every test that declares a mock. It disables interception and flushes pending mocks, so a mock your test never used cannot answer a request in the next one.
  2. gock.DisableNetworking() in a TestMain if you want a hard guarantee that nothing in the package reaches the network.
  3. Check gock.IsDone() before the test returns. Without it, a test where the HTTP call was never made passes, and a code path you broke looks covered.
  4. Keep t.Parallel() away from these tests. gock’s registered mocks are process-global state, and two parallel tests will match each other’s mocks in an order that changes between runs.