Measuring Code Coverage in Lua
Code coverage tells you which parts of your codebase actually ran when your tests executed. It’s not a quality score — it’s a map of what you haven’t tested yet. If you’ve been following the Lua testing series, you already know how to write specs. Now let’s see how much of your code those specs actually touch.
How Coverage Works in Lua
When you run Lua with a coverage tool loaded, the interpreter tracks every line that executes. After the run finishes, the tool spits out a report showing lines hit and lines missed.
The standard tool for this is LuaCov (luarocks package luacov, maintained by lunarmodules). It instruments your code at runtime by wrapping file loads and tracking hit counts per line. You get a summary that breaks down each file’s coverage percentage.
A common misconception is confusing coverage with syntax checking. luac -p only validates that your code parses — it tells you nothing about whether your code actually runs. Coverage requires test execution. That’s an important distinction.
Running Coverage with LuaCov
Install LuaCov the same way you installed busted:
luarocks install luacov
Then run your script through LuaCov:
lua -lluacov my_script.lua
When the script finishes, LuaCov writes a report to luacov.report.out. Open it and you’ll see something like this:
Summary
----
File Lines Miss Cover Cover%
my_module.lua 120 15 105 87.50%
That Miss column is your todo list. Each missed line is a path your tests didn’t take.
You can also point LuaCov at a specific config file if you want non-default behaviour:
lua -lluacov -e "require('luacov').config.from_file('.luacov')" my_script.lua
But most of the time you just want the default behaviour, which is why most people configure LuaCov via a .luacov file in the project root.
The Easier Path: busted —coverage
If you’ve been writing specs with busted, you don’t need to run LuaCov manually. busted has a built-in --coverage flag that runs your specs with coverage tracking enabled automatically.
busted --coverage
This does two things under the hood: it loads LuaCov before running your specs, then generates an HTML report in a coverage/ directory. You can open coverage/index.html in a browser and click through each file to see exactly which lines are red (missed) and which are green (hit).
The shorthand for --coverage is -c:
busted -c
If you want a different output format, you can pass =html (the default) or check the busted documentation for other reporters. For most projects, the HTML output is what you want — the visual breakdown makes it much easier to prioritise which uncovered branches to test next.
Coverage Metrics: What You’re Looking At
LuaCov gives you a few different numbers. Here’s what they mean.
Line coverage is the percentage of executable lines that ran at least once. A line counts as “hit” even if it ran in a single test case. If a line has a bug that only manifests under specific input, line coverage alone won’t catch it.
Branch coverage tracks decision points — if, while, repeat, and the condition part of for loops. It tells you whether both the true and false branches were executed. A classic example:
if x > 0 then
handle_positive(x)
else
handle_non_positive(x)
end
If your tests only ever pass positive values for x, you’ll see 50% branch coverage here. You’ll never know if handle_non_positive works correctly.
Function coverage is simpler — it just checks whether each function definition was called at least once. This catches functions that were written but never imported or used in any test.
For most Lua projects, line coverage is the primary metric because it’s the easiest to act on. Branch and function coverage are useful secondary signals.
Configuring LuaCov with .luacov
A .luacov file in your project root controls how LuaCov behaves. This is also where you integrate coverage thresholds for CI.
-- .luacov
coverage = true
output = "luacov.report.out"
exclude = { "tests/", "vendor/", ".*_spec.lua" }
The exclude patterns are important. You almost never want to measure coverage on your test files themselves, since they obviously hit every line they contain. The "tests/" pattern skips any file in a tests directory, and "_spec.lua" skips spec files directly.
Here’s a more complete example that sets a minimum coverage threshold:
-- .luacov
coverage = true
limit = 80 -- CI will fail if coverage drops below 80%
output = "luacov.report.out"
exclude = {
"tests/",
"vendor/",
".*_spec.lua",
"main.lua" -- skip the entry point if it just wires things together
}
The limit setting is what you want for automated pipelines. When luacov runs and the overall coverage is below the threshold, it exits with a non-zero code, which fails your CI build.
Setting Up Coverage in CI
Here’s a practical GitHub Actions workflow that runs coverage and enforces a threshold:
- name: Run tests with coverage
run: busted --coverage
- name: Check coverage threshold
run: |
luacov || echo "Coverage below threshold"
If you prefer to parse the report directly in a script:
lua -lluacov my_script.lua
# Parse luacov.report.out for the Cover% value
COVER=$(grep "^Total" luacov.report.out | awk '{print $NF}' | tr -d '%')
if [ "$COVER" -lt 80 ]; then
echo "Coverage $COVER% is below 80% threshold"
exit 1
fi
The luacov || exit 1 approach is simpler and sufficient for most projects. You just need to make sure the .luacov file has limit = 80 (or whatever threshold you choose).
What 100% Coverage Doesn’t Tell You
This is worth saying plainly: hitting 100% line coverage does not mean your code is correct.
Tests can cover every line while asserting the wrong thing. A test that checks assert(2 + 2 == 5) will happily cover all the relevant lines in the addition function while being completely wrong about the result.
Coverage also doesn’t catch missing logic. If you forgot to handle a particular input combination, your tests won’t know — they’ll just cover the paths you did write.
What coverage is good for is finding dead code and untested error paths. When you see a missed line inside an if pcall(...) then block, that’s error handling code that your tests never triggered. Either the error condition is genuinely impossible in your usage, or it’s an edge case you should be testing.
High coverage with low assertions is worse than moderate coverage with strong assertions. Aim for tests that verify behaviour, not just lines.
Practical Coverage Targets
There’s no universal standard, but here are sane defaults:
| Project type | Suggested minimum |
|---|---|
| Library or module | 80–90% |
| Critical business logic | 90%+ |
| Scripts and tooling | 60–80% |
These are starting points, not dogma. A carefully tested critical algorithm at 70% is more valuable than sloppily covered code at 100%. Use the numbers to find blind spots, not as a score to maximise.
Common Pitfalls
Confusing luac -p with coverage tools. luac -p only parses your Lua code for syntax errors. It doesn’t run anything, so it produces no coverage data whatsoever.
Including spec files in coverage reports. Your _spec.lua files will always show 100% coverage if measured, which distorts your overall percentage. Always exclude them with exclude = { ".*_spec.lua" }.
Forgetting to set a threshold. Coverage that nobody looks at doesn’t help. Set limit in .luacov and make CI enforce it, or the numbers will quietly drift downward over time.
See Also
- Getting Started with Testing in Lua — write your first busted specs
- Mocks and Stubs in Lua — control what’s reachable during tests
- Functions in Lua — understand Lua functions before you test them