Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions expect/_matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,12 +1144,76 @@ export function toMatchSnapshot(
maybeHint,
);

matchSnapshot(
"toMatchSnapshot",
() =>
propertyMatchers
? applyPropertyMatchers(context, propertyMatchers, "toMatchSnapshot")
: context.value,
hint,
);
}

/**
* Compares the message of the error thrown by a function (or the reason of a
* rejected promise, when used with `.rejects`) against a stored snapshot file.
*
* @experimental
*/
export function toThrowErrorMatchingSnapshot(
context: MatcherContext,
hint?: string,
): MatchResult {
if (context.isNot) {
throw new AssertionError("Snapshot matchers do not support `.not`");
}

let error: unknown;
if (typeof context.value === "function") {
let threw = false;
try {
context.value();
} catch (err) {
threw = true;
error = err;
}
if (!threw) {
const defaultMessage = "Received function did not throw";
throw new AssertionError(
context.customMessage
? `${context.customMessage}: ${defaultMessage}`
: defaultMessage,
);
}
} else {
// With `.rejects`, the value is already the rejection reason.
error = context.value;
}

const message = typeof error === "object" && error !== null &&
"message" in error
? error.message
: error;

matchSnapshot("toThrowErrorMatchingSnapshot", () => message, hint);
}

