fix(expect): improve expect type to make it work better with expect.extend (#5309)

This commit is contained in:
Simon Lecoq 2024-07-16 00:09:06 -04:00 committed by GitHub
parent 63912921b3
commit 83fe1f754f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 110 additions and 4 deletions

View File

@ -1,7 +1,7 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { expect } from "./expect.ts";
import type { MatcherContext, Tester } from "./_types.ts";
import type { Async, Expected, MatcherContext, Tester } from "./_types.ts";
import { AssertionError, assertThrows } from "@std/assert";
declare module "./_types.ts" {
@ -110,3 +110,51 @@ Deno.test("expect.extend() api test case", () => {
"Expected Book object: Book 1. Actual Book object: Book 1",
);
});
Deno.test("expect.extend() example is valid", async () => {
// Extends the `Expected` interface with your new matchers signatures
interface ExtendedExpected<IsAsync = false> extends Expected<IsAsync> {
// Matcher that asserts value is a dinosaur
toBeDinosaur: (options?: { includeTrexs?: boolean }) => unknown;
// NOTE: You also need to overrides the following typings to allow modifiers to correctly infer typing
not: IsAsync extends true ? Async<ExtendedExpected<true>>
: ExtendedExpected<false>;
resolves: Async<ExtendedExpected<true>>;
rejects: Async<ExtendedExpected<true>>;
}
// Call `expect.extend()` with your new matchers definitions
expect.extend({
toBeDinosaur(context, options) {
const dino = `${context.value}`;
const allowed = ["🦕"];
if (options?.includeTrexs) {
allowed.push("🦖");
}
const pass = allowed.includes(dino);
if (context.isNot) {
// Note: when `context.isNot` is set, the test is considered successful when `pass` is false
return {
message: () => `Expected "${dino}" to NOT be a dinosaur`,
pass,
};
}
return { message: () => `Expected "${dino}" to be a dinosaur`, pass };
},
});
// Alias expect to avoid having to pass the generic typing argument each time
// This is probably what you want to export and reuse across your tests
const myexpect = expect<ExtendedExpected>;
// Perform some tests
myexpect("🦕").toBeDinosaur();
myexpect("🦧").not.toBeDinosaur();
await myexpect(Promise.resolve("🦕")).resolves.toBeDinosaur();
await myexpect(Promise.resolve("🦧")).resolves.not.toBeDinosaur();
// Regular matchers will still be available
myexpect("foo").not.toBeNull();
myexpect.anything;
});

View File

@ -137,12 +137,17 @@ const matchers: Record<MatcherKey, Matcher> = {
* @param customMessage - An optional custom message to include in the assertion error.
* @returns An expected object that can be used to chain matchers.
*
* @typeParam T The interface used for `expect`. This is usually needed only if you want to use `expect.extend` to create custom matchers.
*
* @module
*/
export function expect(value: unknown, customMessage?: string): Expected {
export function expect<T extends Expected = Expected>(
value: unknown,
customMessage?: string,
): T {
let isNot = false;
let isPromised = false;
const self: Expected = new Proxy<Expected>(<Expected> {}, {
const self: T = new Proxy<T>(<T> {}, {
get(_, name) {
if (name === "not") {
isNot = !isNot;
@ -287,7 +292,60 @@ expect.addEqualityTesters = addCustomEqualityTesters as (
newTesters: Tester[],
) => void;
/**
* TODO: export appropriate types to define custom matchers.
* Extend `expect()` with custom provided matchers.
*
* To do so, you will need to extend the interface `Expected` to define the new signature of the `expect`.
*
* ```ts
* import type { Async, Expected } from "./expect.ts";
* import { expect } from "./expect.ts";
*
* // Extends the `Expected` interface with your new matchers signatures
* interface ExtendedExpected<IsAsync = false> extends Expected<IsAsync> {
* // Matcher that asserts value is a dinosaur
* toBeDinosaur: (options?: { includeTrexs?: boolean }) => unknown;
*
* // NOTE: You also need to overrides the following typings to allow modifiers to correctly infer typing
* not: IsAsync extends true ? Async<ExtendedExpected<true>>
* : ExtendedExpected<false>;
* resolves: Async<ExtendedExpected<true>>;
* rejects: Async<ExtendedExpected<true>>;
* }
*
* // Call `expect.extend()` with your new matchers definitions
* expect.extend({
* toBeDinosaur(context, options) {
* const dino = `${context.value}`;
* const allowed = ["🦕"];
* if (options?.includeTrexs) {
* allowed.push("🦖");
* }
* const pass = allowed.includes(dino);
* if (context.isNot) {
* // Note: when `context.isNot` is set, the test is considered successful when `pass` is false
* return {
* message: () => `Expected "${dino}" to NOT be a dinosaur`,
* pass,
* };
* }
* return { message: () => `Expected "${dino}" to be a dinosaur`, pass };
* },
* });
*
* // Alias expect to avoid having to pass the generic typing argument each time
* // This is probably what you want to export and reuse across your tests
* const myexpect = expect<ExtendedExpected>;
*
* // Perform some tests
* myexpect("🦕").toBeDinosaur();
* myexpect("🦧").not.toBeDinosaur();
* await myexpect(Promise.resolve("🦕")).resolves.toBeDinosaur();
* await myexpect(Promise.resolve("🦧")).resolves.not.toBeDinosaur();
*
* // Regular matchers will still be available
* myexpect("foo").not.toBeNull()
* myexpect.anything
* ```
*/
expect.extend = setExtendMatchers as (newExtendMatchers: Matchers) => void;
/**