← website
Run it

Testing

Two halves. First, every one of the 676 cases the NotifKit repository already runs, grouped by what it defends — so you know which failures are covered and do not spend your budget re-proving them. Then how to test the part nobody else can: your templates, your consent data, and your own transports.

What is already covered

The suite that ships with the repository is 676 test cases across 31 files — 673 unit and integration cases plus the three chaos scenarios below. Vitest runs them with a coverage floor of 80% on lines, functions, branches and statements; the build fails below that. Everything in this section already runs on every commit.

Grouped by what they defend, rather than by filename. Counts are cases, not assertions — most cases assert several things.

The HTTP surface — 180 cases

SuiteCasesWhat it pins down
api-suppressions.test.ts 58 The compliance handlers: suppression list, create and delete; campaign listing and the full stats funnel including the engagementTracked warnings; notification status and cancellation; and both unsubscribe routes — that GET only renders and never opts anyone out, that POST does, and that a tampered or unsigned token is rejected identically to a valid one for a stranger.
api.test.ts 52 Every handler in isolation — auth token extraction from both header forms, all CRUD endpoints, the four notify targeting modes, workflow trigger including the inline-user shorthand, the limit/channel/topic filters on template and workflow listing, and router parameter decoding.
api-admin.test.ts 30 The operational routes: health degrading independently for Redis and Postgres, worker heartbeats going silent, stream depths and the computed success rate, the whole DLQ lifecycle (list, replay onto the right priority stream, delete, and each one's failure mode), workflow definition create and cancel, scheduled payloads, user details, and the SSE stream — including that it drops events belonging to another project and unsubscribes on client disconnect.
api.integration.test.ts 27 The same endpoints against a real Postgres and Redis in throwaway containers: project bootstrap with the admin key, key rejection, template sync, user and contact lifecycle, preferences, logs, workflow trigger, event ingest, and all three probe routes.
http.test.ts 13 The transport plumbing under the handlers: body reads across chunked streams, the 5 MB cap returning 413 and destroying the request, malformed JSON becoming a 400, Content-Length computed in bytes rather than characters so a multibyte subject line does not truncate, and Zod issues shaped into the documented error body.

The pipeline workers — 158 cases

SuiteCasesWhat it pins down
workers.test.ts 101 The decisions each worker makes. Quiet hours across timezones and DST, including the release instant it computes. Per-user throttling, priority bypass, and per-project overrides with their settings cache. The engine's gate sequence and a 12-case suppression gate — that a lookup failure never degrades to "nothing is suppressed". Delivery across 27 cases: provider ordering, provider-level rate limiting, the push path and invalid-token deactivation, channel fallback, retry counting and the hand-off to the DLQ. Plus enricher fan-out, the scheduler ZSET, AI routing, and the event worker's waiter matching with nested dot-notation criteria.
scheduler-poll.test.ts 22 The poll loop on its own. The lock is taken with NX and an expiry so a crashed poller cannot hold it, is released only if still owned, and is released even when the poll throws. All 16 shards are claimed with a visibility timeout, a shard whose Lua errored is skipped rather than failing the poll, and a full shard asks for another round while shards that merely add up do not. Then release: routing by priority, the fallback when a priority has no producer, and cleanup of a corrupt payload, a payload missing from Postgres, and a failed Postgres delete.
workers-workflow.test.ts 21 The workflow worker's edge cases: unknown event types and missing handlers, a lock it cannot acquire and a concurrent resume collision, every duration unit on step.wait and waitForEvent including 7d, an invalid unit failing the instance rather than expiring instantly, a timed-out step staying null on replay, step.run memoisation and its throw path, JSON workflows executed from the database, unknown step actions, and the lock always released in finally.
workers-ai.test.ts 14 Prompt interpolation, generated content landing in the template, the per-notification prompt cap, invalid payloads dropped rather than retried, and error classification so a permanent failure is not retried and re-billed.

Queue, delivery and shared primitives — 131 cases

SuiteCasesWhat it pins down
shared-primitives.test.ts 38 The five building blocks everything else rests on, each tested for its failure behaviour rather than its happy path. AsyncSemaphore: FIFO ordering, permits handed straight to the next waiter, and an unbalanced release that must not bank a spare permit. LRUCache: eviction order, recency on read, per-entry TTL, and an expired entry freeing its slot. BatchProcessor: size and time flushes, per-index resolution, a whole batch rejecting together, and recovery on the next one. DataLoader: same-tick coalescing and per-key error slots. CircuitBreaker: opening at the threshold, single-flight probing, and re-opening on a failed probe.
queue.test.ts 36 Consumer-group creation, acks and nacks, batch reads across multiple streams, the pending scanner and autoclaim, batch publishing, and the idempotency guard.
registry.test.ts · providers.test.ts 13 · 21 Transport priority ordering and lookup by channel; the Resend and FCM transports against mocked SDKs, invalid-token detection, webhook signature verification including tampered and unsigned bodies, and the per-template sender — that a template's from wins, that the constructor's is used when it names none, and that a non-string falls back rather than being coerced.
shared.test.ts · idempotency.test.ts 10 · 7 Id generation and the error hierarchy; the idempotency guard's SET NX semantics, custom TTLs, and the unconditional markProcessed the engine uses to close out a skipped message.
race-conditions.test.ts 6 The same primitives under real contention, and the only suite where a passing assertion means "exactly one". 20 concurrent callers on one idempotency key admit exactly one; 25 concurrent reads of a cold project collapse into one database load, and a rejected in-flight load does not poison the callers behind it; 20 callers against an open breaker send one probe; 100 concurrent batch items each resolve to their own index; 50 tasks through a semaphore leave an active count of zero.

Data, templates and compliance — 95 cases

SuiteCasesWhat it pins down
repositories.units.test.ts 36 Every repository's mapping and its project guard, against a recording fake of the drizzle client so the assertion is on the query that was built. That an absent preference row means opted in; that contacts resolve in a single join rather than a query per user; that a template, instance, or key belonging to another project is invisible; that a project delete cascades across users, contacts, suppressions, logs and workflows; and that an API key is stored only as a hash.
unsubscribe.test.ts 27 The signed-token scheme, mostly in the negative: a wrong secret, a tampered payload, a tampered signature, a signature of the wrong length, and six shapes of malformed token are all rejected without throwing. Tokens never expire, are deterministic, and are URL-safe. Then the RFC 8058 headers — both of them, angle-bracketed, with a token the verifier accepts and no doubled slash — and target normalisation, so a re-suppressed address collides on conflict instead of duplicating.
repositories.mock.test.ts · repositories.test.ts 9 · 5 Serialization-failure retry on the bulk user upsert and its exhaustion path; then reads and writes against a real database, including that a lookup in one project cannot see another's rows.
templates-cache.test.ts · templates.test.ts 10 · 8 Cache miss, fill, and the two invalidation paths, plus that a repository miss is not cached as null. Renderer registration and fallback, HTML escaping on by default, missing variables rendering empty rather than literal, and interpolation through nested objects and arrays.

The SDK and the contracts — 109 cases

SuiteCasesWhat it pins down
client.test.ts 64 Every method on NotifkitClient, asserting the path, verb, body and headers it puts on the wire, and that a non-2xx becomes an Error carrying the server's message. Covers the newer methods too — notification status and cancellation, scheduled messages, user profile and preferences, template querying, and system health, metrics and DLQ.
workflows.test.ts · workflows-sdk.test.ts 13 · 11 Suspend on step.wait, deterministic replay skipping already-executed steps, waitForEvent resolving and timing out; then target resolution — segment, topic, user as string or object, an array rejected, and the fall-back to the instance's own user with a clear throw when there is nobody to send to.
regressions.test.ts 15 Named after the defects they lock down. See below.
contracts.test.ts · phase3.test.ts 3 · 3 Stream payload schemas accept valid events and reject malformed ones.

The chaos suite

Three tests that do not assert on logic at all — they assert that messages survive things going wrong. All three need Docker, and they are slow on purpose.

TestWhat it doesWhat it asserts
chaos/crash.test.ts Runs workers as separate child processes and SIGKILLs one to three of them at random, repeatedly, while 100 notifications are in flight. Every message is still delivered once the survivors reclaim the pending entries.
chaos/recovery.test.ts Injects 5,000 messages while docker pauseing Postgres, Redis, or both at random intervals for a few seconds at a time. Nothing is lost across the freezes — injection retries and the pipeline drains afterwards.
chaos/load.test.ts Pushes 10,000 messages through the full pipeline, sampling heap as it goes. All 10,000 delivered, and heap growth stays under 1 GB — the leak check.
docker

Five files need a Docker daemon: the three chaos scenarios, api.integration.test.ts, and repositories.test.ts. The last two spin up throwaway Postgres and Redis containers in beforeAll and do not self-skip, so without Docker they fail on a hook timeout after one and two minutes respectively rather than reporting "skipped" — that failure is your environment, not the code.

The other 26 files, 641 cases, need no daemon at all and finish in under ten seconds. npm test runs everything including chaos — there is no separate script excluding it, and the Vitest timeout is 20 minutes to accommodate it. For the fast, hermetic set:

npx vitest run \
  --exclude "tests/chaos/**" \
  --exclude "tests/api.integration.test.ts" \
  --exclude "tests/repositories.test.ts"

Regressions, pinned to the bug

regressions.test.ts names each case after the defect it locks down, so a failure tells you which old bug came back:

IdThe bug it prevents
P1-5Template injection — a variable containing a quote breaking the JSON, or forging a sibling template field. Also covers HTML-escaping values that land in html but not text, and stripping CR/LF so a value cannot inject a header.
P1-8Throttle keys not namespaced by project, letting one tenant's volume throttle another's.
P2-13Pending messages claimed against the wrong stream in multi-stream mode.
P0-3The scheduler's Lua poll erroring on a three-argument call because the visibility timeout had no default.
P1-11Retrying a permanent AI error and being billed again for it. Timeouts, rate limits and 5xx still retry; 4xx does not.
P0-2A .native.native mis-reference in the worker Redis wiring.
P0-2 + P0-4The engine and the scheduler drifting apart: the scheduled event the engine emits is validated against the real schema the scheduler reads it with, so a field added on one side cannot silently strand messages on the other.

What the suite does not cover

Worth being explicit, because these are exactly the gaps your own tests should fill:

  • Real provider APIs. Resend and FCM are mocked at the SDK boundary. Nothing here proves your API key works or that your domain is verified.
  • Your templates. No test knows whether {{trackingUrl}} is a key you actually send.
  • Your consent data. Whether the right users are opted out of the right topics is a property of your data, not the engine.
  • Transports you write. The registry is tested; your send() is not.
  • The dashboard. The Next.js app under dashboard/ has no suite in this count.
  • The MCP server. packages/mcp has no suite either. The endpoints it calls are covered; the tool definitions wrapping them are not.
  • Live quiet-hours and scheduling. The release instant is computed and asserted, but no test sleeps until it arrives — that the scheduler fires on the wall clock is what the chaos and integration runs exercise, not a unit case.

Running it yourself

git clone https://github.com/devkitshq/notifkit.git
cd notifkit
npm install

npm test                                  # everything, with coverage
npx vitest run tests/workers.test.ts      # one file, no Docker needed
npx vitest run tests/chaos                # the slow ones, Docker required
npx vitest                                # watch mode
npx vitest list                           # print all 676 case names without running them
npx vitest run -t "suppression gate"      # every case whose full name matches

vitest list is the fastest way to answer "is this behaviour already covered?" — the case names in this suite are written as sentences about behaviour, so grepping them usually settles it without opening a file.

The rest of this page is how to cover what the suite above cannot.

Writing your own tests

Notification code is awkward to test because the interesting outcome happens somewhere else, later, and often the correct behaviour is that nothing was sent. NotifKit gives you a seam at the transport, which is where nearly all of your assertions belong.

The order to build it in

The three sections after this one give you the pieces, and they only make sense in order. Build them once and every later test is four lines:

  1. A capturing transport — records instead of sending. No Docker, no provider account.
  2. A server fixture — one NotifkitServer per test file, with a project bootstrapped and a client pointed at it.
  3. A wait helper — because notify() returns long before anything is delivered, so a bare assertion after the call always fails.

With those in place, the single highest-value assertion you can write is that no placeholder survived rendering:

it("leaves no unrendered placeholder", async () => {
  email.reset();
  await client.addUser({ id: "usr_1", email: "a@example.com" });

  const delivered = waitForDelivery(server);
  await client.notify({
    user: "usr_1",
    template: "welcome",
    channels: ["email"],
    data: { name: "Alice", company: "Acme" },
  });
  await delivered;

  expect(JSON.stringify(email.contents[0])).not.toContain("{{");
});
gotcha

An unmatched placeholder renders as an empty string rather than throwing. A typo in a data key is therefore silent in production, and invisible to a happy-path test that only checks a delivery happened. This assertion is two lines and catches the whole class.

Three levels

LevelNeedsAnswers
Transport double Nothing Did we ask for the right thing? Right template, right channel, right data.
Full pipeline Docker + testcontainers Did the rules apply? Opt-outs, quiet hours, fallback, rendering.
Contract Provider sandbox Does our transport speak the provider's dialect correctly?

Most of your suite should sit at the first level. The second is where you verify the parts of the system you did not write, and it is worth having a handful of those.

A capturing transport

The whole test double is a class that records instead of sending. Because the Delivery worker only knows the Transport interface, this substitutes cleanly for a real provider.

import type {
  Transport, NotificationDispatchedPayload, DeliveryResult, NotificationChannel,
} from "notifkit";

export class CapturingTransport implements Transport {
  readonly sent: NotificationDispatchedPayload[] = [];

  constructor(
    readonly channel: NotificationChannel = "email",
    private readonly outcome: DeliveryResult = { success: true, providerMessageId: "test-1" },
  ) {}

  async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
    this.sent.push(task);
    return this.outcome;
  }

  // Convenience for assertions
  get contents() {
    return this.sent.map((t) => t.renderedContent.content as Record<string, string>);
  }
  reset() { this.sent.length = 0; }
}

