
Test Coverage
- 20 installs
- 15 repo stars
- Updated August 1, 2026
- connorads/dotfiles
Audits test-coverage gaps, writes targeted tests across TypeScript/Python/Go/Rust, and wires coverage thresholds into CI and pre-commit hooks.
About
Audits, improves, and enforces test coverage in any repository by finding gaps, writing targeted tests, and wiring coverage thresholds into CI and hooks. A developer uses it to improve coverage, add missing tests, or set up coverage enforcement.
- Audits gaps and writes targeted tests across TypeScript, Python, Go, Rust, and more
- Wires coverage thresholds into CI and hooks, treating coverage as a regression gate
Test Coverage by the numbers
- 20 all-time installs (skills.sh)
- Ranked #1,434 of 2,155 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/connorads/dotfiles --skill test-coverageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 15 |
| Last updated | August 1, 2026 |
| Repository | connorads/dotfiles ↗ |
What it does
Audits test-coverage gaps, writes targeted tests across TypeScript/Python/Go/Rust, and wires coverage thresholds into CI and pre-commit hooks.
Files
Test Coverage
Audit gaps, write targeted tests, enforce thresholds — across any ecosystem.
Mental Model
The testing pyramid encodes an economic truth: each tier tests what only it can test.
| Tier | Tests | Cost to write | Cost to run |
|---|---|---|---|
| Unit | Pure functions, domain logic, validation, parsing | Low | Milliseconds |
| Integration | Database queries, API boundaries, access control, service interactions | Medium | Seconds |
| Component | Rendered UI in a real browser, user interactions, visual states | Medium | Seconds |
| E2E | Full user flows across the entire stack | High | Minutes |
Coverage is a regression gate, not a quality metric. High coverage with bad tests is worse than moderate coverage with good tests. The goal is: new code cannot silently skip tests.
Exclusions are architecture, not exceptions. Every exclusion documents a deliberate decision about where code is tested. An exclusion at one tier should have coverage at another.
Decision Tree
Start here. Follow the branch that matches the current state.
Is there any coverage tooling configured?
├── No → Bootstrap (below)
└── Yes
├── Coverage below target? → Audit & Improve (below)
├── Coverage adequate but not enforced? → Enforce (below)
└── Coverage enforced, writing new code? → Write Tests for New Code (below)Bootstrap: Setting Up Coverage from Scratch
1. Detect the ecosystem
Check for project markers: package.json, go.mod, Cargo.toml, pyproject.toml, setup.py, *.csproj. See ecosystem patterns for tool recommendations per language.
2. Create tiered configs
Each test tier gets its own configuration file with targeted include/exclude patterns. This prevents slow integration tests from blocking fast unit test feedback.
Key principles:
- Each tier has a separate
includepattern matching only its source files - Each tier has a separate coverage output directory (avoids conflicts)
- CI vs local reporter selection: text-summary locally, full HTML/JSON/LCOV in CI
TypeScript/Vitest example structure:
vitest.unit.config.mts → tests/unit/**/*.unit.spec.ts → coverage/unit/
vitest.int.config.mts → tests/int/**/*.int.spec.ts → coverage/int/
vitest.browser.config.mts → tests/components/**/*.spec.tsx → coverage/components/Python example:
pytest -m unit --cov --cov-report=html:coverage/unit
pytest -m integration --cov --cov-report=html:coverage/int3. Set initial thresholds
Run coverage once, note the baseline. Set thresholds at the current level — this prevents regression while you improve.
# Example: start where you are
thresholds: { lines: 72 } # measured baselineThen ratchet up as you add tests. Never ratchet down. See enforcement for the full ratcheting strategy.
4. Add coverage scripts
Create per-tier scripts in your project manifest:
{
"test:unit": "vitest run --config ./vitest.unit.config.mts",
"test:unit:coverage": "vitest run --coverage --config ./vitest.unit.config.mts",
"test:int": "vitest run --config ./vitest.int.config.mts",
"test:int:coverage": "vitest run --coverage --config ./vitest.int.config.mts",
"test:components": "vitest run --coverage --config ./vitest.browser.config.mts",
"test:e2e": "playwright test",
"test": "pnpm test:unit && pnpm test:int && pnpm test:components && pnpm test:e2e"
}Audit & Improve: Closing Coverage Gaps
Phase 1: Audit
Run coverage for each tier and examine the output.
# Run with coverage, examine the HTML report or text output
<runner> --coverageIdentify three categories:
- Untested files — no coverage at all (highest priority)
- Untested branches — code paths never exercised
- Untested functions — declared but never called in tests
Phase 2: Classify each gap
For every uncovered file or function, ask:
| Question | If yes | If no |
|---|---|---|
| Business logic or domain rules? | Unit tests (highest priority) | Continue |
| Access control or authorisation? | Integration tests | Continue |
| Data validation or parsing? | Unit tests | Continue |
| API endpoint or mutation? | Integration tests | Continue |
| UI component with logic? | Component tests | Continue |
| Full user flow? | E2E tests | Continue |
| Can it run in the test environment? | Write tests | Document exclusion |
| Auto-generated code? | Exclude with comment | Write tests |
| Thin wrapper around tested library? | Consider excluding | Write tests |
Phase 3: Prioritise
Triage order (highest value first):
1. Domain logic and business rules (unit) 2. Access control and authorisation (integration) 3. Data validation and input parsing (unit) 4. API endpoints and mutations (integration) 5. UI components with conditional logic (component) 6. Async/server-rendered components (E2E) 7. Configuration and wiring (tested implicitly by higher tiers)
Phase 4: Write tests
For each gap, follow the appropriate tier's patterns. Test expected behaviour through the public API, not implementation details.
Unit tests: Pure input → output. No database, no network, no filesystem.
describe('slugify', () => {
it('converts spaces to hyphens', () => {
expect(slugify('hello world')).toBe('hello-world')
})
it('handles empty string', () => {
expect(slugify('')).toBe('')
})
})Integration tests: Real database, real service boundaries, no mocks for things you own.
it('enforces access control on draft posts', async () => {
const result = await payload.find({
collection: 'posts',
where: { _status: { equals: 'draft' } },
overrideAccess: false,
user: anonymousUser,
})
expect(result.docs).toHaveLength(0)
})Component tests: Real browser, real DOM queries (accessibility-first via testing-library).
it('renders film title and year', () => {
render(<FilmCard film={mockFilm} />)
expect(screen.getByText('Film Title')).toBeInTheDocument()
expect(screen.getByText('2024')).toBeInTheDocument()
})E2E tests: Full user flows, real navigation, real network.
test('user can submit a form', async ({ page }) => {
await page.goto('/submit')
await page.fill('[name="title"]', 'My Film')
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/confirmation/)
})See ecosystem patterns for language-specific runner syntax and config examples.
Enforce: Wiring Coverage into Hooks and CI
Pre-commit (composes with hk)
If using the hk skill, add coverage test steps to hk.pkl:
["test-unit"] {
check = "scripts/quiet-on-success.sh pnpm test:unit:coverage"
}
["test-int"] {
check = "scripts/quiet-on-success.sh pnpm test:int:coverage"
depends = List("test-unit")
}Key principles:
- Coverage thresholds live in the test config, not in hook config
- E2E tests are too slow for pre-commit — run in CI or manually
- Order tiers by speed: unit first (fastest fail), then integration, then components
- Wrap in quiet-on-success so passing tests produce no output
CI
Run all tiers with coverage in CI. Upload per-tier reports separately for visibility.
- name: Unit tests
run: pnpm test:unit:coverage
- name: Integration tests
run: pnpm test:int:coverage
- name: E2E tests
run: pnpm test:e2eRatcheting
For projects not yet at target:
1. Measure current coverage 2. Set threshold at current level 3. After each improvement, bump the threshold 4. Never lower it
See enforcement for detailed CI patterns, PR checks, and ratcheting workflow.
Write Tests for New Code
When adding features to a codebase with established coverage:
1. Identify the tier: What kind of code are you writing? Match to the classification table above 2. Write tests first (TDD): Test the expected behaviour before implementing 3. Run coverage locally: --coverage for the relevant tier 4. Handle exclusions: If code genuinely cannot be tested at this tier, document why and ensure coverage exists at another tier 5. Verify thresholds pass: Pre-commit hooks catch regressions, but check early
Cross-tier exclusion pattern
Every exclusion at one tier names the tier that provides coverage:
// Unit config excludes:
// Cross-tier: Service layer - requires database runtime - tested via integration tests
"src/domain/**/service.ts",
// Integration config excludes:
// Cross-tier: React components - requires browser context - tested via component + E2E tests
"src/components/**",See coverage exclusions for the full exclusion taxonomy and documentation format.
Test Organisation Patterns
Directory structure
tests/
unit/ *.unit.spec.ts Pure functions, domain logic
int/ *.int.spec.ts Database, API, access control
components/ *.browser.spec.tsx Rendered UI in real browser
e2e/ *.e2e.spec.ts Full user flows
fixtures/ index.ts Shared test data factories
setup/ Per-tier setup files (DB init, browser cleanup)Naming conventions
Suffix encodes the tier — config include patterns use these suffixes for zero-ambiguity matching:
| Tier | Suffix | Example |
|---|---|---|
| Unit | .unit.spec.ts | slugify.unit.spec.ts |
| Integration | .int.spec.ts | films.int.spec.ts |
| Component | .browser.spec.tsx | FilmCard.browser.spec.tsx |
| E2E | .e2e.spec.ts | auth.e2e.spec.ts |
Test data factories
Use factory functions with auto-incrementing counters for unique identifiers:
let counter = 0
function createTestUser(overrides = {}) {
counter++
return {
email: `test-${counter}@example.com`,
name: `Test User ${counter}`,
...overrides,
}
}Counter-based (not random) for deterministic debugging. Reset between test runs if needed.
Mock boundaries
- Do mock: External APIs, third-party SDKs, environment-specific runtimes
- Do not mock: Code you own — test through the public API
- Database: Use a real local database for integration tests (SQLite, test containers)
- Browser: Use a real browser for component tests (Playwright, Vitest browser mode)
- Server-side imports: Stub server-only modules when testing in browser context
Coverage Providers: Quick Reference
| Provider | Environment | When to use | Limitations |
|---|---|---|---|
| v8 | Node.js | Unit, integration tests | Not supported in browser mode |
| Istanbul | Browser | Component tests | Ignore comments may not survive bundling |
| c8 | Node.js CLI | Standalone v8 wrapper | Alternative to built-in coverage |
| coverage.py | Python | All tiers via pytest-cov | Requires source mapping for packages |
| go cover | Go | Built-in, all tiers | Per-package profiles need merging |
| tarpaulin | Rust | Cargo integration | May miss some async code paths |
| llvm-cov | Rust | Higher accuracy | Requires nightly or specific toolchain |
| lcov | Any | Merging multi-tier reports | Format standard, not a provider |
Gotchas
| Issue | Fix |
|---|---|
| v8 undercounts arrow functions | Lower functions threshold or restructure code |
| Istanbul ignore comments stripped by bundler | Use file-level exclusions in config instead |
| Concurrent DB writes in integration tests | Disable parallelism, use single worker |
| Coverage directories conflict across tiers | Separate reportsDirectory per tier config |
| E2E tests too slow for pre-commit | Run in CI only; document in project README |
| Ignore comment used without justification | Always add a reason after the ignore directive |
| Coverage passes but tests are meaningless | Review test quality, not just the metric |
| New file added with no tests | Threshold regression catches it at commit time |
| Browser tests import server-only code | Create stub modules, alias in browser config |
| Flaky tests in pre-commit hooks | Investigate root cause; do not retry or skip |
References
- Ecosystem Patterns — Index of per-language references:
- TypeScript/JS | Python | Go | Rust | Merging
- Coverage Exclusions — How to document and justify every exclusion
- Enforcement — Wiring coverage into hk hooks, CI pipelines, and PR checks
Coverage Exclusions: Documentation and Justification
Every exclusion is a claim: "this code is tested elsewhere or genuinely untestable." Every claim needs evidence. Undocumented exclusions are technical debt.
Exclusion Categories
1. Runtime-incompatible code
Code requiring a specific runtime not available in the test environment (e.g. Cloudflare Workers, browser APIs, iOS native, edge runtime).
exclude: [
// Runtime: Cloudflare middleware - requires workerd runtime - tested via E2E on preview deploys
'src/middleware.ts',
// Runtime: Server-only auth utilities - requires next/headers - tested via E2E
'src/utilities/auth.ts',
]Justification test: Can this code physically execute in the test harness? If not, this is a valid exclusion.
2. Auto-generated code
Migrations, type definitions, codegen output, ORM-generated files.
exclude: [
// Generated: Database migrations - auto-generated SQL, schema tested via integration tests
'src/migrations/**',
// Generated: Type definitions - auto-generated from schema, no runtime logic
'src/payload-types.ts',
]Justification test: Was this file created by a tool, not a human? Does modifying it manually get overwritten on next generation?
3. Framework configuration
Entry points, config files that wire dependencies together but contain no business logic.
exclude: [
// Config: Framework wiring - tested implicitly via collection integration tests
'src/payload.config.ts',
// Config: Next.js app layout - structural wrapper, no logic
'src/app/layout.tsx',
]Justification test: Does this file only compose other modules without conditional logic? If you removed it, would integration tests fail (proving implicit coverage)?
4. Cross-tier delegation
Code excluded from one tier because it is covered at a different tier. This is the most common and most important category.
// Unit config excludes:
exclude: [
// Cross-tier: Service layer - requires database runtime - tested via integration tests
'src/domain/**/service.ts',
]
// Integration config excludes:
exclude: [
// Cross-tier: React components - requires browser context - tested via component + E2E tests
'src/components/**',
// Cross-tier: Server actions - requires Next.js runtime - tested via E2E
'src/actions/**',
]Justification test: Is there a specific test file at the other tier that exercises this code? Can you name it?
5. Third-party wrappers
Thin wrappers around well-tested libraries that add no custom logic.
exclude: [
// Upstream: shadcn/ui components - well-tested library, no custom logic added
'src/components/ui/button.tsx',
'src/components/ui/input.tsx',
// Upstream: cn() utility - re-export of clsx + tailwind-merge
'src/utilities/cn.ts',
]Justification test: Does this file add conditional logic beyond the library's API? If yes, it needs tests. If it's a pure re-export or thin config wrapper, exclusion is valid.
6. Async server components
Framework-specific: components that fetch data at render time and cannot be rendered in a unit/component test environment.
exclude: [
// Async: Server component - uses await getPayload() at render time
// Cannot render in Vitest browser mode - coverage via E2E tests for /films page
'src/components/blocks/FilmGridBlock.tsx',
// Async: Layout with auth check - reads cookies at render time - E2E tests
'src/app/(frontend)/layout.tsx',
]Justification test: Does this component use async/await at the top level or call server-only APIs during render? Can it be split into an async data-fetching shell and a pure presentational component (which can be tested)?
Exclusion Comment Format
Use a consistent format for all exclusion comments:
// <Category>: <What it is> - <Why untestable here> - <Where tested instead>Examples:
// Runtime: Cloudflare middleware - requires workerd - E2E on preview deploys
// Generated: Payload types - auto-generated, no logic - implicit via int tests
// Cross-tier: React components - browser context needed - component + E2E tests
// Upstream: shadcn/ui Button - no custom logic - tested in library
// Async: FilmGridBlock - server component with data fetch - E2E /films page
// Config: payload.config.ts - framework wiring - implicit via collection int testsThis format enables automated auditing:
# List all exclusion comments across config files
grep -rn "// Runtime:\|// Generated:\|// Cross-tier:\|// Upstream:\|// Async:\|// Config:" \
vitest.*.config.* jest.config.* pyproject.tomlInline Ignore Comments
For individual lines of genuinely unreachable defensive code.
v8
/* v8 ignore next */
const fallback = value ?? 'default' // defensive: value always defined after initIstanbul
/* istanbul ignore next -- defensive null check, ref always set after mount */
if (!ref.current) returnPython
if TYPE_CHECKING: # pragma: no cover
from typing import Protocol
# pragma: no cover — defensive branch, enum is exhaustive
raise AssertionError(f"Unexpected status: {status}")Rust
// tarpaulin: skip next line — defensive unwrap, value always Some after builder
let value = optional.unwrap();Rules for inline ignores
1. Always include a justification after the ignore directive — explain why the code is unreachable 2. Prefer restructuring to eliminate the unreachable branch (e.g. use exhaustive pattern matching) 3. Review during coverage audits — ignored lines may become testable after refactoring 4. Never ignore entire functions — if a function needs ignoring, it likely belongs in an exclusion category above 5. Count them — a codebase with many inline ignores has a smell; investigate patterns
Auditing Exclusions
Run this audit periodically (quarterly or when coverage config changes).
Step 1: List all exclusions
# Config-level exclusions
grep -A 1 "exclude" vitest.*.config.* jest.config.* pyproject.toml .coveragerc
# Inline ignores
grep -rn "v8 ignore\|istanbul ignore\|pragma: no cover\|tarpaulin" src/Step 2: Verify each justification
For each exclusion, confirm:
- [ ] The category comment is present and accurate
- [ ] The "why untestable" reason is still true
- [ ] The "where tested instead" tier actually has tests for this code
- [ ] No code changes have made the exclusion unnecessary
Step 3: Check for orphaned exclusions
- [ ] Are there excluded paths that no longer exist? (stale exclusions)
- [ ] Are there new files matching excluded patterns that should have tests?
- [ ] Have any excluded files gained business logic that wasn't there before?
Step 4: Review inline ignores
- [ ] Can any ignored branches be eliminated by restructuring?
- [ ] Are justifications still accurate?
- [ ] Has the count of inline ignores grown? If so, investigate the pattern
Step 5: Cross-reference tiers
For every cross-tier exclusion, verify the claim:
# Example: "src/components/** excluded from integration tests, tested via component tests"
# Verify component tests exist for these files:
ls tests/components/
# Compare against excluded component files to find gapsA cross-tier exclusion without corresponding tests at the claimed tier is a coverage gap hiding behind a comment.
Ecosystem-Specific Coverage Patterns
Language-specific tools, configuration examples, and runner syntax. Each ecosystem has its own reference file — load only the one you need.
| Ecosystem | File | Key tools |
|---|---|---|
| TypeScript / JavaScript | ecosystems/typescript.md | Vitest, Jest, Playwright, testing-library, v8, Istanbul |
| Python | ecosystems/python.md | pytest-cov, coverage.py, hypothesis |
| Go | ecosystems/go.md | go test -cover, gotestsum, build tags |
| Rust | ecosystems/rust.md | cargo-tarpaulin, cargo-llvm-cov, proptest |
| Merging reports | ecosystems/merging.md | lcov, codecov flags, coveralls, gocovmerge |
Quick ecosystem detection
| Marker file | Ecosystem |
|---|---|
package.json | TypeScript / JavaScript |
pyproject.toml, setup.py, setup.cfg | Python |
go.mod | Go |
Cargo.toml | Rust |
*.csproj, *.sln | C# / .NET |
build.gradle, pom.xml | Java / Kotlin |
Detect the marker, then load the corresponding ecosystem reference.
Go Coverage Patterns
Built-in coverage
# Run tests with coverage profile
go test -coverprofile=coverage/unit.out ./...
# View coverage in terminal
go tool cover -func=coverage/unit.out
# Generate HTML report
go tool cover -html=coverage/unit.out -o coverage/unit.html
# Check total coverage
go tool cover -func=coverage/unit.out | grep total:Per-package thresholds
#!/usr/bin/env bash
# scripts/check-coverage.sh
THRESHOLD=90
COVERAGE=$(go test -coverprofile=coverage.out ./... 2>&1 | grep -oP 'coverage: \K[0-9.]+')
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "Coverage $COVERAGE% below threshold $THRESHOLD%"
exit 1
fiIntegration tests with build tags
//go:build integration
package store_test
import (
"testing"
"database/sql"
)
func TestCreateUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// ...
}# Run only integration tests
go test -tags=integration -coverprofile=coverage/int.out ./...
# Run only unit tests (default, no build tag)
go test -coverprofile=coverage/unit.out ./...gotestsum for better output
# Install
go install gotest.tools/gotestsum@latest
# Run with structured output
gotestsum --format=short -- -coverprofile=coverage.out ./...Table-driven tests (idiomatic Go coverage)
func TestSlugify(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"spaces to hyphens", "hello world", "hello-world"},
{"already clean", "hello", "hello"},
{"empty string", "", ""},
{"special chars", "hello!@#world", "helloworld"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slugify(tt.input)
if got != tt.want {
t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}Profile merging
# gocovmerge for multiple profiles
go install github.com/wadey/gocovmerge@latest
gocovmerge coverage/unit.out coverage/int.out > coverage/merged.out
go tool cover -html=coverage/merged.out -o coverage/merged.htmlMerging Multi-Tier Coverage
Techniques for combining coverage reports from multiple test tiers into a unified view.
lcov (cross-language standard)
# Merge multiple lcov files
lcov \
-a coverage/unit/lcov.info \
-a coverage/int/lcov.info \
-a coverage/components/lcov.info \
-o coverage/merged.info
# Generate combined HTML report
genhtml coverage/merged.info --output-directory coverage/mergedCodecov (per-tier flags)
Upload each tier separately with flags for independent tracking:
codecov --flags unit --file coverage/unit/lcov.info
codecov --flags integration --file coverage/int/lcov.info
codecov --flags components --file coverage/components/lcov.infoCoveralls
# Multiple files in one upload
coveralls-lcov \
--merge coverage/unit/lcov.info \
--merge coverage/int/lcov.info \
coverage/components/lcov.infoGo profile merging
# gocovmerge for multiple profiles
go install github.com/wadey/gocovmerge@latest
gocovmerge coverage/unit.out coverage/int.out > coverage/merged.out
go tool cover -html=coverage/merged.out -o coverage/merged.htmlPython Coverage Patterns
Tools
| Tool | Purpose |
|---|---|
| pytest | Test runner |
| pytest-cov | Coverage plugin (wraps coverage.py) |
| coverage.py | Underlying coverage engine |
| hypothesis | Property-based testing |
Configuration (pyproject.toml)
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"unit: Pure logic tests",
"integration: Database and service boundary tests",
"e2e: End-to-end tests",
]
[tool.coverage.run]
source = ["src"]
omit = [
"src/migrations/*",
"src/**/generated/*",
"src/conftest.py",
]
[tool.coverage.report]
fail_under = 100
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@overload",
"\\.\\.\\.", # ellipsis in protocol/abstract methods
]
show_missing = trueRunning per tier
# Unit tests with coverage
pytest -m unit --cov --cov-report=html:coverage/unit --cov-report=term-missing
# Integration tests with coverage
pytest -m integration --cov --cov-report=html:coverage/int --cov-report=term-missing
# All tests
pytest --cov --cov-report=html:coverage/allFixtures and factories
# tests/conftest.py
import pytest
from itertools import count
_counter = count(1)
@pytest.fixture
def make_user():
def _make(**overrides):
n = next(_counter)
defaults = {"email": f"test-{n}@example.com", "name": f"User {n}"}
return {**defaults, **overrides}
return _makeProperty-based testing with hypothesis
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=100))
def test_slugify_never_produces_empty_for_nonempty_input(s):
result = slugify(s)
# Slugify should always produce something for non-empty input
# (or raise ValueError for truly un-slugifiable content)
assert isinstance(result, str)Rust Coverage Patterns
cargo-tarpaulin
# Install
cargo install cargo-tarpaulin
# Run with HTML report
cargo tarpaulin --out html --output-dir coverage/
# Enforce threshold
cargo tarpaulin --fail-under 90
# Exclude specific files
cargo tarpaulin --exclude-files "src/generated/*" --exclude-files "src/migrations/*"cargo-llvm-cov (higher accuracy)
# Install
cargo install cargo-llvm-cov
# Run with HTML report
cargo llvm-cov --html --output-dir coverage/
# Enforce threshold
cargo llvm-cov --fail-under-lines 95
# Show uncovered lines
cargo llvm-cov --textExcluding code from coverage
// Exclude from tarpaulin
#[cfg(not(tarpaulin_include))]
fn platform_specific_code() {
// Only runs on specific OS — tested via integration tests on CI matrix
}
// Exclude a single line (tarpaulin)
// tarpaulin: skip next line — defensive unwrap, value always Some after init
let value = optional.unwrap();Property testing with proptest
use proptest::prelude::*;
proptest! {
#[test]
fn slugify_never_panics(s in "\\PC{1,100}") {
let _ = slugify(&s); // should never panic
}
#[test]
fn slugify_output_is_lowercase(s in "[a-zA-Z ]{1,50}") {
let result = slugify(&s);
assert_eq!(result, result.to_lowercase());
}
}Integration tests (separate binary)
// tests/integration/db_test.rs
use my_crate::db::Repository;
#[tokio::test]
async fn test_create_and_fetch_user() {
let repo = Repository::new_test().await;
let user = repo.create_user("test@example.com").await.unwrap();
let fetched = repo.get_user(user.id).await.unwrap();
assert_eq!(fetched.email, "test@example.com");
}TypeScript / JavaScript Coverage Patterns
Test runners and coverage providers
| Runner | Coverage provider | Config file |
|---|---|---|
| Vitest | v8 (node), Istanbul (browser) | vitest.*.config.mts |
| Jest | v8 or Istanbul | jest.config.ts |
| Playwright | Istanbul (via fixtures) | playwright.config.ts |
Vitest: Unit config
// vitest.unit.config.mts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/unit/**/*.unit.spec.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: process.env.CI
? ['text', 'html', 'json', 'lcov']
: ['text-summary', 'html', 'json', 'lcov'],
reportsDirectory: './coverage/unit',
include: [
'src/utilities/**/*.ts',
'src/domain/**/*.ts',
],
exclude: [
// Cross-tier: Service layer - requires runtime - tested via integration tests
'src/domain/**/service.ts',
// Runtime: Server-only utilities - requires next/headers - tested via E2E
'src/utilities/auth.ts',
// Generated: Type definitions - auto-generated, no logic
'src/**/*.d.ts',
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
})Vitest: Integration config
// vitest.int.config.mts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/int/**/*.int.spec.ts'],
setupFiles: ['tests/setup/node.setup.ts'],
fileParallelism: false, // databases don't handle concurrent writes
pool: 'forks',
poolOptions: { forks: { maxWorkers: 1 } },
testTimeout: 30_000, // DB init can be slow
coverage: {
provider: 'v8',
reporter: process.env.CI
? ['text', 'html', 'json', 'lcov']
: ['text-summary', 'html', 'json', 'lcov'],
reportsDirectory: './coverage/int',
include: ['src/**/*.{ts,tsx}'],
exclude: [
// Cross-tier: React components - requires browser - component + E2E tests
'src/components/**',
// Generated: Database migrations - auto-generated SQL
'src/migrations/**',
// Runtime: Server actions - requires Next.js runtime - E2E tests
'src/actions/**',
// Config: Framework wiring - tested implicitly via collection tests
'src/payload.config.ts',
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
})Vitest: Browser / component config
// vitest.browser.config.mts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/components/**/*.browser.spec.tsx'],
setupFiles: ['tests/setup/browser.setup.ts'],
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
coverage: {
provider: 'istanbul', // v8 not supported in browser mode
reporter: ['text-summary', 'html', 'json', 'lcov'],
reportsDirectory: './coverage/components',
include: ['src/components/**/*.tsx'],
exclude: [
// Async: Server components - cannot render in browser - E2E tests
'**/ServerComponent.tsx',
// Upstream: UI library components - tested by library maintainers
'**/ui/button.tsx',
'**/ui/input.tsx',
],
thresholds: {
// Istanbul in browser mode has inaccurate branch/function counting
// Lines at 100% proves execution; statement/branch pragmatically lower
statements: 97,
branches: 88,
functions: 95,
lines: 100,
},
},
},
resolve: {
alias: {
// Stub server-side imports in browser context
'@payload-config': 'tests/setup/stubs/payload-config.ts',
},
},
})Jest equivalent (multi-project)
// jest.config.ts
export default {
projects: [
{
displayName: 'unit',
testMatch: ['<rootDir>/tests/unit/**/*.unit.spec.ts'],
coverageDirectory: './coverage/unit',
collectCoverageFrom: ['src/utilities/**/*.ts'],
coverageThreshold: { global: { lines: 100 } },
},
{
displayName: 'integration',
testMatch: ['<rootDir>/tests/int/**/*.int.spec.ts'],
coverageDirectory: './coverage/int',
collectCoverageFrom: ['src/**/*.ts'],
coveragePathIgnorePatterns: ['/components/', '/migrations/'],
coverageThreshold: { global: { lines: 100 } },
},
],
}Package.json scripts
{
"test:unit": "vitest run --config ./vitest.unit.config.mts",
"test:unit:watch": "vitest --config ./vitest.unit.config.mts",
"test:unit:coverage": "vitest run --coverage --config ./vitest.unit.config.mts",
"test:int": "vitest run --config ./vitest.int.config.mts",
"test:int:watch": "vitest --config ./vitest.int.config.mts",
"test:int:coverage": "vitest run --coverage --config ./vitest.int.config.mts",
"test:components": "vitest run --coverage --config ./vitest.browser.config.mts",
"test:components:watch": "vitest --config ./vitest.browser.config.mts",
"test:e2e": "playwright test",
"test": "pnpm test:unit && pnpm test:int && pnpm test:components && pnpm test:e2e"
}Integration test setup (Node)
// tests/setup/node.setup.ts
import { beforeAll, afterAll } from 'vitest'
import type { Payload } from 'payload'
let payload: Payload
beforeAll(async () => {
// Remove stale test database
const dbPath = 'test.db'
if (existsSync(dbPath)) unlinkSync(dbPath)
// Initialise Payload with SQLite (fast, isolated, no external deps)
const { getPayload } = await import('payload')
payload = await getPayload({ config: await import('@payload-config') })
globalThis.testPayload = payload
})
afterAll(async () => {
// Clean up database
await payload?.db?.destroy?.()
})Browser test stubs
Create stub modules to prevent browser tests from importing server-only code:
// tests/setup/stubs/payload-config.ts
export default {} // Stub — server config not needed in browser tests
// tests/setup/stubs/auth-actions.ts
export const loginAction = async () => ({ success: false })
export const logoutAction = async () => {}Component mock factories
// src/components/mocks.ts — shared between tests and Storybook
export function createMockFilm(overrides = {}) {
return {
id: 1,
title: 'Test Film',
year: 2024,
director: 'Test Director',
slug: 'test-film',
...overrides,
}
}Enforcing Coverage: Hooks, CI, and PR Checks
Two enforcement points prevent coverage regression: pre-commit hooks (fast feedback) and CI (comprehensive checks).
Pre-commit Hooks (hk)
If using the hk skill, coverage tests are added as hk steps.
Coverage steps in hk.pkl
steps {
["format"] = new Group {
steps {
// Linting and formatting steps first (auto-fix, re-stage)
}
}
["validate"] = new Group {
steps {
["typecheck"] {
check = "pnpm tsc --noEmit"
}
["test-unit"] {
check = "scripts/quiet-on-success.sh pnpm test:unit:coverage"
}
["test-int"] {
check = "scripts/quiet-on-success.sh pnpm test:int:coverage"
depends = List("test-unit") // no point running slow tests if fast ones fail
}
["test-components"] {
check = "scripts/quiet-on-success.sh pnpm test:components"
depends = List("test-unit")
}
}
}
}Key principles
Thresholds live in test configs, not hk.pkl. hk runs the command and checks the exit code. The test runner's own threshold config determines pass/fail. This keeps the single source of truth in the test config.
E2E tests are excluded from pre-commit. They are too slow (minutes vs seconds). Document this clearly:
<!-- In project README or CLAUDE.md -->
**Run manually before PR:**
- `pnpm test:e2e` — E2E tests (not in pre-commit)
- `pnpm build` — catches static generation errorsOrder tiers by speed. Unit tests run first (fastest feedback). Integration and component tests depend on unit tests passing — no point running expensive tests if cheap ones fail.
Wrap in quiet-on-success. Passing tests produce no output. Only failures are visible. This keeps commit output clean. See hk skill's assets/quiet-on-success.sh.
Stash-aware testing
hk with stash = "git" stashes unstaged changes before running hooks:
- Coverage is measured against staged changes only
- Partial staging works correctly (only committed code is tested)
- Unstaged new files do not inflate or deflate coverage
- After hooks complete, unstaged changes are restored
This means coverage thresholds apply to what you're actually committing, not your entire working tree.
Python equivalent
["test-unit"] {
check = "scripts/quiet-on-success.sh pytest -m unit --cov --cov-fail-under=100"
}
["test-int"] {
check = "scripts/quiet-on-success.sh pytest -m integration --cov --cov-fail-under=100"
depends = List("test-unit")
}Go equivalent
["test-unit"] {
check = "scripts/quiet-on-success.sh scripts/check-coverage.sh 95"
}Where scripts/check-coverage.sh runs go test -coverprofile and checks the threshold.
Skipping during development
# Skip specific test steps (hk feature)
HK_SKIP_STEPS=test-unit,test-int,test-components git commit -m "wip: not ready yet"Use sparingly. Document any skip in the commit message so reviewers know tests were bypassed.
---
CI Pipeline
CI runs all tiers including E2E. This is the comprehensive check gate before merge.
GitHub Actions: TypeScript/JavaScript
name: Test Coverage
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm tsc --noEmit
- name: Unit tests with coverage
run: pnpm test:unit:coverage
- name: Integration tests with coverage
run: pnpm test:int:coverage
- name: Component tests with coverage
run: pnpm test:components
- name: E2E tests
run: pnpm test:e2e
- name: Upload coverage
if: always()
uses: codecov/codecov-action@v4
with:
files: >-
coverage/unit/lcov.info,
coverage/int/lcov.info,
coverage/components/lcov.info
flags: unit,integration,components
fail_ci_if_error: falseGitHub Actions: Python
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v3
- name: Install dependencies
run: pip install -e ".[test]"
- name: Unit tests
run: pytest -m unit --cov --cov-report=xml:coverage/unit.xml --cov-fail-under=100
- name: Integration tests
run: pytest -m integration --cov --cov-report=xml:coverage/int.xml --cov-fail-under=100
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage/unit.xml,coverage/int.xmlGitHub Actions: Go
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Unit tests
run: go test -coverprofile=coverage/unit.out ./...
- name: Integration tests
run: go test -tags=integration -coverprofile=coverage/int.out ./...
- name: Check coverage threshold
run: |
TOTAL=$(go tool cover -func=coverage/unit.out | grep total | awk '{print $3}' | tr -d '%')
echo "Coverage: ${TOTAL}%"
if (( $(echo "$TOTAL < 90" | bc -l) )); then
echo "::error::Coverage ${TOTAL}% below 90% threshold"
exit 1
fiGitHub Actions: Rust
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install tarpaulin
run: cargo install cargo-tarpaulin
- name: Tests with coverage
run: cargo tarpaulin --out xml --fail-under 90
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: cobertura.xml---
PR Coverage Checks
Codecov configuration
# codecov.yml
coverage:
status:
project:
default:
target: auto # Don't let coverage drop from current level
threshold: 1% # Allow 1% variance for measurement noise
patch:
default:
target: 90% # New code in the PR must be 90%+ covered
flags:
unit:
paths:
- src/utilities/
- src/domain/
carryforward: true
integration:
paths:
- src/
carryforward: true
components:
paths:
- src/components/
carryforward: trueManual enforcement (no third-party service)
For teams that prefer not to use Codecov/Coveralls:
#!/usr/bin/env bash
# scripts/check-coverage-threshold.sh
# Usage: ./scripts/check-coverage-threshold.sh coverage/unit/coverage-summary.json 100
SUMMARY_FILE="$1"
THRESHOLD="${2:-100}"
COVERAGE=$(jq '.total.lines.pct' "$SUMMARY_FILE")
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "Coverage ${COVERAGE}% below threshold ${THRESHOLD}%"
exit 1
fi
echo "Coverage ${COVERAGE}% meets threshold ${THRESHOLD}%"Add to CI:
- name: Check unit coverage threshold
run: ./scripts/check-coverage-threshold.sh coverage/unit/coverage-summary.json 100---
Ratcheting Strategy
For projects not yet at their target threshold.
The ratcheting workflow
1. Measure current coverage: Run coverage, note the baseline 2. Set threshold at current level: This prevents regression immediately
thresholds: {
lines: 72, // baseline measured 2024-03-01
}3. Improve incrementally: Add tests as you touch code (boy scout rule) 4. Bump threshold after each improvement:
thresholds: {
lines: 78, // ratcheted 2024-03-15: added auth + validation tests
}5. Never lower the threshold: If a commit lowers coverage, fix it — add tests for the new code or adjust exclusions
Ratcheting rules
- Ratchet on merge, not on PR. Set the threshold to the new level after a coverage-improving PR merges.
- Document each ratchet. The comment on the threshold line should include the date and what was added.
- Set a target date. "100% by end of Q2" gives the team a goal to work toward.
- Protect against gaming. Deleting tested code raises the percentage but doesn't improve quality. Review PRs that significantly change coverage.
Automated ratcheting (advanced)
#!/usr/bin/env bash
# scripts/ratchet-coverage.sh
# After tests pass, update threshold to current level
CURRENT=$(jq '.total.lines.pct' coverage/unit/coverage-summary.json)
CURRENT_INT=${CURRENT%.*} # truncate to integer
# Update vitest config threshold
sed -i "s/lines: [0-9]*/lines: $CURRENT_INT/" vitest.unit.config.mts
echo "Ratcheted coverage threshold to ${CURRENT_INT}%"Run after merge to main, not on every commit.
---
Composition: test-coverage + hk Skills
When both skills are loaded:
| Concern | Owner | Location |
|---|---|---|
| What to test | test-coverage | Test files, fixture factories |
| Coverage thresholds | test-coverage | Test runner config files |
| Hook wiring | hk | hk.pkl |
| Step ordering | hk | depends in hk.pkl |
| Output formatting | hk | quiet-on-success.sh |
| CI pipeline | test-coverage | .github/workflows/ |
| Exclusion docs | test-coverage | Config file comments |
Boundary rule: test-coverage never edits hk.pkl. hk never edits test runner configs. Each skill owns its domain.
When setting up a new project:
1. Use test-coverage to establish the test architecture (tiers, configs, thresholds) 2. Use hk to wire the coverage commands into pre-commit hooks 3. Use test-coverage to set up CI coverage reporting 4. Both skills reference scripts/quiet-on-success.sh — hk owns the file, test-coverage documents its usage