testutil

package
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package testutil provides test helpers for gitlab-mcp-server.

It wraps net/http/httptest, the official gitlab.com/gitlab-org/api/client-go client, and shared assertion helpers so every domain test can stand up an isolated MCP server in a few lines. The package is consumed by every tool test under internal/tools and is the only sanctioned way to construct a gitlabclient.Client in tests.

Helper categories

Typical usage

func TestListBranches(t *testing.T) {
    client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        testutil.RespondJSON(w, http.StatusOK, `[{"id":1,"name":"main"}]`)
    }))
    // ... call the domain handler with client ...
}

Coverage

This package does not reach the repository's 100% statement rule, and the residue is named here rather than left for the next contributor to rediscover: the testing.T.Fatalf branches of AssertEmbeddedResource, IsolateTempDir and the legacy elicitation client. Every one of them exists to abort the caller's test, so reaching it means arranging for a helper to fail while the test that called it keeps running, and the only way to do that is to route the abort through a package variable a test can replace. That would cost these helpers the guarantee they are used for, since a Fatalf that no longer aborts leaves the code after it running on the value it was refusing, and it would have to be done at every call site to be worth anything. The recording and shape files added for the request inventory are at 100%, and the seams they use ([createShard], [recorderPackage], the requestReporter interface) are the pattern to follow if these are ever closed.

Index

Constants

View Source
const InventoryDirEnv = "GITLAB_MCP_TEST_INVENTORY_DIR"

InventoryDirEnv names the directory every request a test issues through NewTestClient is recorded into. Recording is off unless it is set, so an ordinary `go test` run pays nothing for it.

The path must be absolute. A test binary runs with its own package directory as the working directory, so a relative path would scatter one shard per package under 178 different directories and the merge would find none of them. A relative value is refused rather than resolved, because resolving it would produce exactly that scattering silently.

It carries the project's prefix but is not one of the settings internal/config resolves: it configures the test harness, cmd/server never reads it, and there is no legacy spelling of it to warn anybody about. That is the same standing the developer-only EVAL_SURFACE_* variables have.

View Source
const MsgErrEmptyProjectID = "expected error for empty project_id, got nil"

MsgErrEmptyProjectID is the canonical assertion message for tests that expect an error when the GitLab tool input omits project_id.

Variables

This section is empty.

Functions

func AllowInvalidGraphQL

func AllowInvalidGraphQL(tb testing.TB)

AllowInvalidGraphQL exempts tb, and every subtest under it, from GraphQL document validation.

It exists for the test that deliberately sends a malformed document, which is the only honest reason to want it: a document the pinned schema refuses is a document GitLab refuses, so silencing the gate anywhere else silences a real defect. Say in a comment at the call site which malformed document the test is sending and why it has to.

Declare it on the test that calls NewTestClient, not on a subtest under one. The exemption is looked up under the name of the test the validation reports against, and that is the test the client belongs to: with a client built in the parent and driven from subtests, which is the common shape here, a subtest's exemption is never consulted and the refusal is reported against the parent while every subtest passes.

The exemption lasts for tb's lifetime and is released on cleanup.

func AssertCapturedDecodeFailures

func AssertCapturedDecodeFailures(t *testing.T, cases []CapturedCase)

AssertCapturedDecodeFailures runs each case under a subtest of its own and fails the ones whose handler answered successfully, or with an error that does not name the captured response's decode failure.

Every package that reads a field client-go does not model asserts the same thing about every handler it converted (ADR-0021), so the table lives with the handlers and the assertion lives here.

func AssertEmbeddedResource

func AssertEmbeddedResource(t *testing.T, ctx context.Context, session *mcp.ClientSession, name string, args map[string]any, wantURI string, toggle EmbedToggle)

AssertEmbeddedResource verifies that an MCP tool behaves correctly under both states of the embedded-resource toggle. It invokes the named tool via session twice:

  1. With toggle(true) — expects an *mcp.EmbeddedResource block whose URI matches wantURI and whose MIME type is "application/json".
  2. With toggle(false) — expects no EmbeddedResource blocks.

The toggle is always restored to its enabled (production default) state on test exit via testing.T.Cleanup. The test fails the surrounding run if either subtest fails.

func AssertQueryParam

func AssertQueryParam(t *testing.T, r *http.Request, key, expected string)

AssertQueryParam fails the test if the URL query parameter key does not equal expected. Missing parameters are reported as a mismatch with an empty actual value.

func AssertRequestMethod

func AssertRequestMethod(t *testing.T, r *http.Request, expected string)

