This is purely theoretical speculations about how code coverage may be calculated.
It's all started with this code:
export function comp(a, b) {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}Most of code coverage tools would say this code has 4 branches. Which seemed strange to me. So I wondered why...
@vitest/coverage-istanbul |
vitest-monocart-coverage |
@vitest/coverage-v8 |
what I expect | |
|---|---|---|---|---|
| example0.1.js | 0/0 | 0/0 | 1/1 | 1/1 |
| example0.2.js | 2/2 | 2/2 | 2/2 | 2/2 |
| example1.1.js | 4/4 | 4/4 | 4/4 | 3/3 or 4/4 |
| example1.2.js | 4/4 | 4/4 | 3/3 | 4/4 |
| example1.3.js | 4/4 | 4/4 | 3/3 | 4/4 |
| example1.4.js | 6/6 | 6/6 | 4/4 | 6/6 |
| example1.5.js | 4/4 | 4/4 | 5/5 | 3/3 or 4/4 |
| example2.1.js | 4/4 | 4/4 | 4/4 | 3/3 or 4/4 |
| example3.1.js | 2/2 | 2/2 | 2/2 | 2/2 or 1/1 |
| example3.2.js | 2/2 | 2/2 | 2/2 | 2/2 or 1/1 |
| example3.3.js | 2/2 | 2/2 | 3/3 | 2/2 or 1/1 |
| example3.4.js | 4/4 | 4/4 | 3/3 | 2/2 or 4/4 |
| example4.1.js | 2/2 | 2/2 | 3/3 | 2/2 |
| example4.2.js | 1/1 | 1/1 | 2/2 | 2/2 |
| example4.3.js | 2/2 | 2/2 | 2/2 | 2/2 |
| example4.4.js | 3/3 | 3/3 | 3/3 | 3/3 |
| example5.1.js | 0/0 | 0/0 | 2/2 | 2/2 |
| example6.1.js | 0/0 | 0/0 | 2/2 | 2/2 or 1/1 |
| example6.2.js | 0/0 | 0/0 | 2/2 | 2/2 |
| example7.1.js | 0/0 | 0/0 | 2/2 | 2/2 |
| example7.2.js | 0/0 | 0/0 | 1/2 | 1/2 |
| example8.1.js | 0/0 | 0/0 | 1/2 | 1/3 |
Note: if you have 100% coverage you probably don't care if it is 3/3 or 5/5. This would make a difference it you have less than 100%, than numbers can be skewed.
1 branch (or no branching):
console.log(1);flowchart LR
s --> e
2 branches:
console.log(1);
if (a) {
console.log(2);
}flowchart LR
s(s) --- if["if(a)"] -- true --> e
if -- false --> e(e)
Important even so for a = true it would visit all lines of code, we as well need to execute code with a = false to claim that all cases have been covered.
And this is basically explains why it counts 4 branches here:
export function comp(a, b) {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}a > b |
a < b |
|
|---|---|---|
| 1 | true | true |
| 2 | true | false |
| 3 | false | true |
| 4 | false | false |
But there is no difference between cases 1 and 2. If the first condition is true we will never reach code in the second condition, because of early return.
flowchart LR
s(s) --- if1["if(a > b)"] -- true --> e
if1 -- false --- if2[" if(a < b)"] -- true --> e
if2 -- false --> e(e)
On the other hand, code like this:
export function comp(a, b) {
let result = 0;
if (a > b) result = 1;
if (a < b) result = -1;
return result;
}Indeed has 4 branches:
flowchart LR
s(s) --- if1["if(a > b)"]
if1 -- true --> if2
if1 -- false --> if2
if2["if(a < b)"] -- true --> e
if2 -- false --> e(e)
In example above we have 4 branches and coincidentally 4 paths. All branches can be reached, but not all paths, because there are no such values that a > b and a < b.
Let's take a different example:
export function experiment(a, b) {
let result = 0;
if (a) result += 1;
if (b) result += 2;
return result;
}All branches can be covered with two tests:
expect(experiment(false, false)).toBe(0);
expect(experiment(true, true)).toBe(3);But to cover all paths you need 2 more tests:
expect(experiment(true, false)).toBe(1);
expect(experiment(false, true)).toBe(2);One more example:
export function experiment(a, b, c) {
let result = 0;
if (a) result += 1;
if (b) result += 2;
if (c) result += 4;
return result;
}It has 6 branches, but 8 paths.
Let's take the same example, we started with:
export function comp(a, b) {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}And write 100% test coverage:
expect(comp(1, 1)).toBe(0);
expect(comp(1, 0)).toBe(1);
expect(comp(0, 1)).toBe(-1);We still miss edge cases for NaN:
(comp(1, 1) === comp(1, NaN)) === comp(NaN, 1);Which may be not a desired behaviour.
export function comp(a, b) {
let result;
if (a === b) result = 0;
else if (a > b) result = 1;
else result = -1;
return result;
}This code has 3 or 4 branches (depending on how you define "branches"):
flowchart LR
s(s) --- if1["if(a === b)"]
if1 -- true --> e
if1 -- false --- if2
if2["if(a > b)"] -- true --> e
if2 -- false --> e(e)
So far we talked only about if/else. Let's talk about other "branching" constructs
a && b();
// is the same as
if (a) b();
a || b();
// is the same as
if (!a) b();
a ? b() : c():
// is the same as
if (!a) b(); else c();Which makes sense. But what about this example:
if (a || b) {
console.log(1);
}Using logic above this code can be estimated to have 4 branches. But it seems more natural to count it as 2 branches (4 paths?). WDYT?
With exceptions if second operand (b) is a function call (b()) or property accessor (b.something), which may be a getter.
Shall we count code like this:
let x = a ? 1 : 2;as 2 branches or as 1 branch (but 2 paths)?
This should count as 2 branches:
switch (a) {
case 1:
//...
break;
default:
//...
}This is 2 branches as well:
switch (a) {
case 1:
//...
break;
}This is 2 branches as well:
switch (a) {
case 1:
//...
default:
//...
}This is 3 branches:
switch (a) {
case 1:
//...
case 3:
//...
default:
//...
}Is this 2 or 3 branches:
switch (a) {
case 1:
case 3:
//...
default:
//...
}This should count as 2 branches (?):
try {
a(x);
b(y);
//...
} catch (e) {
//...
}But what if each function (a, b) can throw an exception. Shall we count it as 3 (or 4) branches? On the other hand there is no way to know this from statical analysis unless we have type system with effects, like in koka.
This should count as 2 branches
for (let i = 0; i < j; i++) {
//
}Because depending on the value of j we may or may not "get inside" for statement. On the other hand - this is 1 branch:
for (let i = 0; i < 10; i++) {
//
}Same argument applies to while:
while (j < 3) {
//...
}do always counts as 1 branch
do {
//...
} while (j < 3);Do we count optional chaining as branching?
let x = a?.something;It should be counted the same way as:
let x = a == null ? undefined : a.something;Do we count whole chain as 2 branches or do we add branch for each link:
let x = a?.something?.else;Same goes to nullish coalescing and nullish coalescing assignment
Do we count each yield in generator as branch?
const x = function* () {
yield "a";
yield "b";
yield "c";
};