Skip to main content
zerotal

Mocking

To test that code triggers a side effect — queues a job, notifies a user, broadcasts an event — swap the real service for an in-memory fake. The fake records each call so you can assert on it, and performs no real I/O.

EventFake, QueueFake, and NotificationFake share an install() / restore() lifecycle that swaps a container binding; broadcasting, storage, and the HTTP client are faked through their own facades. All record-and-assert without touching the network or the disk.

Danger — A fake performs no real I/O. If a fake is still installed when a later test expects real delivery, jobs and notifications silently vanish — always restore it in afterEach.

Which fake do I use?

  • Emitting a domain event? Use EventFake — captures every Events.emit() and stops its listeners from running.
  • Queueing a job? Use QueueFake — captures every Queue.dispatch().
  • Sending a notification (including the mail channel)? Use NotificationFake — captures every Notify.send() / Notify.queue().
  • Broadcasting an event over WebSockets? Use Broadcast.fake() — records every Broadcast.send() / Broadcast.to().
  • Writing a file to a disk? Use Storage.fake() — an in-memory disk that records what was stored.
  • Calling another service over HTTP? Use Http.fake() — stubs outbound requests so no real network call is made.
  • Signing in through an OAuth provider? Use Social.fake() from @zerotal/auth — returns a canned profile instead of exchanging a code.
  • Depending on the passage of time? Freeze the clock with Carbon.freeze() rather than installing a fake.
  • Need filler test data (names, emails, dates)? Use fake, the data generator — it has nothing to do with the service fakes above.

Everything except Broadcast.fake() and Social.fake() is re-exported from @zerotal/testing, so a test rarely has to know which package a fake ships in.

Events

EventFake.install() replaces the events binding, so emitted events are recorded and their listeners never run.

// src/tests/PostPublishTest.ts
import { EventFake } from "@zerotal/testing";

let events: EventFake;
beforeEach(() => {
  events = EventFake.install();
});
afterEach(() => events.restore());

it("announces that a post was published", async () => {
  const post = await PostFactory.create({ status: "draft" });

  await post.publish();

  events.assertEmitted(PostPublished);
  events.assertEmitted(PostPublished, (e) => e.postId === post.id);
  events.assertNotEmitted(PostDeleted);
  events.assertEmittedCount(PostPublished, 1);
});

Faking the emitter is what lets this test assert that publishing announces itself without also running everything that reacts to the announcement — mail, search indexing, cache invalidation.

Warning — To test a listener, do not fake. Construct the listener and hand it an event directly; the fake stops listeners from running at all.

Queue

QueueFake.install() replaces the queue binding so dispatched jobs are captured instead of persisted or executed.

// src/tests/PostPublishTest.ts
import { QueueFake } from "@zerotal/testing"; // re-exported from @zerotal/queue

let queue: QueueFake;
beforeEach(() => {
  queue = QueueFake.install();
});
afterEach(() => queue.restore());

it("queues a job when a post is published", async () => {
  const post = await PostFactory.create({ status: "draft" });
  await post.transitionTo("published");

  queue.assertDispatched(NotifySubscribersJob);
  queue.assertDispatched(NotifySubscribersJob, (j) => j.postId === post.id);
  queue.assertNotDispatched(ProcessPaymentJob);
  queue.assertDispatchedCount(1);
  queue.assertNothingDispatched(); // for isolation tests
});

Warning — The fake captures the job but never calls its handle(). To test the job's body, instantiate and await it directly instead of faking.

Notifications

NotificationFake.install() replaces the notifications binding. Both Notify.send() and Notify.queue() are captured as "sent". The mail channel runs through Notify, so faking notifications also covers email side effects.

// src/tests/InvoiceTest.ts
import { NotificationFake } from "@zerotal/testing"; // re-exported from @zerotal/notifications
import { Notify } from "@zerotal/notifications";

let notify: NotificationFake;
beforeEach(() => {
  notify = NotificationFake.install();
});
afterEach(() => notify.restore());

it("notifies the user when an invoice is ready", async () => {
  const user = await UserFactory.create();
  const invoice = await InvoiceFactory.create({ userId: user.id });

  await Notify.send(user, new InvoiceReady(invoice));

  notify.assertSentTo(user, InvoiceReady);
  notify.assertSentTo(user, InvoiceReady, (n) => n.invoiceId === invoice.id);
  notify.assertNotSentTo(adminUser, InvoiceReady);
  notify.assertSentCount(1);
  notify.assertNothingSent(); // for isolation tests
});

Broadcasting

The Broadcast facade has its own recorder. Call Broadcast.fake() to install it and Broadcast.resetFake() to restore — the returned fake exposes the assertions:

// src/tests/PostBroadcastTest.ts
import { Broadcast } from "@zerotal/broadcasting";

const fake = Broadcast.fake();

