Skip to main content

Coverage

$ lest --coverage

Lest measures line coverage on native suites by compiling with Luau's coverage level, reading each loaded function's recorded hit counts after the run, and aggregating per-file line hits across every worker.

Coverage:
┌─────────────────┬─────────┬─────────┐
│ File │ Lines │ Covered │
├─────────────────┼─────────┼─────────┤
│ src/cart.luau │ 34/36 │ 94.4% │
│ src/parser.luau │ 81/97 │ 83.5% │
│ src/fetch.luau │ — │ — │
├─────────────────┼─────────┼─────────┤
│ All files │ 115/133 │ 86.5% │
└─────────────────┴─────────┴─────────┘

Native suites only — by design

The VM hooks that produce hit counts don't exist across a process boundary, so lune, lute, and cloud suites can't be instrumented.

Files those suites covered show a dimmed in the table rather than being counted as zero (src/fetch.luau above), and they're left out of the lcov output entirely (an lcov consumer treats "absent" and "not instrumented" the same way). Honest numbers or none — reporting a Lune-only module as 0% would be worse than saying nothing, because it would make a well-tested file look like a gap.

If coverage matters for a module, give it a native suite.

Choosing what's reported

The table only ever lists files the run actually loaded — coverage is read back off the modules your specs required, not from a walk of the repo. So the starting set is already "your code and what it pulls in," not everything on disk.

Two keys narrow it further. Globs match against root-relative, forward-slashed paths, so one pattern works the same on every platform.

exclude

By default:

["**/*.spec.luau", "**/*.spec.lua", "Packages/**"]

Spec files aren't the code under test, and vendored packages aren't yours. Override in config:

[coverage]
exclude = ["**/*.spec.luau", "vendor/**", "src/generated/**"]

Setting exclude replaces the defaults rather than adding to them — list the spec patterns yourself if you still want them out.

include

When subtracting is the long way round — a library where only src/ counts — name what you want instead:

[coverage]
include = ["src/**"]

Only files matching an include glob are reported. Leaving the key out (the default) means no narrowing at all.

The two compose, and exclude wins: include picks the candidates, exclude removes from what's left. That's the pairing to reach for when you want a tree minus its generated corner:

[coverage]
include = ["src/**"]
exclude = ["**/*.spec.luau", "src/generated/**"]

Note that setting include doesn't drop the default excludes — exclude is still defaulted independently, so specs stay out unless you override it.

Lest's own framework is never reported, no matter how wide include is.

An include of [] is rejected rather than guessed at: "cover nothing" and "cover everything" are equally fair readings of an empty list. Remove the key if you meant no narrowing.

Glob syntax

*, **, ?, {a,b} alternation and [abc]/[!abc] character classes all work. Two things to know:

* stops at a directory boundary; ** is how you say "at any depth." src/* is the files directly in src/, src/** is the whole tree. This is the same rule a suite's include follows, so a pattern means one thing wherever you write it. (It changed in 0.5 — see Migrating from 0.4.)

A leading ! does not negate. These are globs, not gitignore lines — ! is matched as a literal character, so "!src/**" quietly matches nothing rather than erroring. Use include for what a negated pattern would have expressed.

Matching is case-sensitive, even where the file system isn't. On macOS and Windows, include = ["Src/**"] against a src/ directory matches nothing and reports an empty table.

Output formats

table (default)

The box-drawn terminal table above: per-file covered/total lines and a percentage, ruled off from an All files row. It belongs to the pretty report, so it isn't printed under --reporter json or --reporter junit — splicing a table into those streams would corrupt the document they promise.

lcov

$ lest --coverage --coverage-format=lcov > lcov.info

Writes a standard lcov tracefile to stdout, which is what Codecov, Coveralls, and editor gutter extensions consume. Under this format the lcov document owns stdout and the human test report moves to stderr, so redirecting stdout as above captures a clean tracefile and nothing else.

Gating on a minimum

$ lest --min 80

Fails the run with exit code 1 when overall coverage is below the given percentage, printing what it got and what it needed. --min implies --coverage, so you don't need both.

The same gate can live in config, which is usually where you want it — the number is a property of the project, not of one invocation:

[coverage]
min = 80

--min overrides [coverage] min for a single run, which is handy for checking where you'd land before committing to a higher bar. Setting [coverage] min also turns coverage measurement on by itself, for the same reason --min implies --coverage.

A coverage shortfall is a test failure (exit 1), not a tool error (exit 2) — the run happened and the project didn't meet its own standard. See Continuous integration.

A gate over nothing is different: when a minimum is set but nothing instrumented reached the table — every selected suite runs on lune, say, or an include glob matched no file — there is no percentage to compare, and exiting 0 would green-light CI while measuring nothing. That's a tool error (exit 2). The one exception is --changed selecting no affected specs: the empty run was requested, so it exits 0 and the gate is explicitly skipped with a note (coverage minimum not enforced — --changed selected no specs).