Promise Rejections
The first thing to realize is that with the
.rejects.toThrow()
API we are creating a chain. The rejects
part becomes a property that returns another object with the .toThrow()
function. I reflect this in the Assertions
type straight away:interface Assertions {
toBe(expected: unknown): void
rejects: {
toThrow(expected: Error): Promise<void>
}
}
Note that the.rejects.toThrow()
assertion will be returning a Promise. Annotate its return type accordingly (Promise<void>
).
Next, I extend the object returned from the
globalThis.expect
function to have the rejects
property. In that property, I define a new toThrow
function.globalThis.expect = function (actual: unknown) {
return {
toBe(expected: unknown) {
if (actual !== expected) {
throw new Error(`Expected ${actual} to equal to ${expected}`)
}
},
rejects: {
toThrow(expected) {
Although the
actual
value will be a Promise in our case, we can pass anything to the expect()
function. If we assume it's a Promise, and write actual.catch()
, TypeScript will kindly warn us that actual
is unknown
, and it doesn't necessarily have the .then()
/.catch()
methods a Promise has.I make sure that the passed
actual
value is the instance of Promise:rejects: {
toThrow(expected) {
if (!(actual instanceof Promise)) {
throw new Error(`Expected ${actual} to be a promise`)
}
Now that we are always asserting on a Promise, I will make sure it rejects, and compare the expected and the actual error messages once it does.
rejects: {
toThrow(expected) {
if (!(actual instanceof Promise)) {
throw new Error(`Expected ${actual} to be a promise`)
}
return actual.catch((error) => {
if (error.message !== expected.message) {
throw new Error(`Expected error message to be ${error.message} but got ${expected}`)
}
})
}
}
.then(onFulfilled, onRejected)
callback, not via the .then().catch()
chaining. See the correct implementation below, and feel free to skip to 02:28 to watch the rest of the solution.To handle the unwanted cases when the
actual
promise resolves, I will replace the .catch()
callback with a single .then()
callback, providing it with two arguments:return actual.catch(onRejected)
return actual.then(onFulfilled, onRejected)
The
onFulfilled
function will be executed when the actual
promise resolves, and I will throw an error if that happens. The onRejected
function will be the same we've provided to the .catch()
method before.rejects: {
toThrow(expected) {
if (!(actual instanceof Promise)) {
throw new Error(`Expected ${actual} to be a promise`)
}
return actual.then(
() => {
throw new Error(`Expected ${actual} to reject but it didn't`)
},
error => {
if (error.message !== expected.message) {
throw new Error(
`Expected ${error.message} to equal to ${expected.message}`,
)
}
},
)
},
Using a single.then()
callback allow me to handle the promise fulfillment/rejection without introducing a chain. You can learn more about why this is important in this issue.
Finally, I change the test case to use the newly created
.rejects.toThrow()
assertion and provide the expected error:test('throws on greeting user with undefined user response', async () => {
await expect(greetByResponse(undefined)).rejects.toThrow(
new Error('Failed to greet the user: no user response provided'),
)
})
await
the expect()
call because the .toThrow()
function returns a promise.And I verify that the newly introduced behavior of the
greetByResponse()
function behaves as intended by running the tests:npx tsx --import ./setup.ts greet.test.ts
β returns a greeting message for the given name
β returns a congratulation message for the given name
β throws on greeting user with undefined user response
β returns a greeting message for the given user response