I recently encountered a unit test that looked like this.
describe("Spline", () => {
const spline = new Spline();
it("can be reticulated", () => {
const reticulatedSpline = spline.reticulate();
if (! reticulatedSpline) {
throw new Error('missing a spline');
}
expect(reticulatedSpline.reticulatedCount).to.eq(1);
});
});
The use of branching and conditionals in tests is an anti-pattern since we want tests to be predictable, each test to focus on a single code execution path and generally keep things simple.
The obvious solution is to replace the conditional with .to.not.be.null
or .to.not.be.undefined
or the catch-all .to.exist
(less cognitive overhead). So why was this code written using an if
and throw
in the first place?
The answer is that asserting existence of the object here causes TypeScript error TS2532: Object is possibly 'undefined'.
.
it("can be reticulated", () => {
const reticulatedSpline = spline.reticulate();
expect(reticulatedSpline).to.exist;
expect(reticulatedSpline.reticulatedCount).to.eq(1); // causes TS2532
});
This is because the implementation in chai creates an assertion object and evaluates it, then an error is thrown if the assertion fails. TypeScript can’t infer that the .to.exist
check will throw if the object is null.
This is not a new problem and a proposal for asserting control flow has been discussed in TypeScript#8655 and an implementation proposed in TypeScript#32695.
Assert Not Null
The first solution is a more elegant variation if the original if
and throw
.
describe("with extracting assertNotNull", () => {
function assertNotNull<T>(v: T | null): T {
if (!v) throw new Error();
return v;
}
it("can be reticulated", () => {
const reticulatedSpline = spline.reticulate();
assertNotNull(reticulatedSpline);
expect(reticulatedSpline!.reticulatedCount).to.eq(1);
});
});
Unfortunately, TypeScript as of now doesn’t infer the conditional inside a function, either, so, you need to wrap the call and ensure it returns an object to make this option work.
describe("with extracting assertNotNull", () => {
function assertNotNull<T>(v: T | null): T {
if (!v) throw new Error();
return v;
}
it("can be reticulated", () => {
const reticulatedSpline = assertNotNull(spline.reticulate());
expect(reticulatedSpline!.reticulatedCount).to.eq(1);
});
});
We get an error instead of an assertion.
1) Spline
with extracting assertNotNull
can be reticulated:
Error:
at assertNotNull (test/spline.spec.ts:17:21)
at Context.it (test/spline.spec.ts:23:33)
Improving Errors
We can augment assertNotNull
with an expect
to get a proper assertion failure instead of an error.
describe("with expect inside the assert", () => {
function assertNotNull<T>(v: T | null): T {
expect(v).to.exist;
if (!v) throw new Error();
return v;
}
it("can be reticulated", () => {
const reticulatedSpline = assertNotNull(spline.reticulate());
expect(reticulatedSpline!.reticulatedCount).to.eq(1);
});
});
The result is better.
2) Spline
with expect inside the assert
can be reticulated:
AssertionError: expected null to exist
at assertNotNull (test/spline.spec.ts:30:19)
at Context.it (test/spline.spec.ts:37:33)
Casting a Type
We can cast the result of reticulate()
and TypeScript will happily let us by.
describe("using a cast", () => {
it("can be reticulated", () => {
const reticulatedSpline = spline.reticulate() as Spline;
expect(reticulatedSpline).to.exist;
expect(reticulatedSpline.reticulatedCount).to.eq(1);
});
});
This is problematic. If the signature of reticulate()
were to change, we would just be forcing the response to pretend to be a Spline
, getting no new compile-time errors and leaving nonsensical tests.
Allowing Null
Finally, we can use TypeScript !
and explicitly check .to.exist
.
describe("allowing null", () => {
it("can be reticulated", () => {
const reticulatedSpline = spline.reticulate();
expect(reticulatedSpline).to.exist;
expect(reticulatedSpline!.reticulatedCount).to.eq(1);
});
});
This is my preferred method, but requires disabling strictNullChecks
in tests (read more about this here).
The result, in my opinion, is the cleanest.
3) Spline
allowing null
can be reticulated:
AssertionError: expected null to exist
at Context.it (test/spline.spec.ts:45:35)
Links
- stackoverflow#57066536: How can I avoid an if/else in TypeScript with mocha and undefined returns?
- sample code from above
- TypeScript#8655: Control flow based type narrowing for assert(…) calls.
- TypeScript#32695: A proposed
assert
implementation.