Increase Test Coverage
SkillDev toolsIncrease Go test coverage to 100% per touched package using a Research → Plan → Implement pipeline, with seams for the branches real input cannot reach. Analyzes coverage gaps, generates table-driven tests with httptest mocks, and validates results with go test -coverprofile. Designed for Go MCP server projects using the official go-sdk and gitlab.com/gitlab-org/api/client-go/v3.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Increase Test Coverage skill
What this skill tells your AI
The instructions your AI receives, as published by jmrplens/gitlab-mcp-server in .github/skills/increase-test-coverage/SKILL.md and read by ahel’s review.
Primary Directive
Systematically increase Go test coverage to 100% per touched package, preexisting code included, using a structured Research → Plan → Implement pipeline; every function that stays below 100% is named in the report with its reason. Generate comprehensive, buildable, passing tests that follow project conventions and use proper mocks for external dependencies.
All tests must:
- Compile and pass on the first run
- Use existing test helpers and patterns found in the codebase
- Mock external dependencies (GitLab API) via
httptest - Follow table-driven test patterns with
t.Run()subtests - Cover happy paths, edge cases, and error scenarios
- Include documentation (doc comment) explaining what each test validates and why
- Be verified against false passes (assertions that never fail)
- Be written in English per project language policy
Execution Context
This skill is designed for the Test Expert agent or any agent tasked with increasing test coverage. It operates on Go codebases that use the standard testing package and net/http/httptest; this project has no testify dependency, so assertions are plain t.Errorf / t.Fatalf.
Pipeline Overview
flowchart TD
start[Increase test coverage]
research[Research\nMeasure coverage and analyze gaps]
plan[Plan\nPrioritize gaps by package]
implement[Implement\nWrite tests per phase]
build[Build\ngolangci-lint and go build]
test[Test\ngo test]
fix[Fix\nRepair errors]
start --> research --> plan --> implement
implement --> build
implement --> test
implement --> fix
fix --> build
fix --> test
Phase 1: Research
Thoroughly analyze the codebase and measure current coverage before writing any tests.
Step 1: Measure Baseline Coverage
Run coverage analysis for the entire project:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
Record the per-package and per-function coverage percentages. This is the baseline.
Step 2: Generate HTML Coverage Report (Optional)
go tool cover -html=coverage.out -o coverage.html
Review the HTML report to visually identify uncovered code blocks.
Step 3: Identify Coverage Gaps
For each package, analyze:
- Uncovered functions — Functions with 0% coverage (highest priority)
- Partially covered functions — Functions below 100% (error paths, edge cases, defensive branches)
- Untested branches — Conditional logic where only one branch is covered
- Error handling paths —
if err != nilblocks that are never tested - Edge cases — Boundary conditions, nil inputs, empty slices, zero values
Step 4: Discover Existing Test Patterns
Before writing any test, analyze the codebase for conventions:
| Convention | Where to Look |
|---|---|
| Test helpers | internal/testutil/helpers.go — testutil.NewTestClient(), testutil.RespondJSON(), testutil.RespondJSONWithPagination(), testutil.AssertRequestPath(), testutil.ForbiddenHandler() |
| Mock patterns | How httptest.NewServer + http.HandlerFunc are used to mock GitLab API |
| Assertion style | Stdlib t.Errorf/t.Fatalf only (no testify) |
| Test naming | Pattern: TestFunctionName_Scenario_ExpectedResult; a _test.go is named after the module it tests (make check-test-file-names) |
| Table-driven tests | Look for tests := []struct{} or cases := map[string]struct{} patterns |
| File placement | Tests in same package (white-box) or _test package (black-box) |
| Pagination helpers | testutil.PaginationHeaders struct and testutil.RespondJSONWithPagination() |
Step 5: Document Research Findings
Create a coverage analysis summary containing:
- Current overall coverage percentage
- Per-package coverage breakdown
- List of uncovered/partially covered functions ranked by priority
- Existing test patterns and helpers available
- Dependencies that need mocking (GitLab API endpoints)
Phase 2: Plan
Create a phased implementation plan that prioritizes maximum coverage impact.
Step 1: Prioritize Packages
Rank packages by coverage gap impact:
| Priority | Criteria |
|---|---|
| P0 — Critical | Core business logic with 0% coverage, main() included |
| P1 — High | Tool handlers, resource handlers below 80% |
| P2 — Medium | Helper functions, config loading below 100%: error paths and edge cases |
| P3 — Last | The remaining defensive branches: real inputs where one exists, seams where none does |
Step 2: Group into Phases
Divide work into 2-5 phases, each targeting a specific package or functional area:
Phase 1: [package] — Current: X% → Target: 100%
- TestFunctionA_HappyPath
- TestFunctionA_ErrorCase
- TestFunctionA_EdgeCase_EmptyInput
...
Phase 2: [package] — Current: X% → Target: 100%
...
Step 3: Define Test Cases per Function
For each uncovered function, specify:
- Happy path — Valid inputs producing expected outputs
- Error cases — Invalid inputs, API failures (4xx, 5xx), network errors
- Edge cases — Empty strings, zero values, nil pointers, large inputs
- Context cancellation — Cancelled or timed-out contexts
- Pagination — Multiple pages, empty results, single page
Step 4: Identify Required Mocks
For each test, document:
- Which GitLab API endpoint needs mocking (method + path)
- Expected request body validation (if any)
- Response status code and JSON payload
- Pagination headers (if applicable)
Step 5: Plan Structure
Present the plan to the user for confirmation before implementation. The plan should include:
- Phase breakdown with package targets
- Estimated number of new test functions per phase
- Expected coverage increase per phase
- New test helpers needed (if any)
- Total estimated coverage after all phases
Phase 3: Implement
Execute the plan phase by phase, validating after each phase.
Step 1: Implement One Phase at a Time
For each phase:
- Read source code — Understand the function signatures, logic branches, and dependencies
- Write test file — Create or extend
*_test.gofiles following existing patterns - Use existing helpers — Reuse
testutil.NewTestClient(),testutil.RespondJSON(),testutil.RespondJSONWithPagination() - Create new helpers if needed — Add to
internal/testutilwhen several packages need them, otherwise keep them unexported in the package's own test file
Step 2: Test Writing Patterns
Table-Driven Tests (Preferred)
func TestFunctionName_Scenarios(t *testing.T) {
tests := []struct {
name string
input InputType
mockStatus int
mockBody string
wantErr bool
want OutputType
}{
{
name: "happy path",
input: InputType{Field: "value"},
mockStatus: http.StatusOK,
mockBody: `{"id": 1, "name": "test"}`,
want: OutputType{ID: 1, Name: "test"},
},
{
name: "not found",
input: InputType{Field: "missing"},
mockStatus: http.StatusNotFound,
mockBody: `{"message": "404 Not found"}`,
wantErr: true,
},
{
name: "empty input",
input: InputType{},
mockStatus: http.StatusBadRequest,
mockBody: `{"error": "field required"}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.RespondJSON(w, tt.mockStatus, tt.mockBody)
}))
got, err := functionUnderTest(context.Background(), client, tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
Request Validation Mocks
When testing POST/PUT operations, validate the request:
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
http.Error(w, "unexpected method", http.StatusBadRequest)
return
}
if r.URL.Path != "/api/v4/projects/42/issues" {
t.Errorf("path = %s, want /api/v4/projects/42/issues", r.URL.Path)
http.Error(w, "unexpected path", http.StatusBadRequest)
return
}
testutil.RespondJSON(w, http.StatusCreated, `{...}`)
}))
Pagination Tests
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.RespondJSONWithPagination(w, http.StatusOK, `[...]`, testutil.PaginationHeaders{
Page: "1",
PerPage: "20",
Total: "50",
TotalPages: "3",
NextPage: "2",
})
}))
Context Cancellation Tests
func TestFunction_ContextCancelled(t *testing.T) {
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.RespondJSON(w, http.StatusOK, `{}`)
}))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := functionUnderTest(ctx, client, input)
if err == nil {
t.Fatal("expected error for cancelled context, got nil")
}
}
Step 3: Validate After Each Phase
After writing tests for a phase:
- Compile:
go build ./... - Static analysis:
make golangci-lint - Run tests:
go test -v ./internal/[package]/... - Measure coverage:
go test -coverprofile=coverage.out ./internal/[package]/... - Check coverage:
go tool cover -func=coverage.out | grep [package] - Mutation-test the package:
make coverage-mutants PKG=./internal/[package] - Check the conditions:
make coverage-conditions PKG=./internal/[package]
Steps 6 and 7 are the ones that decide whether the tests are worth anything, and they are not optional. Coverage says a line ran. It cannot say an assertion would have noticed a change, and a test that asserts only err != nil executes every line of the error path while proving nothing about which error it was. A tranche of tests shipped exactly that way here: 100% of the new lines covered, and an SDK failure before the code under test would have passed them all.
The gate on a package you changed is Lived 0 and Not covered 0. A LIVED mutant is a change to the source no test noticed, so it names a missing assertion; a NOT COVERED one names a line no test reaches. coverage-mutants runs with --invert-logical, so && and || are checked for independence too.
coverage-conditions reports the boolean conditions never evaluated both ways, counting each operand separately. Every line it names is a test case you have not written.
If tests fail:
- Fix compilation errors immediately
- Adjust mock responses to match actual API behavior
- Verify test expectations match function implementation
Step 4: Track Progress
After each phase, update progress:
Package Before After Target Status
internal/tools 72% 100% 100% ✅ Done
internal/gitlab 65% — 100% 🔄 In Progress
internal/config 80% — 100% ⏳ Pending
internal/resources 70% — 100% ⏳ Pending
internal/prompts 68% — 100% ⏳ Pending
Step 5: Final Validation
After all phases complete:
- Run full test suite:
go test -race -coverprofile=coverage.out ./... - Generate final coverage report:
go tool cover -func=coverage.out - Verify every touched package is at 100%, or name each function below it with its reason
- Run quality checks:
make golangci-lint,make check-test-subtests,make check-test-goroutines,make check-test-file-names - Refresh
docs/development/testing/testing.md:go run ./cmd/gen_testing_docs/ - Verify the generated testing reference:
go run ./cmd/gen_testing_docs/ --check - Lint the generated testing reference:
npx markdownlint-cli2 docs/development/testing/testing.md - Clean up any temporary files
Test Quality Standards
What to Test
| Category | Examples |
|---|---|
| Happy path | Valid inputs → expected output |
| Error responses | 400, 401, 403, 404, 422, 500 from GitLab API |
| Empty results | Empty arrays, null fields, missing optional fields |
| Input validation | Required fields missing, invalid IDs, empty strings |
| Pagination | First page, last page, single page, many pages |
| Context | Cancelled context, timed-out context |
| Edge cases | Very long strings, special characters, unicode, zero values |
What NOT to Test
- Third-party library internals (GitLab client SDK, MCP SDK)
- Trivial getters/setters with no logic
- Generated code (JSON schema tags, struct definitions)
- The Go standard library itself
Test Quality Checklist
- Tests are independent — no shared mutable state between tests
- Tests are deterministic — same result every run
- Test names describe behavior:
TestFunction_Scenario_ExpectedResult - Each test has clear Arrange-Act-Assert sections
- Mock responses match real GitLab API response formats
- Error messages include context:
"functionName() expected error, got nil" -
t.Helper()is called in all test helper functions -
t.Cleanup()is used for resource cleanup - No hardcoded sleep or timing dependencies
- Tests run fast (< 1 second per test function)
Coverage Targets
The house rule: a package touched by a change is driven to 100% statement coverage, preexisting code included. Every function that stays below 100% is named in the report with the reason, and "defensive branch" is not a reason until a seam has been tried.
| Scope | Target |
|---|---|
| Every package the change touches | 100% |
| The repository as a whole (the CI gate) | 90% minimum; a floor, never the goal |
Reaching the last branches
-
Real inputs first. A crafted fixture, a specific declaration shape, a file that does not parse, a broken symlink: prefer any of these over a seam.
-
Seams for what a real input cannot reach. A package-level function variable defaulting to the standard-library call, overridden in the test and restored with
t.Cleanup:var ( walkDir = filepath.WalkDir formatSource = format.Source writeFile = os.WriteFile )Established in
cmd/godoc_tool/docgo.goandcmd/gen_stats/main.go. Each seam carries a short doc comment naming the branch it exists for. Wrap a helper rather than aliasingos.WriteFiledirectly when gosec's taint analysis would otherwise re-home a finding onto a test file. -
Tests run as root. Permission bits make nothing fail. A read that must fail even for root uses a broken symlink (
os.Symlinkto a missing target); a write that must fail goes through a seam. -
main()is covered, not exempt. ExtractrunMain(args []string, stdout, stderr io.Writer) int, makemain()the one lineosExit(runMain(os.Args, os.Stdout, os.Stderr))withvar osExit = os.Exit, and assert every exit code and message. Replaceos.Argsin the test so the flag set parses no test flags. -
Never lift the number by other means. No weakened assertions, no
//nolint, no coverage pragmas, no branches deleted to make the figure. A provably dead branch is removed as a code change with its own justification, or made reachable by extracting it into a function a test can call directly.
Case Completeness: Conditions and Mutants
Statement coverage says a line ran. It does not say a decision was taken both ways, and it does not say a test would notice the decision changing. Measured on this repository: cmd/gen_stats at 100% statement coverage still had ten conditions that were only ever true or only ever false, and five mutants no test reached. Go's own coverage cannot see either, because a compound if a && b is one block to it. Three steps close the gap, and a changed package passes all three before the work is done.
1. Derive the cases before writing them
For every decision in the changed code, write the case table first, then the tests that fill it:
- A simple condition (
if n > 0): one case true, one false, and for a comparison the boundary itself and both neighbours (0,1,-1). - A compound condition (
a && b,a || b,!a, and their nestings): the MC/DC minimal set, N+1 cases for N conditions, each condition flipping the outcome on its own while the others hold. Fora && b: (T,T) is the true case, (F,T) and (T,F) each falsify it through one condition. Fora || b: (F,F) is the false case, (T,F) and (F,T) each satisfy it through one condition. Longer chains compose the same way, and short-circuit order decides which operand is even evaluated. - Every
if err != nil: one case where the error happens, driven by a real failure (a fixture that does not parse, a broken symlink, a refused connection, a cancelled context) or by a seam. - Every
switchand type switch: one case per arm and one for the default, including an arm no real input reaches, which is extracted into a function a test can call directly. - Loops: zero iterations, one, several; and each
continue,breakand earlyreturninside. - Inputs: empty, whitespace, the boundary length, unicode, the maximum, nil and the zero value; first, middle, last and empty pages; the context cancelled before and during the call.
2. Measure the conditions with gobco
gobco instruments every boolean condition, &&, || and ! operands included, and reports the ones never evaluated both ways:
make coverage-conditions PKG=./cmd/gen_stats
# Condition coverage: 156/166
# main.go:177:7: condition "lines > s.LargestTestLines" was 9 times true but never false
# main.go:355:17: condition "isE2E" was 29 times false but never true
Every reported line is a missing case from the table in step 1. The target is nothing reported. A condition that genuinely cannot take the other value is dead code: remove it as a code change, or extract it so a test can reach it; do not leave it as an accepted exception. gobco does not report a function never called at all (statement coverage does) and does not instrument select.
3. Prove the cases with mutation testing
A case can execute a decision and still not check it. gremlins rewrites one operator at a time (> to >=, == to !=, && to ||, + to -, -x to x, i++ to i--) and reruns the tests; a mutant that survives is a decision no assertion pins:
make coverage-mutants PKG=./cmd/gen_stats
# Killed: 108, Lived: 0, Not covered: 12
# Test efficacy: 100.00% Mutator coverage: 90.00%
The gate on a changed package is Lived: 0 and Not covered: 0. A lived mutant is fixed by strengthening the assertion that should have caught it, never by excluding the mutant. The not-covered mutants sit on the same lines gobco reports, so step 2 usually clears them. The target turns on INVERT_LOGICAL (&& to ||, off by gremlins' default), which is the operator that proves each operand of a compound condition matters on its own; GREMLINS_FLAGS passes anything else, such as -S l to print only the lived mutants or -E to exclude generated files. A package behind a build tag passes it there as --tags (GREMLINS_FLAGS='--tags e2e'): the recipe runs its timed baseline under the same tags, and refuses a package with no test file under the tags it was given, unless it is an importable package measured in place and GREMLINS_FLAGS asks for an integration run with a -coverpkg that names it; a package main, or one measured through a staged copy, is refused whatever the flags say.
Both tools run through go run with the version pinned in the Makefile; go install github.com/rillig/gobco@v1.3.4 and go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 keep them on the path for repeated use. Neither runs in CI: they are the last two steps of the coverage work on a package, and their results go in the pull request beside the coverage figure.
Troubleshooting
Tests don't compile
- Check import paths match the module path in
go.mod - Verify test file is in the correct package (same as source file)
- Ensure mock response JSON matches expected struct field names
- Run
make golangci-lintfor quick diagnostics
Tests fail unexpectedly
- Print actual vs expected values with
%+vformatting - Check mock handler serves the correct URL path and method
- Verify GitLab API response format (field names are snake_case in JSON)
- Check if function under test modifies input struct
Coverage doesn't increase
- Ensure test exercises the uncovered code path (check HTML report)
- Look for unreachable code or dead branches
- Some error paths require specific mock setups (e.g., network errors)
- Test both
ifandelsebranches of conditionals
Race conditions detected
- Use
go test -race ./...to detect data races - Ensure tests don't share mutable state
- Use
t.Parallel()carefully — only when tests are truly independent - Check that test helpers don't store state across calls
Assertions off the test goroutine (MANDATORY)
Never call t.Fatal/t.Fatalf/t.FailNow inside an httptest handler, a
go statement, an errgroup task, or an MCP tool handler — it kills only that
goroutine and truncates the response. Follow the six-rule contract in
.github/instructions/test-goroutines.instructions.md (t.Errorf + response +
return, or record with atomics and assert on the test goroutine). Verify with
make check-test-goroutines.
Case loops and file names (gated in CI)
- Every case table runs under
t.Run: a range over a slice or map literal that asserts must open one subtest per case, named by thenamefield, the string element, or the map key.go run ./cmd/audit_test_subtests/ -fixrewrites the unambiguous shapes;// sequential: <reason>on the line above a loop declares dependent steps rather than cases.make check-test-subtestsgates it. - A
_test.gofile exists only under the name of a module it tests (branches.gohasbranches_test.go,merge_requests.gohasmerge_requests_test.go);make check-test-file-namesgates it. - Refresh the generated testing reference when done:
go run ./cmd/gen_testing_docs/, thengo run ./cmd/gen_testing_docs/ --check.
Signals
- GitHub stars
- 39
- Forks
- 5
- Last commit
- Sep 2026
ahel review
K1binfo
installs-packages
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
increase-test-coverage- Source
- github.com/jmrplens/gitlab-mcp-server