Failure is one constructor argument away:

const flakyPush = new CapturingTransport("push", { success: false, error: "provider down" });
const deadToken = new CapturingTransport("push", { success: false, invalidToken: true });

Testing the whole pipeline

NotifkitServer starts throwaway Postgres and Redis containers when you do not give it URLs, so an end-to-end test needs no fixture of your own. Two prerequisites, though. The first: the testcontainers packages have to be installed. NotifKit keeps them as dev dependencies and marks them external so they stay out of the published bundle — installing notifkit does not bring them with it.

npm install -D @testcontainers/postgresql @testcontainers/redis

The second: the container path is gated on NODE_ENV. Anything other than production gets containers; production throws Missing required configuration instead. Set it explicitly with the server's nodeEnv option rather than trusting the ambient value, which CI often pins to production. Docker has to be running either way.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { NotifkitServer, NotifkitClient } from "notifkit";
import { CapturingTransport } from "./capturing-transport.js";

const PORT = 34567;
const email = new CapturingTransport("email");

let server: NotifkitServer;
let client: NotifkitClient;

beforeAll(async () => {
  process.env.ADMIN_API_KEY = "test-admin-key";

  server = new NotifkitServer({
    services: ["all"],
    nodeEnv: "development",
    port: PORT,
    logLevel: "silent",
    providers: [email],
  });
  await server.start();

  // Bootstrap a project and take its key.
  const res = await fetch(`http://localhost:${PORT}/v1/projects`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin-key" },
    body: JSON.stringify({ name: "test" }),
  });
  const { apiKey } = await res.json();

  client = new NotifkitClient({ baseUrl: `http://localhost:${PORT}`, apiKey });

  await client.syncTemplates({
    templates: [{
      id: "welcome",
      channel: "email",
      topic: ["transactional"],
      content: { subject: "Hi {{name}}", text: "Welcome, {{name}}." },
    }],
  });
}, 120_000);

