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 assume these imports:

ts
import { 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:

ts
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:

ts
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:

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 rules, 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.

Released under the MIT License.