await PostController.publish({ http: ctx });

fake.assertBroadcast("PostPublished", "posts");
fake.assertBroadcast("PostPublished", "posts", { id: post.id }); // partial payload match
fake.assertNothingBroadcast();

Broadcast.resetFake();

assertBroadcast(eventName, channel?, data?) matches on the broadcast-as name first, then the channel, then a partial payload. See Broadcasting › Testing for the full assertion set.

Storage

Storage.fake() swaps a disk for an in-memory one and returns it. Nothing touches the filesystem, so each test starts empty by construction rather than by remembering to clean up — and a suite that forgets to clean up passes the second time for the wrong reason.

// src/tests/AvatarTest.ts
import { Storage } from "zerotal/storage";
import { fakeFile } from "@zerotal/testing";

let disk: ReturnType<typeof Storage.fake>;
beforeEach(() => {
  disk = Storage.fake(); // or Storage.fake('s3') for a named disk
});
afterEach(() => Storage.restoreFakes());

it("stores an uploaded avatar", async () => {
  const res = await app.actingAs(user).multipart("/avatar", {
    avatar: fakeFile.image("me.png"),
  });

  res.assertCreated();
  disk.assertExistsMatching(/^avatars\/[0-9a-f-]+\.png$/);
  disk.assertContentType(disk.paths()[0]!, "image/png");
  disk.assertCount(1);
});

Uploads are stored under a generated name, which is why assertExistsMatching takes a pattern. When you control the path, assertExists(path, contents?) checks it directly.

Outbound HTTP

Http.fake() intercepts requests made through the Http client, so a test that exercises a service integration makes no real network call — and does not fail when that service is down or rate-limits you.

// src/tests/PaymentTest.ts
import { Http } from "@zerotal/testing";

beforeEach(() => {
  Http.fake([
    { url: "https://api.payments.test/charges", body: { id: "ch_1", status: "paid" } },
    { url: "*", status: 404 }, // anything else is a mistake, and says so
  ]);
});
afterEach(() => Http.resetFakes());

it("charges the card and records the reference", async () => {
  await checkout.pay(order);

  Http.assertSent((req) => req.url.includes("/charges") && req.method === "POST");
  Http.assertSentCount(1);
  await assertDatabaseHas("orders", { payment_reference: "ch_1" });
});

A catch-all stub at the end is worth adding: without it an unstubbed URL falls through, and a test can quietly start depending on a service being reachable.

Time

Behaviour that depends on the passage of time — a token that expires in seven days, a reminder that only fires after 24 hours — has no way to be tested except by waiting. Freeze the clock instead:

// src/tests/InvitationTest.ts
import { Carbon } from "zerotal/carbon";

afterEach(() => Carbon.release());

it("expires an invitation after seven days", async () => {
  Carbon.freeze("2025-01-01T00:00:00Z");
  const invitation = await InvitationFactory.create();

  expect(invitation.isExpired()).toBe(false);

  Carbon.travel({ days: 8 });

  expect(invitation.isExpired()).toBe(true);
});
MemberPurpose
Carbon.freeze(value?)Freeze at value, or at the current instant.
Carbon.setTestNow(value)Freeze at an absolute point; null releases.
Carbon.travelTo(value)Jump to an absolute point.
Carbon.travel(amount)Move relative to now; negative values go back.
Carbon.withTestNow(v, f)Freeze for the duration of f, releasing even if it throws.
Carbon.release()Let the clock run normally again.
Carbon.isFrozen()Whether the clock is currently frozen.

Danger — Always release the clock in an afterEach. A frozen clock that outlives its test makes the next one fail somewhere unrelated.

This moves Carbon's clock, which is what Carbon.now() and everything built on it read — isPast, isToday, diffForHumans, a model's timestamps. A raw Date.now() is unaffected, so code that must be testable against time should go through Carbon.

Generating fake data

fake is a zero-dependency, South-African-flavoured data generator for filling in test values — used inside factories or directly in a test. It is unrelated to the service fakes above.

// src/tests/example.ts
import { fake } from "@zerotal/testing";

// Identity
fake.name(); // "Sipho Dlamini"
fake.email(); // "sipho.dlamini73@gmail.com"
fake.email({ corporate: true }); // "sipho.dlamini@shoprite.co.za"
fake.phone(); // "071 234 5678"

// Location & business
fake.city();
fake.province();
fake.suburb();
fake.streetAddress();
fake.postalCode();
fake.company();
fake.jobTitle();
fake.department();

// Primitives
fake.string(10); // random alphanumeric
fake.number(1, 1000); // integer in [1, 1000]
fake.float(0, 1, 2); // 2-decimal float
fake.boolean(0.8); // true 80% of the time
fake.uuid();
fake.maybe("val", 0.5); // value or null