afterAll(async () => { await server.stop(); });
timing

Pulling images takes a while on a cold machine. Raise the hook timeout — testTimeout: 120_000 and hookTimeout: 240_000 are realistic — and share one server across the file rather than starting one per test.

Waiting for something asynchronous

notify() returns before anything is delivered, so a bare assertion after the call always fails. Wait on the server's events rather than on a sleep:

export function waitForDelivery(server: NotifkitServer, timeoutMs = 10_000) {
  return new Promise<{ taskId: string; channel: string }>((resolve, reject) => {
    const timer = setTimeout(() => {
      server.off("delivery:delivered", onDone);
      reject(new Error("no delivery within timeout"));
    }, timeoutMs);

    function onDone(taskId: string, _providerMessageId: string, channel: string) {
      clearTimeout(timer);
      server.off("delivery:delivered", onDone);
      resolve({ taskId, channel });
    }

    server.on("delivery:delivered", onDone);
  });
}
it("renders the template and delivers it", async () => {
  await client.addUser({ id: "usr_1", email: "a@example.com" });

  const delivered = waitForDelivery(server);
  await client.notify({
    user: "usr_1",
    template: "welcome",
    channels: ["email"],
    data: { name: "Alice" },
  });
  await delivered;

  expect(email.sent).toHaveLength(1);
  expect(email.sent[0]!.destination).toBe("a@example.com");
  expect(email.contents[0]).toMatchObject({
    subject: "Hi Alice",
    text: "Welcome, Alice.",
  });
});

