Blog

Branch coverage for Apex: what line coverage misses

A line with a decision on it counts as covered the moment it runs — regardless of which way the decision went. 100% line coverage sitting on top of 50% branch coverage is not an exotic failure. In Apex it is the normal case, and the platform's metric cannot show you the difference.

Short version

Line coverage asks whether a line executed. Branch coverage asks whether each decision on that line went both ways. Every Apex construct that packs a decision into one line — a ternary, a single-line guard clause, a defaulting expression — is a place where the two numbers come apart, and the Salesforce 75% gate only ever looks at the first one. Measure both, and treat branch coverage as the floor below which "covered" means nothing.

Two different questions

Line coverage answers: did this line execute during a test? Branch coverage answers something narrower and more useful: for each decision in the code, did the tests take both outcomes?

When a decision spans multiple lines, the two questions almost converge. An if with a multi-line body and a multi-line else has lines that only run on one side, so line coverage notices when a side is untested. That convergence is the reason line coverage feels adequate — it works on the code shape people picture when they think about branching.

The problem is all the other shapes. Every time a decision and its outcomes share a single physical line, line coverage collapses the two questions into one and answers the easy one. And Apex, like every language with a ternary and single-statement if bodies, gives you plenty of ways to do that — the terser and more idiomatic the code, the blinder the metric.

Where Salesforce leaves you

The platform reports line coverage. ApexCodeCoverage and ApexCodeCoverageAggregate expose covered and uncovered lines; the deploy gate compares a line-coverage percentage against 75%. There is no branch or condition figure in what the org gives back, so the number your release process depends on is exactly the number that cannot see a half-taken ternary.

This is not a claim that the information is unobtainable in principle — it is a claim about what is reported. If you want to know whether your suite has been down both sides of a decision, the org's coverage numbers will not tell you, and no amount of pushing them higher will change that. A class can go from 75% to 100% line coverage without a single new decision outcome being exercised.

Example 1: the ternary

The cleanest case. One executable line in the method, one decision on it, two outcomes.

apex
public class ShippingCalculator {
    public static Decimal fee(Decimal orderTotal) {
        return orderTotal >= 50 ? 0 : 4.95;
    }
}

@isTest
private class ShippingCalculatorTest {
    @isTest
    static void shippingIsFreeOverThreshold() {
        System.assertEquals(0, ShippingCalculator.fee(120),
            'orders of 50 or more ship free');
    }
}

Line coverage of ShippingCalculator: 100%. There is one executable line and it ran. The 4.95 arm has never been evaluated in the history of this codebase. Change it to 49.5, or invert the comparison to > so that an order of exactly 50 now pays, and the suite stays green.

Branch coverage of the same line: half. The condition was observed true, never false. That is a materially different report about the same test run, and it is the one that tells you what to write next.

Example 2: the one-line guard clause

Early returns are good style. They are also the most common place line coverage lies, because the guard and its return share a line.

apex
public class AccountArchiver {
    public static Boolean archive(Account a) {
        if (a.IsArchived__c) return false;

        a.IsArchived__c = true;
        update a;
        return true;
    }
}

@isTest
private class AccountArchiverTest {
    @isTest
    static void archivesAnOpenAccount() {
        Account a = new Account(Name = 'Northwind Trading', IsArchived__c = false);
        insert a;

        System.assertEquals(true, AccountArchiver.archive(a));

        Account reloaded = [SELECT IsArchived__c FROM Account WHERE Id = :a.Id];
        System.assertEquals(true, reloaded.IsArchived__c, 'archive() should set the flag');
    }
}

Every executable line in archive ran: the guard line was evaluated, and the three lines after it executed. Line coverage says 100%. The early return never happened — nothing in this suite establishes that archiving an already-archived Account is a no-op, which is the entire reason the guard exists.

Note what decides this. Written as if (a.IsArchived__c) return false; the gap is invisible to line coverage. Written across two lines, with return false; on its own, line coverage would have flagged the uncovered return. Your formatting determines whether your coverage metric notices a missing test. That should be disqualifying for the metric, not an argument about braces.

Example 3: the defaulting expression

The same collapse happens wherever a decision hides inside an expression rather than a statement.

apex
public class LeadNormalizer {
    public static Lead normalize(Lead l) {
        l.Company = String.isBlank(l.Company) ? 'Unknown' : l.Company.trim();
        l.Country = l.Country == null ? 'US' : l.Country;
        return l;
    }
}

@isTest
private class LeadNormalizerTest {
    @isTest
    static void trimsCompanyAndKeepsCountry() {
        Lead l = new Lead(
            LastName = 'Vance',
            Company  = '  Initech  ',
            Country  = 'DE'
        );

        Lead result = LeadNormalizer.normalize(l);

        System.assertEquals('Initech', result.Company);
        System.assertEquals('DE', result.Country);
    }
}

