Test with Vitest
How-to.
@unthrown/vitestadds custom Vitest matchers for asserting onResultandAsyncResultvalues.
pnpm add -D @unthrown/vitestvitest is a peer dependency.
Register the matchers
Import the package once — in a test or, better, a Vitest setup file — to register the matchers and pull in their type augmentation:
// vitest.setup.ts
import "@unthrown/vitest";Assert on a Result
import { Ok, Err } from "unthrown";
import { expect, test } from "vitest";
test("matchers", () => {
expect(Ok(1)).toBeOk();
expect(Ok(1)).toBeOkWith(1); // deep equality on the value
expect(Err("e")).toBeErr();
expect(Err(new NotFound())).toBeErrTagged("NotFound");
expect(aDefect).toBeDefect();
expect(aDefect).toBeDefectWith(expect.any(TypeError)); // assert the cause
expect(Ok(1)).not.toBeErr(); // negations work too
});| Matcher | Passes when |
|---|---|
toBeOk() | the result is Ok |
toBeOkWith(value) | the result is Ok and the value deep-equals value |
toBeErr() | the result is Err |
toBeErrWith(value) | the result is Err and the error deep-equals value |
toBeErrTagged(tag) | the result is Err whose error has _tag === tag |
toBeErrTagged(tag, expected) | …and its payload matches expected (exact for a plain object, partial for an asymmetric matcher) |
toBeDefect() | the result is a Defect |
toBeDefectWith(cause) | the result is a Defect whose cause deep-equals cause |
Assert on a tagged error's payload
toBeErrTagged takes an optional second argument to also assert the tagged error's payload — its own fields, minus the keys TaggedError reserves (_tag, name, message, stack). A plain object matches it exactly; an asymmetric matcher matches it partially:
import { Err, TaggedError } from "unthrown";
import { expect } from "vitest";
class NotFound extends TaggedError("NotFound")<{ id: number; msg: string }> {}
// exact — every payload field must match
expect(Err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged("NotFound", {
id: 1,
msg: "nope",
});
// partial — only the listed fields are checked
expect(Err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged(
"NotFound",
expect.objectContaining({ id: 1 }),
);The reserved keys are skipped so the exact form keeps working with the standard way of setting a message — override message = "…" lands as an own property on the instance, but it is Error's human string, not payload:
class HttpError extends TaggedError("HttpError")<{ status: number }> {
override message = `http ${this.status}`;
}
// the payload is `{ status }` — the message is not part of it
expect(Err(new HttpError({ status: 500 }))).toBeErrTagged("HttpError", {
status: 500,
});Async results — await is required
Each matcher detects a thenable AsyncResult and awaits it internally. That means for an AsyncResult you must await the assertion:
await expect(fromPromise(load(), qualify)).toBeOk();
await expect(fromSafePromise(Promise.reject(boom))).toBeDefect();Don't forget the await
Always await expect(asyncResult)…. As a safety net, importing the package also registers an afterEach hook: a test that ends with async assertions still pending fails with an explicit message naming the un-awaited matchers and the line that created them, instead of passing silently.
@unthrown/vitest: 1 async assertion(s) (toBeOk) were still pending when the
test ended — a forgotten `await`. … Created at: loadUser (src/user.spec.ts:42:18).The full stack is on the error's cause, for reporters that render it.
Where to go next
- Define the errors you're asserting on: Model errors.
- Keep dropped results out of your code: Lint your codebase.