Asserting that nothing was sent

Half of what you want to prove is a negative — the opt-out worked, the quiet hours held. Those have no delivery event to wait on, so listen for the skip instead:

function waitForSkip(server: NotifkitServer, timeoutMs = 10_000) {
  return new Promise<{ reason: string }>((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error("no skip event")), timeoutMs);
    server.once("notification:skipped", (payload: any) => {
      clearTimeout(timer);
      resolve(payload);
    });
  });
}

it("does not send to a user who opted out of the topic", async () => {
  await client.addUser({
    id: "usr_2",
    email: "b@example.com",
    preferences: { topics: { transactional: false } },
  });

  email.reset();
  const skipped = waitForSkip(server);
  await client.notify({ user: "usr_2", template: "welcome", channels: ["email"] });

  expect((await skipped).reason).toBe("user_opted_out");
  expect(email.sent).toHaveLength(0);
});
EventFires when
delivery:deliveredA transport reported success.
delivery:failedEvery transport for the channel failed, and no fallback remained.
notification:skippedA gate dropped it. Carries reason.
notification:throttledThe per-user hourly cap was hit.
notification:failedRetries exhausted; the message went to the DLQ.
notification:canceledA scheduled task was cancelled before dispatch.

Testing a fallback chain

const push = new CapturingTransport("push", { success: false, error: "device unreachable" });
const email = new CapturingTransport("email");