/**
* Serializes the value returned by `getValue` and compares it against the
* stored snapshot for the current test, or records it when running in update
* mode.
*/
function matchSnapshot(
matcherName: string,
getValue: () => unknown,
hint: string | undefined,
): void {
// Determine test file path
const state = getState();
const testFilePath = state.testPath ?? getTestFileFromStack();
if (!testFilePath) {
throw new Error(
"toMatchSnapshot: Unable to determine test file path. " +
`${matcherName}: Unable to determine test file path. ` +
"Set it using expect.setState({ testPath: import.meta.url }).",
);
}
Expand All @@ -1158,7 +1222,7 @@ export function toMatchSnapshot(
const testName = state.currentTestName;
if (!testName) {
throw new Error(
"toMatchSnapshot: Unable to determine test name. " +
`${matcherName}: Unable to determine test name. ` +
"Set it using expect.setState({ currentTestName: '<test name>' }).",
);
}
Expand All @@ -1169,11 +1233,7 @@ export function toMatchSnapshot(
const count = snapshotCtx.getCount(snapshotName);
const key = `${snapshotName} ${count}`;

const valueToSerialize = propertyMatchers
? applyPropertyMatchers(context, propertyMatchers, "toMatchSnapshot")
: context.value;

const actualSnapshot = serialize(valueToSerialize);
const actualSnapshot = serialize(getValue());
snapshotCtx.pushToUpdateQueue(key);

if (getIsUpdate()) {
Expand Down
182 changes: 182 additions & 0 deletions expect/_to_throw_error_matching_snapshot_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright 2018-2026 the Deno authors. MIT license.

import { expect } from "./expect.ts";
import {
assertEquals,
AssertionError,
assertRejects,
assertThrows,
} from "@std/assert";
import { SnapshotContext } from "./_snapshot_state.ts";

/**
* Runs `fn` against a temporary test file whose snapshot file contains
* `snapshots` (a map of snapshot key to serialized value).
*/
async function withSnapshotFile(
testName: string,
snapshots: Record<string, string>,
fn: () => void | Promise<void>,
) {
const dir = await Deno.makeTempDir();
const testFile = `${dir}/test.ts`;
const snapshotDir = `${dir}/__snapshots__`;
await Deno.mkdir(snapshotDir);
await Deno.writeTextFile(
`${snapshotDir}/test.ts.snap`,
[
"export const snapshot = {};",
"",
...Object.entries(snapshots).map(([key, value]) =>
`snapshot[\`${key}\`] = \`${value}\`;\n`
),
].join("\n"),
);

expect.setState({ currentTestName: testName, testPath: testFile });
try {
await fn();
} finally {
expect.setState({ currentTestName: undefined, testPath: undefined });
SnapshotContext.contexts.clear();
await Deno.remove(dir, { recursive: true });
}
}

Deno.test("expect().toThrowErrorMatchingSnapshot() matches the thrown error message", async () => {
await withSnapshotFile(
"throw match",
{ "throw match 1": `"boom"` },
() => {
expect(() => {
throw new Error("boom");
}).toThrowErrorMatchingSnapshot();
},
);
});

Deno.test("expect().toThrowErrorMatchingSnapshot() detects a different error message", async () => {
await withSnapshotFile(
"throw mismatch",
{ "throw mismatch 1": `"boom"` },
() => {
assertThrows(
() => {
expect(() => {
throw new TypeError("bang");
}).toThrowErrorMatchingSnapshot();
},
AssertionError,
"Snapshot does not match",
);
},
);
});

Deno.test("expect().toThrowErrorMatchingSnapshot() uses the hint in the snapshot key", async () => {
await withSnapshotFile(
"throw hint",
{ "throw hint: my hint 1": `"boom"` },
() => {
expect(() => {
throw new Error("boom");
}).toThrowErrorMatchingSnapshot("my hint");
},
);
});

Deno.test("expect().toThrowErrorMatchingSnapshot() shares the snapshot counter with toMatchSnapshot()", async () => {
await withSnapshotFile(
"throw counter",
{ "throw counter 1": `"value"`, "throw counter 2": `"boom"` },
() => {
expect("value").toMatchSnapshot();
expect(() => {
throw new Error("boom");
}).toThrowErrorMatchingSnapshot();
},
);
});

Deno.test("expect().toThrowErrorMatchingSnapshot() throws on missing snapshot in assert mode", async () => {
await withSnapshotFile("throw missing", {}, () => {
assertThrows(
() => {
expect(() => {
throw new Error("boom");
}).toThrowErrorMatchingSnapshot();
},
AssertionError,
"Missing snapshot: throw missing 1",
);
});
});

Deno.test("expect().toThrowErrorMatchingSnapshot() throws when the function does not throw", async () => {
await withSnapshotFile(
"no throw",
{ "no throw 1": `"boom"` },
() => {
assertThrows(
() => {
expect(() => {}).toThrowErrorMatchingSnapshot();
},
AssertionError,
"Received function did not throw",
);
},
);
});

Deno.test("expect().toThrowErrorMatchingSnapshot() works with rejects", async () => {
await withSnapshotFile(
"rejects match",
{ "rejects match 1": `"async boom"`, "rejects match 2": `"async boom"` },
async () => {
await expect(Promise.reject(new Error("async boom"))).rejects
.toThrowErrorMatchingSnapshot();
await assertRejects(
() =>
expect(Promise.reject(new Error("other"))).rejects
.toThrowErrorMatchingSnapshot(),
AssertionError,
"Snapshot does not match",
);
},
);
});

Deno.test("expect().not.toThrowErrorMatchingSnapshot() throws", async () => {
await withSnapshotFile("throw not", {}, () => {
assertThrows(
() => {
expect(() => {
throw new Error("boom");
}).not.toThrowErrorMatchingSnapshot();
},
AssertionError,
"Snapshot matchers do not support `.not`",
);
});
});

Deno.test("expect().toThrowErrorMatchingSnapshot() throws without currentTestName", () => {
expect.setState({ currentTestName: undefined, testPath: import.meta.url });
try {
const error = assertThrows(
() => {
expect(() => {
throw new Error("boom");
}).toThrowErrorMatchingSnapshot();
},
Error,
"Unable to determine test name",
);
assertEquals(
error.message.startsWith("toThrowErrorMatchingSnapshot:"),
true,
);
} finally {
expect.setState({ currentTestName: undefined, testPath: undefined });
}
});
27 changes: 27 additions & 0 deletions expect/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,33 @@ export interface Expected<IsAsync = false> {
expected?: string | RegExp | E | (new (...args: any[]) => E),
): void;

/**
* Asserts that the function throws an error whose message matches the most
* recent snapshot. If no snapshot exists, one will be created when running
* in update mode (`-- --update`).
*
* When used with `.rejects`, the message of the rejection reason is
* compared instead.
*
* Requires `expect.setState({ currentTestName: "..." })` to be called before use.
* The test file path is auto-detected from the call stack.
*
* @example Usage
* ```ts ignore
* import { expect } from "@std/expect";
*
* Deno.test("error snapshot test", () => {
* expect.setState({ currentTestName: "error snapshot test", testPath: import.meta.url });
* expect(() => {
* throw new Error("boom");
* }).toThrowErrorMatchingSnapshot();
* });
* ```
*
* @param hint An optional hint string appended to the snapshot name.
*/
toThrowErrorMatchingSnapshot(hint?: string): void;

/**
* The negation object that allows chaining negated assertions.
*/
Expand Down
2 changes: 2 additions & 0 deletions expect/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
toMatchSnapshot,
toStrictEqual,
toThrow,
toThrowErrorMatchingSnapshot,
} from "./_matchers.ts";
import { addSerializer } from "./_serializer.ts";
import { isPromiseLike } from "./_utils.ts";
Expand Down Expand Up @@ -117,6 +118,7 @@ const matchers: Record<MatcherKey, Matcher> = {
toReturnWith: toHaveReturnedWith,
toStrictEqual,
toThrow,
toThrowErrorMatchingSnapshot,
};

/**
Expand Down
4 changes: 2 additions & 2 deletions expect/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
* - {@linkcode Expected.toHaveProperty | toHaveProperty}
* - {@linkcode Expected.toMatchSnapshot | toMatchSnapshot}
* - {@linkcode Expected.toMatchInlineSnapshot | toMatchInlineSnapshot}
* - {@linkcode Expected.toThrowErrorMatchingSnapshot | toThrowErrorMatchingSnapshot}
* - Mock related matchers:
* - {@linkcode Expected.toHaveBeenCalled | toHaveBeenCalled}
* - {@linkcode Expected.toHaveBeenCalledTimes | toHaveBeenCalledTimes}
Expand Down Expand Up @@ -70,9 +71,8 @@
* - {@linkcode expect.extend}
* - {@linkcode expect.hasAssertions}
*
* Only these functions are still not available:
* Only this function is still not available:
* - Matchers:
* - `toThrowErrorMatchingSnapshot`
* - `toThrowErrorMatchingInlineSnapshot`
*
* The tracking issue to add support for unsupported parts of the API is
Expand Down
Loading