Skip to content

Test domain logic ​

Problem: entities involve ids and timestamps, and you want deterministic tests without stubbing Date.now or crypto.randomUUID.

Snippets below run in a vitest test file — test and expect in scope — plus:

ts
import { P } from "unthrown";

Branded vocabulary (Slug, Organization, createOrg, …) is whatever your own domain declares; the sections below build the helpers from it.

Mint fixture values with a helper, not a cast ​

Every field is branded, so a bare "acme" is not a Slug and a test that passes one does not compile. Declare one helper per piece of vocabulary, beside the vocabulary, and the rest of the file reads like literals:

ts
const slug = (value: string) => Slug.parse(value);
const name = (value: string) => DisplayName.parse(value);
const money = (amount: number, currency: "EUR" | "USD" | "GBP") =>
  Money.parse({ amount, currency });

// createOrg is the factory bound in the next section
createOrg({ slug: "acme", name: "Acme" }); // ✗ compile error — not branded
createOrg({ slug: slug("acme"), name: name("Acme") }); // ✓

A helper is a named parse, not a cast — an invalid fixture fails loudly instead of being asserted into existence. It throws, which is what you want here: a bad literal in a test is a bug in the test. Untrusted data goes through make instead and comes back as a Result.

One thing worth knowing while you read a test file: vitest transpiles without type-checking, so a branding violation is invisible to vitest run. Only tsc sees it — which is why this package's own example compiles its declarations.

Bind fixed generators instead of stubbing globals ​

The entity generates nothing itself, so a test binds its own sources:

ts
const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000";
const FIXED_AT = "2026-08-06T09:00:00Z";

const createOrg = Organization.factory({
  id: () => FIXED_ID,
  createdAt: () => FIXED_AT,
});

test("a new organization starts on its trial", () => {
  const org = createOrg({
    slug: slug("acme"),
    name: name("Acme"),
  }).getOrThrow();
  expect(org.createdAt).toBe(FIXED_AT);
});

The generators need no cast: a generated value is spread into make and validated there, so each one is typed as its schema's input.

No global patching, no module mocking, no reset in afterEach. Production binds the same factory to real ports at the composition root.

Need distinct ids across a test? Generators are called once per create:

ts
let n = 0;
const createOrg = Organization.factory({
  id: () => `0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}`,
  createdAt: () => FIXED_AT,
});

Skip the factory when you already have the values ​

For most tests you are not exercising creation at all — make takes complete data:

ts
const org = Organization.make({
  id: FIXED_ID,
  slug: "acme",
  name: "Acme",
  createdAt: FIXED_AT,
}).getOrThrow();

Assert on failures without try/catch ​

Nothing throws, so a failing case is an ordinary value:

ts
test("an over-long name is rejected", () => {
  expect(Organization.make({ ...raw, name: "x".repeat(81) }).isErr()).toBe(
    true,
  );
});

When the reason matters, match all three channels — that is also how you prove a failure is bad input rather than a bug:

ts
const outcome = Organization.make({ ...raw, slug: "" }).match({
  ok: () => "WRONGLY ACCEPTED",
  errCases: (m) =>
    m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => i.path?.[0])),
  defect: () => "DEFECT",
});

expect(outcome).toEqual(["slug"]);

Asserting "WRONGLY ACCEPTED" in the ok branch is worth the line: without it, a test that stops rejecting passes silently.

Test invariants through the entry points ​

Invariants run on every construction path, so there is nothing separate to call:

ts
expect(Trial.make(rowWithBadDates).isErr()).toBe(true);
expect(trial.update({ trialEndsAt: earlier }).isErr()).toBe(true);

Pin compile-time guarantees in *.test-d.ts ​

Some behaviour only exists at the type level — the seal, the generated/immutable flags, a computed field being underivable. Those belong in a .test-d.ts file, checked by tsc:

ts
// @ts-expect-error `id` is generated by the domain, not supplied by the caller
createOrg({ slug, name, id });

// @ts-expect-error `id` is immutable
org.update({ id });

// @ts-expect-error construction is sealed
new Organization({ id, slug, name, createdAt });

An unused @ts-expect-error is itself an error, so these fail loudly if a guarantee is lost.

One trap worth knowing: do not use as never for the values in such a test. never is assignable to anything, including a function type, so an assertion written with it can silently stop testing what you meant. Use real branded values — the mint helpers above are exactly what they are for.

Released under the MIT License.