Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means
Coverage tells you which code your tests ran. It does not tell you whether the tests checked anything. We measured a small module three ways to show the difference.
- Code coverage measures how much of your code runs during tests.
- Branch coverage is stricter than line coverage, and more useful.
- In our test, a suite with no assertions still reached 100% coverage.
- Use coverage to find untested code, not as proof of quality.
A team proudly reports 95% code coverage, then ships a bug in a line that was “covered”. This is not a contradiction. Coverage answers one narrow question: did the tests execute this code? Whether they checked the result is a different question entirely.
We used Python with coverage.py 7.17 and pytest 9.2. Every number and report below is from our own run. The ideas apply in any language.
What is code coverage?
Code coverage is the percentage of your code that runs while your automated tests run. A tool records which lines, and optionally which branches, were executed, then reports what was missed.
It is most useful in reverse. Low coverage in an area tells you, with certainty, that tests are not exercising it. High coverage tells you much less.
Types of coverage
| Type | Question it answers |
|---|---|
| Function | Was each function called at least once? |
| Line (statement) | Did each line run at least once? |
| Branch | Did each if go both ways, true and false? |
| Condition | Did each part of a combined condition (a and b) take both values? |
| MC/DC | Did each condition independently change the outcome? Used in safety-critical software. |
Each level is stricter than the one above it. Most teams track line and branch coverage. Condition coverage and MC/DC appear in aviation and automotive standards, where tooling support is specialised.
The code we measured
def shipping_fee(total, is_member, express=False):
if total < 0:
raise ValueError("total cannot be negative")
fee = 0 if total >= 1000 else 50
if is_member:
fee = max(fee - 25, 0)
if express:
fee += 100
return fee
Our first test file had two tests:
def test_regular_order():
assert shipping_fee(500, False) == 50
def test_member_express():
assert shipping_fee(500, True, express=True) == 125
We ran it with branch measurement switched on:
coverage run --branch -m pytest test_shipping.py
coverage report -m
Reading the report
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------
shipping.py 9 1 6 1 87% 3
- Stmts 9, Miss 1: nine executable statements, one never ran.
- Branch 6, BrPart 1: three
ifdecisions give six possible outcomes. One decision only ever went one way. - Missing 3: line 3, the
raise ValueError, never ran. No test passed a negative total.
The fix is a test for the error path. We also added a test for a large order:
def test_free_over_1000():
assert shipping_fee(1000, False) == 0
def test_negative_total():
with pytest.raises(ValueError):
shipping_fee(-1, False)
With both files, the report showed 100% and no missing lines or partial branches. Error paths are the classic gap coverage finds, which is why tests for invalid input, designed with techniques like equivalence partitioning, pair so well with it.
What the report hid
Look at the first report again. It said only line 3 was missing. But the first two tests both used a total of 500, so the free shipping rule for orders of 1000 or more was never tested, and the report did not flag it.
The reason is that fee = 0 if total >= 1000 else 50 sits on one line. coverage.py measures branches between lines, so a conditional expression inside a single line counts as one executed statement. If that rule had a bug, such as > instead of >=, the first suite would still have shown 87% with no hint. Tests of the boundary itself, as described in boundary value analysis, are what catch it.
100% coverage with no checks
Next, we wrote a deliberately poor test that calls every path but asserts nothing:
def test_runs_everything_but_checks_nothing():
shipping_fee(500, True, express=True)
shipping_fee(1000, False)
shipping_fee(500, False)
try:
shipping_fee(-1, False)
except ValueError:
pass
shipping.py 9 0 6 0 100%
It reached 100% line and branch coverage. The function could return any number at all and this test would still pass. This is the core limit of coverage: it measures execution, not verification.
Tip: When reviewing a pull request, read the assertions, not just the coverage change. A test that adds coverage without a meaningful assert only adds false confidence.
To measure whether tests actually catch bugs, use mutation testing. A tool such as mutmut for Python, PIT for Java or Stryker for JavaScript makes small changes to your code, such as flipping >= to >, and checks that a test fails. Our no-assert test would let every mutation survive.
What target to set
There is no universally correct number. Practical guidance:
- Do not chase 100%. The last few percent often means testing trivial code or writing tests that just touch lines.
- Many teams aim for roughly 70 to 85% overall, with higher expectations for core business logic.
- Watch the trend, not the number. Coverage that drops on each release is a warning sign.
- Require coverage of new code in pull requests rather than forcing old code up all at once.
- Exclude what does not matter, such as generated code, with clear rules.
Goodhart’s law applies: when a measure becomes a target, it stops being a good measure. Hard targets encourage tests like our no-assert example.
Tools by language
| Language | Common tools |
|---|---|
| Python | coverage.py, pytest-cov |
| JavaScript / TypeScript | Istanbul (nyc), built into Jest and Vitest |
| Java | JaCoCo |
| .NET | Coverlet |
| Go | go test -cover |
| Reporting | Codecov, SonarQube, Coveralls |
Coverage in CI
Run coverage as part of your CI pipeline, and publish the report so reviewers can see which new lines are untested. With coverage.py, coverage report --fail-under=80 fails the build below a threshold. Keep any threshold modest and combine it with a rule on new code, so a single large legacy file does not block every change.
Beyond coverage: better questions to ask
- Do tests cover the risky, frequently changed parts of the code?
- Are boundaries and error paths tested, not just the happy path?
- Would a test fail if this line were wrong?
- Are the tests fast and stable enough to be run on every change?
These belong in any test strategy. Coverage helps answer only the first.
Excluding code and reading HTML reports
Some code is not worth testing directly, such as debug helpers or a block that only runs when a script is started by hand. coverage.py lets you exclude lines with a comment:
if __name__ == "__main__": # pragma: no cover
main()
You can also set exclusions and the source folders to measure in configuration, for example in pyproject.toml:
[tool.coverage.run]
branch = true
source = ["shop"]
[tool.coverage.report]
show_missing = true
exclude_also = ["if TYPE_CHECKING:"]
Use exclusions sparingly and review them like code. Each one is a decision that some lines will never be checked. Excluding a hard-to-test error handler just to lift the number defeats the purpose.
For day-to-day work, the HTML report is more useful than the terminal table. Run coverage html and open htmlcov/index.html. Each file is shown with executed lines in green, missed lines in red and partial branches in yellow, so you can see at a glance which if never took its other path. Many editors can also show coverage in the margin, which makes gaps visible while you are writing the code rather than afterwards.
Coverage before refactoring
Coverage is also a safety check before refactoring. If you plan to restructure a module, look at its report first. Red areas have no tests to warn you if behaviour changes, so add characterisation tests that capture the current behaviour before you start. Used this way, coverage stops being a target and becomes a practical planning tool.
The same report helps when a bug escapes to production. Find the line where the bug lived and check whether any test ran it. If not, you have found a gap worth closing. If a test did run it, the problem is a weak assertion, which is exactly the situation our no-assert example showed. Both answers tell you how to improve the suite.
Final thoughts
Code coverage is a flashlight, not a grade. Use branch coverage to find code that no test touches, especially error handling, and then write tests that make real assertions. If you remember one result from our run, make it this one: 100% coverage was reached by a test that checked nothing.
Frequently asked questions
What is code coverage in testing?
It is the percentage of code executed while automated tests run, measured by lines, branches or conditions. It shows what tests touched, not whether they verified it.
What is the difference between line and branch coverage?
Line coverage checks that each line ran. Branch coverage checks that each decision went both ways. Branch coverage finds untested paths that line coverage misses.
Is 100% code coverage good?
Not by itself. In our test, a suite with no assertions reached 100%. Aim for meaningful tests on important code rather than a perfect number.
What is a good code coverage percentage?
Many teams target roughly 70 to 85% overall, with more on critical logic. The trend and the quality of the assertions matter more than the exact number.
How is mutation testing different from coverage?
Mutation testing changes your code slightly and checks that a test fails. It measures whether tests detect bugs, which coverage cannot do.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.