// server started with providers: [push, email]

it("rolls over to email when push fails", async () => {
  await client.addUser({ id: "usr_3", email: "c@example.com", pushToken: "tok_dead" });

  const delivered = waitForDelivery(server, 20_000);
  await client.notify({
    user: "usr_3",
    template: "welcome",
    channels: ["push", "email"],
    fallback: true,
  });

  expect((await delivered).channel).toBe("email");
  expect(push.sent).toHaveLength(1);    // tried first
  expect(email.sent).toHaveLength(1);   // then rolled over
});

Give fallback tests a longer timeout than single-channel ones. Rollover re-enters the pipeline at the enriched stream, so it goes through the Engine a second time.

Testing time-dependent behaviour

Fake timers do not help here — the deferral lives in Redis and is released by a poller in another part of the system. Assert on the decision rather than on the eventual send:

it("defers a normal-priority send during quiet hours", async () => {
  await client.addUser({
    id: "usr_4",
    email: "d@example.com",
    timezone: "UTC",
    // A window that certainly contains "now".
    preferences: { quietHours: [{ start: "00:00", end: "23:59" }] },
  });

  email.reset();
  await client.notify({ user: "usr_4", template: "welcome", channels: ["email"] });

  await new Promise((r) => setTimeout(r, 2000));
  expect(email.sent).toHaveLength(0);                       // held, not sent

  const { scheduled } = await fetch(`${base}/v1/notifications/scheduled`, { headers })
    .then((r) => r.json());
  expect(scheduled.length).toBeGreaterThan(0);              // parked for later
});