AssertRequestMethod fails the test if r.Method does not equal expected. It calls testing.T.Helper so failure lines point at the caller.

func AssertRequestPath

func AssertRequestPath(t *testing.T, r *http.Request, expected string)

AssertRequestPath fails the test if r.URL.Path does not equal expected. It calls testing.T.Helper so failure lines point at the caller.

func CancelledCtx

func CancelledCtx(t *testing.T) context.Context

CancelledCtx returns a context.Context that is already cancelled. Use it to test handler cancellation paths without dealing with real timers.

The returned context returns context.Canceled from context.Context.Err immediately. The associated cancel function is intentionally dropped — callers must not attempt to cancel it again.

func CaptureSlog

func CaptureSlog(t *testing.T) *bytes.Buffer

CaptureSlog redirects slog output to an in-memory bytes.Buffer for the duration of the test. The original slog.Default logger is restored via testing.T.Cleanup.

CaptureSlog is NOT safe for testing.T.Parallel: it acquires [captureSlogMu] for the lifetime of the test and would deadlock with another parallel CaptureSlog caller.

func ConnectLegacyElicitationClient

func ConnectLegacyElicitationClient(ctx context.Context, t *testing.T, server *mcp.Server, handler ElicitHandlerFunc, opts LegacyClientOptions) *mcp.ServerSession

ConnectLegacyElicitationClient connects a minimal legacy MCP client (protocol 2025-11-25, elicitation capability advertised) to server and returns the resulting server session. Server-initiated elicitation/create requests are answered by handler; ping requests are acknowledged; all other server-initiated requests fail with MethodNotFound. The session and the fake client are torn down via t.Cleanup.

After the handshake, handler runs on the fake client's serving goroutine, not the test goroutine: report failures inside handler with t.Errorf or by returning an error, never t.Fatal/t.FailNow (which only terminate the calling goroutine).

func ForbiddenHandler

func ForbiddenHandler(t *testing.T) http.Handler

ForbiddenHandler returns an http.Handler for mocks that must never be called. Each request is counted atomically and answered with a deterministic 500 so the client under test fails loudly, and a testing.T.Cleanup hook asserts on the test goroutine that no request arrived. This is the sanctioned replacement for `t.Fatal("should not be called")` inside handler literals, which the testing package forbids off the test goroutine (see .github/instructions/test-goroutines.instructions.md).

func GraphQLHandler

func GraphQLHandler(handlers map[string]http.HandlerFunc) http.Handler

GraphQLHandler returns an http.Handler that routes GraphQL POST requests by matching the request's query string against handler keys. The first key that appears as a substring of the query wins; keys are therefore evaluated longest-first so specific mutation names take precedence over shorter operation roots (e.g. "vulnerabilityDismiss" matches before "vulnerability").

The request body is parsed into a [graphqlRequest], then reattached to r so downstream handlers can read it again. Non-POST requests are rejected with 405 Method Not Allowed, malformed JSON with 400 Bad Request, and queries that match no key with 400 Bad Request.

Keys should be GraphQL operation identifiers (field names, type names, or mutation names) that uniquely identify the operation, for example:

testutil.GraphQLHandler(map[string]http.HandlerFunc{
    "vulnerabilities":      handleListVulnerabilities,
    "vulnerabilityDismiss": handleDismissVulnerability,
})

func IsolateTempDir

func IsolateTempDir(t *testing.T, dir string)

IsolateTempDir points os.TempDir at dir for the duration of the test.

Setting TMPDIR alone is a POSIX-only instruction. On Windows os.TempDir never reads it, so a test that sets only TMPDIR keeps the real temporary directory and its isolation silently does nothing.

That silence is expensive in exactly the tests this helper exists for. The upload, download and import allow-lists treat the OS temporary directory as an always-allowed root, so a fixture built with testing.T.TempDir and meant to sit outside every allowed root instead sat inside one. Every assertion that such a path is refused then passed a path that was correctly accepted, and the suite reported that containment worked while testing nothing. The allow-list itself was never wrong.

It cannot be used from a parallel test, because testing.T.Setenv cannot.

func NewTestClient

func NewTestClient(tb testing.TB, handler http.Handler) *gitlabclient.Client

NewTestClient creates a gitlabclient.Client pointed at a fresh httptest.Server backed by handler. The server is automatically torn down when the test finishes via testing.T.Cleanup, so callers never need to manage its lifecycle.

The client uses a static token ("test-token"), TLS verification disabled, and retries disabled — sufficient for most handler unit tests but not for exercising the transport retry policy. NewTestClient calls testing.T.Helper and testing.T.Fatalf if client construction fails.

