Test domain logic
Problem: entities involve ids and timestamps, and you want deterministic tests without stubbing Date.now or crypto.randomUUID.
Snippets below assume these imports:
tsimport { z } from "zod"; import { P } from "unthrown"; import { Entity } from "@btravstack/entity";
Bind fixed generators instead of stubbing globals
The entity generates nothing itself, so a test binds its own sources:
const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000" as z.infer<
typeof OrgId
>;
const FIXED_AT = "2026-08-06T09:00:00Z" as z.infer<typeof Instant>;
const createOrg = Organization.factory({
id: () => FIXED_ID,
createdAt: () => FIXED_AT,
});
test("a new organization starts on its trial", () => {
const org = createOrg({ slug, name }).getOrThrow();
expect(org.createdAt).toBe(FIXED_AT);
});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:
let n = 0;
const createOrg = Organization.factory({
id: () =>
`0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}` as z.infer<
typeof OrgId
>,
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:
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:
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:
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:
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 rules, a computed field being underivable. Those belong in a .test-d.ts file, checked by tsc:
// @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.