A lot of tests use the expect().toMatchObject() assertion in Jest or Vitest, because we’re most
often dealing with object structures returned from the subject of the test. The toMatchObject
assertion is useful because it applies a deep partial match, so you only need to specify the
particular property paths you’re interested in. It doesn’t matter if other properties are added to
the object structure later. However, I would often find the need to add explicit type information in addition to the assertion,
because the fluent interface in expect() cannot indicate any type information to TypeScript or the
IDE. For example a test might do something like this: // Could be a loose type with many optionals.
const responseBody: unknown = await someApiCall();
expect(responseBody).toMatchObject({
foobar: "hello",
tags: [],
context: {
userId: "12345"
},
});
doSomethingElse(
responseBody?.context?.userId,
responseBody?.tags[1]
);
We have asserted on the structure at run-time but not compile-time. While we’re working on this
test, TypeScript does not know the exact type of responseBody. If we want to do something else
with those values, or get IDE autocomplete for it, we have to explicitly tell TypeScript about the
type, for example: const responseBody: {
foobar: string,
tags: string[],
context: {
userId: string
}
} = await someApiCall();
expect(responseBody).toMatchObject({
foobar: "hello",
context: {
userId: "12345"
},
});
It’s not so bad, but it has some ergonomic costs that add up over a project. Expressing types twice,
both in the assignment and in the assertion, adds noise and boilerplate to every test. In many cases
the real inferred types from the function return are not assignable to the structure we want to
coerce them into in a test. This approach also detaches the real run-time assertion from the compile-time type information, when
most often we want to ensure those are correctly aligned. It would be nice if we could do both things at once: validate the real structure, and narrow the
type down to match that verified structure. The
type narrowing composable matchers
in the @kensio/smartass package fulfill those two requirements in one call: import { assertObjectMatches, nonEmptyArray } from "@kensio/smartass";
const responseBody = await someApiCall();
assertObjectMatches({
foobar: "hello", // literals are matched as const
tags: nonEmptyArray(), // run-time and compile-time type validation
context: { // deep partial structural matching
userId: stringOfLength(5) // run-time and compile-time type validation
},
});
After the assertObjectMatches() call, TypeScript and the IDE know much more detailed information
about the type structure of responseBody. The same information is also validated at run-time,
which joins compile-time and run-time verification back together. Taking this approach removes a lot of clutter from tests, which keeps them more readable and easier
to maintain. It also tends to mean you get extra validations and extra type checking, as each comes
for free whenever you use the other in a test. GitHub: https://github.com/KensioSoftware/smartass npm: www.npmjs.com/package/@kensio/smartass View post:
Composable type narrowing test assertion matchers in TypeScript |