The servertest package provides a test harness that simulates an LSP client over in-memory pipes. It handles JSON-RPC framing, initialization, and cleanup so you can write focused tests for your handler logic.
The package is included with go-lsp — no extra dependency needed.
import "github.com/owenrumney/go-lsp/servertest"Create a harness by passing your handler to servertest.New. It starts the server, performs the initialize/initialized handshake, and registers cleanup to shut down gracefully when the test ends.
func TestHover(t *testing.T) {
h := servertest.New(t, &myHandler{})
h.DidOpen("file:///test.txt", "plaintext", "hello world")
hover, err := h.Hover("file:///test.txt", 0, 5)
if err != nil {
t.Fatal(err)
}
if hover == nil {
t.Fatal("expected hover result")
}
}No manual shutdown or cleanup needed — t.Cleanup handles it.
The harness provides shortcuts for document sync notifications:
// Open a document (sets version to 1)
h.DidOpen("file:///main.go", "go", sourceCode)
// Update with new content (full sync)
h.DidChange("file:///main.go", 2, updatedSource)
// Trigger save
h.DidSave("file:///main.go")
// Close
h.DidClose("file:///main.go")Typed methods for common LSP requests. These construct the params structs for you from minimal arguments:
hover, err := h.Hover(uri, line, char)
list, err := h.Completion(uri, line, char)
item, err := h.ResolveCompletionItem(item)
sig, err := h.SignatureHelp(uri, line, char)
decls, err := h.Declaration(uri, line, char)
locs, err := h.Definition(uri, line, char)
types, err := h.TypeDefinition(uri, line, char)
impls, err := h.Implementation(uri, line, char)
locs, err := h.References(uri, line, char, includeDeclaration)
highlights, err := h.DocumentHighlight(uri, line, char)
syms, err := h.DocumentSymbol(uri)
syms, err := h.WorkspaceSymbol("query")
lenses, err := h.CodeLens(uri)
links, err := h.DocumentLink(uri)
colors, err := h.DocumentColor(uri)
edits, err := h.Formatting(uri)
edits, err := h.RangeFormatting(uri, theRange)
edits, err := h.OnTypeFormatting(uri, line, char, "}")
edit, err := h.Rename(uri, line, char, "newName")
prepare, err := h.PrepareRename(uri, line, char)
folds, err := h.FoldingRange(uri)
selection, err := h.SelectionRange(uri, positions)
linked, err := h.LinkedEditingRange(uri, line, char)
monikers, err := h.Moniker(uri, line, char)
hints, err := h.InlayHint(uri, theRange)
tokens, err := h.SemanticTokensFull(uri)For code actions (which need richer params), pass the full struct:
actions, err := h.CodeAction(&lsp.CodeActionParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
Range: theRange,
Context: lsp.CodeActionContext{Diagnostics: diags},
})The harness also has typed helpers for code action, code lens, document link, and inlay hint resolve requests; pull diagnostics; semantic token delta/range requests; call/type hierarchy; file operation requests; and workspace/executeCommand.
For anything not covered by a typed method, use the escape hatch:
result, err := h.Call("textDocument/someMethod", params)
err := h.Notify("custom/notification", params)Use CallAsync and CancelRequest when testing cancellation behavior:
call, err := h.CallAsync("textDocument/hover", params)
if err != nil {
t.Fatal(err)
}
_ = h.CancelRequest(call.ID())
_, err = call.Wait(ctx)Diagnostics arrive as server-to-client notifications, which are asynchronous. The harness collects them automatically. Use WaitForDiagnostics to block until they arrive:
func TestDiagnosticsOnSave(t *testing.T) {
h := servertest.New(t, newHandler())
h.DidOpen("file:///test.txt", "plaintext", "TODO fix this")
h.DidSave("file:///test.txt")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
diags, err := h.WaitForDiagnostics(ctx, "file:///test.txt")
if err != nil {
t.Fatal(err)
}
if len(diags) != 1 {
t.Fatalf("expected 1 diagnostic, got %d", len(diags))
}
}You can also check what's been collected so far without waiting:
diags := h.Diagnostics("file:///test.txt") // latest for this URI
all := h.AllDiagnostics() // all notifications received
h.ClearDiagnostics() // reset between test stepsThe harness also collects window/showMessage and window/logMessage notifications:
msgs := h.Messages() // []lsp.ShowMessageParams
logs := h.LogMessages() // []lsp.LogMessageParamsWhen the notification is asynchronous, wait for it:
msg, err := h.WaitForMessage(ctx)
log, err := h.WaitForLogMessage(ctx)Handlers that call methods on server.Client can be tested without a real editor. Configure the client response, trigger the server behavior, then inspect the captured request:
h.SetClientResponse("window/showMessageRequest", lsp.MessageActionItem{Title: "OK"})
_, err := h.ExecuteCommand("ask-user", nil)
if err != nil {
t.Fatal(err)
}
req, err := h.WaitForClientRequest(ctx, "window/showMessageRequest")
if err != nil {
t.Fatal(err)
}
// assert on req.Method or req.ParamsThis works for newer client helpers too, such as workspace/configuration, workspace/applyEdit, and dynamic registration:
h.SetClientResponse("workspace/configuration", []map[string]any{{"enabled": true}})
h.SetClientResponse("workspace/applyEdit", lsp.ApplyWorkspaceEditResult{Applied: true})
req, err := h.WaitForClientRequest(ctx, "workspace/configuration")
req, err = h.WaitForClientRequest(ctx, "workspace/applyEdit")
req, err = h.WaitForClientRequest(ctx, "client/registerCapability")Use SetClientError to force an error response, or ClientRequests to inspect everything captured so far.
The repository Makefile exposes the usual local verification commands:
make test # go test -v ./...
make test-race # go test -race ./...
make test-cover # go test -cover ./...
make test-fuzz-document # short document fuzz runFuzz tests live in normal package test files. Ordinary go test ./... runs only their seed cases, while make test-fuzz-document actively fuzzes document position and edit invariants.
The initialize result is stored on the harness:
h := servertest.New(t, &myHandler{})
if h.InitResult.Capabilities.HoverProvider == nil {
t.Fatal("expected hover support")
}Override the default initialize params or pass server options:
h := servertest.New(t, &myHandler{},
servertest.WithInitializeParams(&lsp.InitializeParams{
// custom client capabilities, root URI, etc.
}),
servertest.WithServerOptions(
server.WithLogger(slog.Default()),
server.WithMaxConcurrentRequests(4),
server.WithMethodMiddleware(func(method string, next server.MethodHandlerFunc) server.MethodHandlerFunc {
return next
}),
),
)Testing a handler that flags duplicate keys in .env files:
func TestDuplicateKeys(t *testing.T) {
handler := newEnvHandler()
h := servertest.New(t, handler)
uri := lsp.DocumentURI("file:///test.env")
h.DidOpen(uri, "env", "FOO=1\nBAR=2\nFOO=3")
h.DidSave(uri)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
diags, err := h.WaitForDiagnostics(ctx, uri)
if err != nil {
t.Fatal(err)
}
if len(diags) != 1 {
t.Fatalf("expected 1 duplicate key diagnostic, got %d", len(diags))
}
if diags[0].Message == "" {
t.Fatal("expected diagnostic message")
}
// Hover should show the value
hover, err := h.Hover(uri, 0, 0)
if err != nil {
t.Fatal(err)
}
if hover == nil || !strings.Contains(hover.Contents.Value(), "FOO") {
t.Fatal("expected hover to show key name")
}
}