Three executable lines, all covered, 100%. Two decisions, and this test takes the same side of both. Nothing verifies that a blank company becomes 'Unknown' or that a null country defaults to 'US' — the two behaviours the method was written for. Delete both defaults and the test still passes.

This shape is everywhere in real Apex: null-coalescing in a mapper, a default in a constructor, a fallback in a formatter. It is the code most likely to be written once and never read again, which makes an unexercised default an excellent hiding place for a bug.

What Nimbus measures

Nimbus collects coverage in the interpreter while it executes your tests. Each run records a hit count per executable line, execution counts per method, and — for each if condition and each ternary — the true side and the false side separately. A branch counts as covered only when both sides were taken, so everything above shows up as half.

bash
nimbus test "ShippingCalculatorTest.*" --coverage

The run prints line, method, and branch totals, then a per-class and per-method breakdown. For ShippingCalculator above, the line and method figures read 100% and the branch figure does not — which is the entire signal, and it arrives without you doing anything beyond adding a flag to a run you were making anyway.

Two reading notes. The line denominator comes from static analysis of the parsed source rather than from whichever lines happened to run, so code nothing reached still counts against you. And the overall percentage is measured against every executable line Nimbus loaded, not just the classes your pattern touched — run the whole suite when you want the headline number, and use the per-class table while you are working on one thing.

The JSON report carries the same data per line, with trueExecuted and falseExecuted as separate fields alongside a covered flag. That is the shape you want for a review conversation: not "this class is at 91%" but "line 44 has only ever been true." The Cobertura export fills in branch-rate and condition-coverage attributes from the same figures, so SonarQube and Codecov see the branch data too.

bash
# Console summary
nimbus test --coverage

# Machine-readable, with per-line branch status
nimbus test --coverage --coverage-report coverage.json

One caveat on the gates, because it would be easy to assume otherwise: --min-coverage measures line coverage, and nimbus coverage diff computes its delta on line coverage. Branch data is present in the reports; the thresholds are line-based. Branch coverage here is a number you read and act on, not one you fail a build against.

Where branch coverage stops

Branch coverage is a better metric than line coverage. It is not a good enough metric to trust unexamined, and the boundary is worth stating precisely.

It records the outcome of a condition, not of each operand inside it. Consider a guard built from a short-circuit:

apex
public class LeadRouter {
    public static String queueFor(Lead l) {
        if (l.Country == null || l.Country == 'US') {
            return 'Domestic';
        }
        return 'International';
    }
}

Two tests — one with Country = 'US', one with Country = 'DE' — take the condition both ways. Line coverage: 100%. Branch coverage: 100%. And l.Country == null has never been true, so the null case that the operand was added for remains untested. Because || short-circuits, the second operand was not even evaluated in the DE run.

Distinguishing that requires condition-level coverage, which reports each operand separately. Nimbus does not report it — a compound condition is recorded as one decision with two outcomes. Knowing that is what keeps the number honest: 100% branch coverage on a compound guard means both outcomes were reached, not that every reason to reach them was.

The next rung

Follow the ladder up and the pattern is consistent: each metric answers a sharper question than the last, and each one still measures what your tests touched rather than what they would catch. Even full branch coverage is satisfied by a test with no assertions at all — walk both sides of every decision, assert nothing, score 100%.

Mutation testing is the rung that changes the question. Instead of asking what ran, it breaks the code deliberately — flips a comparison, swaps an operator, replaces a condition with a constant — and re-runs the suite. A mutant that survives is a change to real behaviour that no test objects to. Applied to the examples above, a mutant that changes >= to > in ShippingCalculator survives immediately, and it names the exact line.

The reason to care about branch coverage anyway is that it is nearly free. It comes out of the test run you were doing regardless, it needs no extra passes over the suite, and it points at the same gaps mutation testing would find — just faster and less precisely. Use branch coverage continuously and mutation testing on the code that matters.

What to do with this

  • Stop reading line coverage as "tested." It is "reached." Those diverge exactly where the code is dense.
  • Suspect every ternary, one-line guard, and default. They are the shapes that collapse both questions into one line.
  • Clear 75% for the deploy, then look at the branch number. It is the one that moves when you write a real test.
  • Read branch data per line, not per class. "Line 44 has only ever been true" is actionable; "91%" is not.
  • Remember the compound-condition limit. Both outcomes reached is not every operand exercised.
  • Escalate to mutation testing where correctness is expensive. Coverage measures reach; mutation score measures whether the suite would object.

Get the second number

Run your suite locally with --coverage and compare the line percentage against the branch percentage. If the gap is large, it is not a reporting artefact — it is the list of decisions your suite has only ever seen go one way.