Daniel Doubrovkine bio photo

Daniel Doubrovkine

aka dB., @awscloud, former CTO @artsy, +@vestris, NYC

Email Twitter LinkedIn Github Strava
Creative Commons License

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)