Every GraphQL document sent through the returned client is validated against the pinned GitLab schema before handler sees it, so a mock can no longer answer what GitLab would refuse. The request proceeds either way and a refusal is reported with testing.TB.Errorf; AllowInvalidGraphQL is the opt-out for a test that sends a malformed document on purpose.

That validation is the reason to build a test's client here rather than wiring httptest.NewServer to gitlabclient.NewClient by hand. A handful of tests still do the latter, none of them sending GraphQL today, and any GraphQL written against one of those seams would be judged by nobody, which is the state this helper exists to end. The same goes for the recording below: a client built by hand is a request nothing observes.

Every request is also recorded, when InventoryDirEnv names a directory to record into, so the suite can answer what this server actually sends GitLab. Recording is off in an ordinary run.

The returned client is safe for concurrent use; the httptest.Server that backs it is goroutine-safe by construction.

func ParseGraphQLVariables

func ParseGraphQLVariables(r *http.Request) (map[string]any, error)

ParseGraphQLVariables reads r's body and returns the Variables map from the GraphQL request, or an error if the body cannot be read or is not valid JSON. The body is restored on r so subsequent handlers can re-read it. The returned map is nil when the request omits a variables field.

ParseGraphQLVariables is intended for assertions inside test handlers — production code should rely on the client-go GraphQL transport.

func RespondGraphQL

func RespondGraphQL(w http.ResponseWriter, status int, data string)

RespondGraphQL writes a GraphQL JSON envelope response with the given data payload. It wraps data in the standard {"data": ...} envelope expected by the GitLab GraphQL API client. For example:

testutil.RespondGraphQL(w, http.StatusOK, `{"project":{"name":"foo"}}`)

produces the body {"data":{"project":{"name":"foo"}}}. Callers remain responsible for escaping any quotes embedded in data.

func RespondGraphQLError

func RespondGraphQLError(w http.ResponseWriter, status int, message string)

RespondGraphQLError writes a GraphQL error envelope with the given message. It sets data to null and emits a single error entry, mirroring the shape returned by GitLab when a query partially or fully fails. For example:

testutil.RespondGraphQLError(w, http.StatusOK, "not found")

produces {"data":null,"errors":[{"message":"not found"}]}. The message is interpolated directly into JSON — callers must escape any embedded quotes.

func RespondJSON

func RespondJSON(w http.ResponseWriter, status int, body string)

RespondJSON writes a JSON response with the given HTTP status and raw body. It sets Content-Type to "application/json". body is written verbatim — callers are responsible for producing valid JSON. Write errors are intentionally ignored because the only writer in tests is an httptest.ResponseRecorder, which never fails.

func RespondJSONWithPagination

func RespondJSONWithPagination(w http.ResponseWriter, status int, body string, p PaginationHeaders)

RespondJSONWithPagination writes a JSON response with GitLab pagination headers attached. Headers whose PaginationHeaders field is empty are omitted, matching GitLab's behavior on pages without a next/previous pointer.

Types

type CapturedCase

type CapturedCase struct {
	// Name says which handler the call drives, and names the subtest.
	Name string
	// Call runs the handler and returns the error it reported, if any.
	Call func() error
}

CapturedCase is one handler call in a table asserting the failure a captured response adds: a body the output type cannot hold.

type ElicitHandlerFunc

type ElicitHandlerFunc func(context.Context, *mcp.ElicitParams) (*mcp.ElicitResult, error)

ElicitHandlerFunc handles one server-initiated elicitation request.

type EmbedToggle

type EmbedToggle func(bool)

EmbedToggle is the signature of the [toolutil.EnableEmbeddedResources] setter. testutil reproduces the type locally so callers can drive the embedded-resource global flag without importing toolutil (which would introduce an import cycle through the tool sub-packages).

type LegacyClientOptions

type LegacyClientOptions struct {
	// URLElicitation advertises support for URL-mode elicitation in
	// addition to form elicitation.
	URLElicitation bool
}

LegacyClientOptions configures the fake legacy client's advertised capabilities.

type PaginationHeaders

type PaginationHeaders struct {
	Page       string // X-Page — current page number.
	PerPage    string // X-Per-Page — items per page in this response.
	Total      string // X-Total — total items across all pages.
	TotalPages string // X-Total-Pages — total page count.
	NextPage   string // X-Next-Page — next page number, if any.
	PrevPage   string // X-Prev-Page — previous page number, if any.
}

PaginationHeaders is the set of GitLab pagination response headers returned with list endpoints. Empty fields are omitted from the response so handlers can populate only the headers a given scenario exercises.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL