All posts

TypeScript / Optional Chaining and Code Coverage

Posted On 08.29.2022

For the uninformed, optional chaining is a new JavaScript feature that allows you to access property values deep inside an object without checking that each reference in the chain is valid. For example:

// this nested if hell
 
if (foo) {
    if (foo.bar) {
        const value = foo.bar.beep || "hello";
    }
}
 
// can be replaced with
const value = foo?.bar?.beep ?? "hello";

As you can see, each ?. create an implicit branch in the code. When it comes to writing tests, it is easy to miss these implicit branches, which will affect your code coverage.

Let’s take a look at this example. I have a greeting method that says hello to a person if they have a name. Otherwise, just say, “Hi there!”:

interface Person {
    name: string;
}
 
export const greeting = (person?: Person): string => {
    if (person?.name) {
        return `Hello! ${person.name}`;
    } else {
        return `Hi there!`;
    }
};

And it’s pretty easy to test this method:

describe('test greeting', () => {
    it('should greet the name', () => {
        const result = greeting({ name: 'Huy' });
        expect(result).toEqual('Hello! Huy');
    });
 
    it('should greet in generic if no name', () => {
        const result = greeting({ name: '' });
        expect(result).toEqual('Hi there!');
    });
});

Now, run the test and get some code coverage. Things seem fine:

$ jest --coverage

But look closely. You will notice that the Conditional Coverage (% Branch section) does not get to 100%.

And the code that does not have enough coverage is this:

if (person?.name) {

What’s going on here? In our code, we already covered the case where this if statement returns true as well as the false case. Why is it not covered?

Turned out, it’s the optional chaining that creates an implicit branch, so the code would be split into one more branch, like this:

if (person) {
    if (person.name) {
        return `Hello! ${person.name}`;
    }
}
return `Hi there!`;

In the test, we did not cover the case where person is falsy. Let’s fix it by adding one more test case:

it('should greet in generic if no person', () => {
    const result = greeting();
    expect(result).toEqual('Hi there!');
});

Run the test again. Now we’re able to get to 100% coverage!

Disclaimer: I’m not saying I’m obsessed with code coverage, or I’m advocating for 100% test coverage here. It’s up to you to decide how much coverage is good for your project.