Skip to content

Testing your app

Most of what you want to test is producer-side: did my service enqueue the right job with the right payload? You should not need a worker, a database, or sleeps for that. effect-mq/testing ships TestJobStore for those assertions, and MemoryJobStore covers the integration-style tests that do run a worker.

Assert what your services enqueue

TestJobStore.layer provides a fresh in-memory store as both the default JobStore (for the code under test) and the TestJobStore inspection service (for your assertions). Nothing claims jobs, so they accumulate in waiting/delayed:

ts
import { TestJobStore } from "effect-mq/testing"
import { Effect } from "effect"

it.effect("signup enqueues a welcome email", () =>
  Effect.gen(function*() {
    yield* SignupService.register({ email: "ada@example.com" })

    const emails = yield* TestJobStore.enqueuedOf(SendEmail)
    expect(emails).toHaveLength(1)
    expect(emails[0]?.payload.to).toBe("ada@example.com")
    expect(emails[0]?.state).toBe("waiting")
  }).pipe(Effect.provide(TestJobStore.layer)))

Each Effect.provide(TestJobStore.layer) builds an isolated store, so tests never share state.

Decoded, typed payloads

enqueuedOf(JobClass) filters by the job's tag and decodes each payload back through the definition's own schema. You assert against the typed values your service produced: Redacted and DateTime fields come back as real instances, decoded from the JSON the store persists:

ts
class SendEmail extends Job.make("SendEmail", {
  payload: {
    to: Schema.String,
    apiKey: Schema.Redacted(Schema.String),
    at: Schema.DateTimeUtc
  }
}) {}

const emails = yield* TestJobStore.enqueuedOf(SendEmail)
expect(Redacted.value(emails[0]!.payload.apiKey)).toBe("secret")
expect(DateTime.toEpochMillis(emails[0]!.payload.at)).toBe(0)

Results come back oldest-first, the natural reading order for "it enqueued A, then B". Same-instant enqueues tie-break by id, so when many jobs land in one tick, assert on the set rather than the sequence.

Beyond the payload, each record surfaces its scheduling detail: state, priority, runAt, metadata, and dedupeKey, enough to assert that a job was delayed, prioritized, or deduplicated.

enqueued(name?) covers tests where you don't have (or don't want) the job class: it returns the raw JobRecords with encoded payloads, same ordering.

Named stores

Jobs bound to a JobStore.named(...) key don't use the default JobStore, so TestJobStore.layer won't satisfy them. Use layerFor with the same key:

ts
const Durable = JobStore.named("durable")

class Critical extends Job.make("Critical", {
  payload: { id: Schema.String },
  store: Durable
}) {}

it.effect("billing enqueues to the durable store", () =>
  Effect.gen(function*() {
    yield* Critical.enqueue({ id: "x" })
    const jobs = yield* TestJobStore.enqueuedOf(Critical)
    expect(jobs).toHaveLength(1)
  }).pipe(Effect.provide(TestJobStore.layerFor(Durable))))

WARNING

Each layerFor(...) call builds a fresh store. Provide one layer instance around both the enqueue and the assertion; separate instances give the assertion an empty store.

The raw store escape hatch

The inspection API also exposes the underlying JobStore service as .store, for scenarios richer than "what got enqueued", such as simulating claims and acks or reading counts():

ts
const api = yield* TestJobStore.TestJobStore
expect((yield* api.store.counts()).waiting).toBe(1)

TestJobStore's API is plain Effect; nothing couples it to a test runner. The effect-mq/testing entrypoint also ships the driver conformance suite, which imports @effect/vitest, so that (otherwise optional) peer must be installed for the import to resolve.

Integration tests: a real worker under TestClock

For end-to-end behavior (retries, delays, timeouts), run an actual worker against MemoryJobStore.layer. Everything in effect-mq takes time from the Effect Clock, so under @effect/vitest's it.effect the TestClock drives the whole pipeline: a one-hour delay is a virtual-time adjustment with zero real waiting.

ts
import { expect, it } from "@effect/vitest"
import { Effect, Layer, Schema } from "effect"
import { TestClock } from "effect/testing"
import { Job, MemoryJobStore, Worker } from "effect-mq"

/** Let all currently runnable fibers make progress. */
const settle = Effect.gen(function*() {
  for (let i = 0; i < 10; i++) yield* Effect.yieldNow
})

it.effect("delayed jobs only run once the delay elapses", () =>
  Effect.gen(function*() {
    const Later = Job.make("Later", { payload: { n: Schema.Number } })
    let ran = 0
    const handlers = Later.toLayer(() => Effect.sync(() => void ran++))

    yield* Effect.gen(function*() {
      yield* Later.enqueue({ n: 1 }, { delay: "5 seconds" })
      yield* settle
      yield* TestClock.adjust("4 seconds")
      yield* settle
      expect(ran).toBe(0)                 // still delayed

      yield* TestClock.adjust("1 second")
      yield* settle
      expect(ran).toBe(1)                 // claimed and run, zero real waiting
    }).pipe(Effect.provide(
      handlers.pipe(
        Layer.provideMerge(Worker.layer()),
        Layer.provideMerge(MemoryJobStore.layer)
      )
    ))
  }))

The settle helper yields the scheduler so worker fibers can react between clock adjustments, the same pattern effect-mq's own test suite uses.

This also works against real storage: the Postgres and Redis drivers take all time as bind parameters from the Clock, so the same TestClock-driven tests run against a real database. The conformance suite enforces that property on every driver.

Where to next

  • Enqueueing: the options (delay, priority, dedupe) your assertions inspect.
  • Workers & handlers: what the real worker does with those accumulated jobs.
  • Writing a driver: the conformance suite behind TestClock-on-real-storage.