// Dates
fake.date();
fake.pastDate(3);
fake.futureDate(2);
fake.isoDate();
fake.timestamp();

// Text
fake.word();
fake.words(5);
fake.sentence();
fake.paragraph();
fake.title();
fake.slug("My Title");

// Arrays
fake.pick([1, 2, 3]); // random element
fake.shuffle([1, 2, 3]); // shuffled copy
fake.sample([1, 2, 3, 4], 2); // 2 unique random picks

References

EventFake — captures emitted events:

MethodSignatureDescription
installstatic install(): EventFakeSwap the events binding for the fake.
restorerestore(): voidRestore the original events binding.
emittedemitted(): object[]All captured events, in emit order.
emittedOfemittedOf(EventClass): T[]The captured events of one class, typed.
assertEmittedassertEmitted(EventClass, filter?): voidAn event of that class was emitted (optionally matching).
assertNotEmittedassertNotEmitted(EventClass, filter?): voidNo such event was emitted.
assertEmittedCountassertEmittedCount(EventClass, count): voidExactly count events of that class were emitted.
assertNothingEmittedassertNothingEmitted(): voidNo events were emitted at all.
clearclear(): voidDiscard what has been captured so far.

FakeDisk — returned by Storage.fake():

MethodSignatureDescription
assertExistsassertExists(path, contents?): thisA file is stored at path (optionally with contents).
assertMissingassertMissing(path): thisNothing is stored at path.
assertExistsMatchingassertExistsMatching(pattern: RegExp): thisSome stored path matches pattern.
assertContentTypeassertContentType(path, type): thisThe file was stored with that content type.
assertCountassertCount(expected): thisExactly expected files are stored.
assertNothingStoredassertNothingStored(): thisNothing was stored.
paths / filepaths(): string[] / file(path)Inspect what is stored.
clearclear(): voidEmpty the disk.

Http — the outbound HTTP client's own harness:

MethodSignatureDescription
fakestatic fake(stubs?): voidIntercept outbound requests with stubbed responses.
resetFakesstatic resetFakes(): voidRestore real HTTP behaviour.
recordedstatic recorded(): Array<…>Every request recorded since fake().
assertSentstatic assertSent(predicate): voidA matching request was sent.
assertNotSentstatic assertNotSent(pred): voidNo matching request was sent.
assertSentCountstatic assertSentCount(n): voidExactly n requests were sent.
assertNothingSentstatic assertNothingSent(): voidNo requests were sent at all.

QueueFake — captures dispatched jobs:

MethodSignatureDescription
installstatic install(): QueueFakeSwap the queue binding for the fake.
restorerestore(): voidRestore the original queue binding.
dispatcheddispatched(): Job[]All captured jobs.
assertDispatchedassertDispatched(JobClass, filter?): voidA job of that class was dispatched (optionally matching filter).
assertNotDispatchedassertNotDispatched(JobClass): voidNo job of that class was dispatched.
assertNothingDispatchedassertNothingDispatched(): voidNo jobs were dispatched at all.
assertDispatchedCountassertDispatchedCount(count): voidExactly count jobs were dispatched.

NotificationFake — captures sent notifications:

MethodSignatureDescription
installstatic install(): NotificationFakeSwap the notifications binding for the fake.
restorerestore(): voidRestore the original notifications binding.
sentsent(): CapturedNotification[]All captured { notifiable, notification } pairs.
assertSentToassertSentTo(notifiable, NotificationClass, filter?): voidA notification of that class was sent to the notifiable.
assertNotSentToassertNotSentTo(notifiable, NotificationClass): voidThat class was not sent to the notifiable.
assertNothingSentassertNothingSent(): voidNo notifications were sent at all.
assertSentCountassertSentCount(count): voidExactly count notifications were sent.

BroadcastFake — returned by Broadcast.fake():

MethodSignatureDescription
recordedrecorded(): RecordedBroadcast[]All recorded { channel, event, data } broadcasts.
resetreset(): voidClear recorded broadcasts.
assertBroadcastassertBroadcast(eventName, channel?, data?): voidAn event was broadcast (optionally on channel, with a partial data match).
assertNotBroadcastassertNotBroadcast(eventName, channel?): voidThat event was not broadcast.
assertNothingBroadcastassertNothingBroadcast(): voidNo broadcasts were recorded.
assertBroadcastCountassertBroadcastCount(count): voidExactly count broadcasts were recorded.

Next steps

  • Factories — where fake is most often used.
  • Events — the real emitter EventFake stands in for.
  • Queue — the real queue QueueFake stands in for.
  • Notifications — the real notifier NotificationFake stands in for, including the mail channel.
  • Storage — the disks Storage.fake() stands in for.
  • Carbon — the date-time API whose clock the time helpers move.
  • Console Tests — assert side effects of CLI commands.
  • Database — assert rows alongside faked side effects.