it("lets a critical send through quiet hours", async () => {
  const delivered = waitForDelivery(server);
  await client.notify({
    user: "usr_4", template: "welcome", channels: ["email"], priority: "critical",
  });
  await expect(delivered).resolves.toBeTruthy();
});

Testing your own transport in isolation

A transport is a plain class. Give it a payload and assert on what it does — no server, no containers, no Docker.

import type { NotificationDispatchedPayload } from "notifkit";

const task = {
  projectId: "p_1",
  taskId: "t_1",
  enrichedEventId: "e_1",
  recipientId: "usr_1",
  channel: "webhook",
  priority: "normal",
  templateId: "welcome",
  templateVariables: { name: "Alice" },
  recipient: { id: "usr_1", locale: "en", timezone: "UTC", preferences: {} },
  renderedContent: { content: { text: "Welcome, Alice." } },
  destination: "https://hooks.example.com/abc",
  deliveryOptions: { maxAttempts: 3, timeoutMs: 10_000 },
} as unknown as NotificationDispatchedPayload;

it("posts the rendered text and reports the provider id", async () => {
  const fetchMock = vi.fn().mockResolvedValue(
    new Response(null, { status: 200, headers: { "x-slack-req-id": "req_9" } }),
  );
  vi.stubGlobal("fetch", fetchMock);

  const result = await new SlackTransport().send(task);

  expect(result).toEqual({ success: true, providerMessageId: "req_9" });
  expect(JSON.parse(fetchMock.mock.calls[0]![1].body)).toEqual({ text: "Welcome, Alice." });
});

Testing workflows

Workflows suspend on wait, and the wake-up poller runs every five seconds — so real durations make for slow tests. Use short waits, and assert on step outputs rather than on elapsed time.

workflow("test-flow", async ({ step, event }) => {
  await step.notify({ template: "welcome", channels: ["email"] });
  await step.wait(process.env.NODE_ENV === "test" ? "1s" : "3d");
  await step.notify({ template: "follow-up", channels: ["email"] });
});

it("sends both messages across the wait", async () => {
  const { instanceId } = await client.triggerWorkflow({
    name: "test-flow",
    input: { user: { id: "usr_5" } },
  });

  await vi.waitFor(async () => {
    const instance = await client.getWorkflow(instanceId);
    expect(instance.status).toBe("completed");
  }, { timeout: 30_000, interval: 1000 });

  expect(email.sent).toHaveLength(2);
});
gotcha

Workflow handlers are registered in an in-process registry, so the test file must import the module that calls workflow() before triggering. A workflow that silently does nothing is almost always a missing import.

Habits that keep the suite honest

  • Unique user ids per test. Idempotency guards run for 24 hours; reusing an id across tests makes the second one mysteriously do nothing.
  • logLevel: "silent". The pipeline is chatty, and useful failures get buried.
  • One server per file. Container startup dominates the runtime; beforeAll, not beforeEach.
  • Reset transports between tests rather than recreating the server.
  • Never assert immediately after notify(). Wait on an event, or poll with vi.waitFor. A bare setTimeout is the flakiest thing you can write here.
  • Keep the throttle in mind. A hundred sends to one user in a single test hits the hourly cap and the rest vanish.