28 - Testing Basics
Tests are the first place Effect’s structured failure model pays off. Every effect produces an Exit<A, E>, a value that encodes success, typed failure, and defects separately.
This step covers the two runners used in tests, the Exit guards that narrow the result, and the patterns for asserting on both success and failure paths.
What you’ll work through
index.test.ts contains three interactive beats — run-promise, run-promise-exit, and exit-guards — each with a stub asking for a runner call or guard. The Exit.match and full-example sections are read-run reference material.
By the end you should reach for runPromiseExit whenever a test inspects a failure, narrow on cause._tag === "Fail" before touching cause.error, and recognise an unhandled-rejection in a test for what it is: a missing exit-returning runner.
Exit Is the Result
Running an effect produces an Exit<A, E>. A throwing runner discards the structure and re-raises the failure; an exit-returning runner hands it back as a value.
runPromise(effect) Effect<A, E, never> ──────────────────────► A (throws on failure)
runPromiseExit(effect) Effect<A, E, never> ──────────────────────► Exit<A, E> ├── Success { value: A } └── Failure { cause: Cause<E> } ├── Fail { error: E } ├── Die { defect } └── Interrupt ...Two runners cover every test:
| Runner | Use for |
|---|---|
Effect.runPromise |
Happy path, await the success value. |
Effect.runPromiseExit |
Any path, inspect the Exit directly. |
The test runner in this project is
vite-plus/test. In your own project useimport { describe, it, expect } from "vitest".
Effect.runPromise
Returns Promise<A>. Resolves with the success value; rejects with the failure.
The R channel must be never, every requirement must be satisfied before running.
import { Effect } from "effect"
// ┌─── Promise<number>// ▼const result = await Effect.runPromise(Effect.succeed(42))// 42When to Use
When the test path is expected to succeed. A rejection bubbles up as an unhandled error in the test, which is the right signal, the effect under test failed when it should not have.
> runs. Assertions placed after the `runPromise` call execute against the > pending promise, not the result, and the test passes vacuously.Your turn await `Effect.runPromise(Effect.succeed(42))` so the test reads `42` from the resolved promise.
Effect.runPromiseExit
Returns Promise<Exit<A, E>>. Never rejects.
Use it when you need to assert on a failure, branch on the result, or inspect the typed error.
import { Effect, Exit } from "effect"
class AppError { readonly _tag = "AppError" constructor(readonly message: string) {}}
// ┌─── Promise<Exit<never, AppError>>// ▼const exit = await Effect.runPromiseExit( Effect.fail(new AppError("oops")))
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { exit.cause.reasons[0].error.message // "oops"}When to Use
When the test asserts on failure, when both branches need verifying, or when the test inspects the specific shape of the error.
Why this matters.
runPromiseExitnever rejects, so a failing effect stays inside the test as a value. The assertion inspects the typed error instead of fighting an unhandled rejection.
Your turn call `Effect.runPromiseExit` on the failing effect so the test receives an `Exit` value, not a rejection.
Exit.isSuccess and Exit.isFailure
Type guards that narrow Exit<A, E> into Exit.Success<A, E> or Exit.Failure<A, E>. After the guard, TypeScript exposes .value or .cause directly.
import { Effect, Exit } from "effect"
const exit = await Effect.runPromiseExit(Effect.succeed(42))
if (Exit.isSuccess(exit)) { // exit.value: number exit.value // 42}Details
exit.cause is a Cause<E>. A cause can be Fail (a typed error from Effect.fail), Die (a defect, usually a bug), or Interrupt.
Always narrow on cause._tag === "Fail" before reading cause.error.
Your turn narrow with `Exit.isSuccess` to read `.value`, then combine `Exit.isFailure` with `cause._tag === "Fail"` to reach `.cause.reasons[0].error`.
Exit.match
Branches on success vs failure in a single expression. The onFailure callback receives the full Cause<E>, match on its _tag to distinguish typed errors from defects.
import { Effect, Exit } from "effect"
class NotFound { readonly _tag = "NotFound"}
const exit = await Effect.runPromiseExit(Effect.fail(new NotFound()))
const label = Exit.match(exit, { onSuccess: (value) => `ok: ${value}`, onFailure: (cause) => cause._tag === "Fail" ? `err: ${cause.error._tag}` : "defect",})// "err: NotFound"When to Use
When the test computes a string or value from both branches, or when an if/else on _tag would clutter the assertion.
A Full Example
Wrapping an async function with Effect.tryPromise and asserting on both the success and the typed failure.
import { Effect, Exit } from "effect"
class FetchError { readonly _tag = "FetchError" constructor(readonly cause: unknown) {}}
const fetchName = (name: string) => Effect.tryPromise({ try: () => Promise.resolve(name), catch: (cause) => new FetchError(cause), })
const value = await Effect.runPromise(fetchName("Alice"))// "Alice"
const exit = await Effect.runPromiseExit( Effect.tryPromise({ try: () => Promise.reject(new Error("Network down")), catch: (cause) => new FetchError(cause), }))
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { exit.cause.reasons[0].error._tag // "FetchError"}The error in the Exit is the domain FetchError, not the raw Error thrown by the promise. Effect.tryPromise translated it at the boundary; the rest of the program sees the typed value.
Common Pitfalls
1, runPromise on a failure. The promise rejects, which surfaces as an unhandled rejection instead of a structured assertion. Use runPromiseExit whenever the test inspects a failure.
2, Missing await. Both runners return a Promise. Without await the test finishes before the effect runs and assertions inside .then(...) never execute, the test passes vacuously.
3, Reading cause.error without narrowing. A Cause can be Fail, Die, or Interrupt. Only Fail has .error.
Narrow with cause._tag === "Fail" (or use Exit.match) before touching the value.
4, try/catch around runPromise. Catching the rejection back into a try/catch discards the typed error. runPromiseExit is the idiomatic way to inspect failures.
and confirm the resolved value is `42`. - [ ] In the `run-promise-exit` beat, swap in `Effect.runPromiseExit(...)` so the failing effect returns an `Exit` instead of rejecting. - [ ] In the `exit-guards` beat, narrow with `Exit.isSuccess` and the combined `Exit.isFailure && cause._tag === "Fail"` check. - [ ] Read the `Exit.match` and full-example sections — note how the `_tag === "Fail"` narrowing recurs in both forms.
See Also
- Previous,
24-advanced-di: building the effects that will be tested. - Next,
29-test-layers: swapping service implementations to isolate tests. - Later,
48-cause-and-sandbox: the full shape ofCause<E>. - Later,
30-test-clock: deterministic tests for time-based logic.
Run the tests: pnpm test 28-testing-basics
The full test suite
The index.test.ts for this lesson, verbatim from steps/28-testing-basics/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Data, Exit } from "effect"
// =============================================================================// 15 - Testing Basics// =============================================================================//// Two runners cover every test:// Effect.runPromise — happy path; rejects on failure.// Effect.runPromiseExit — any path; resolves to Exit<A, E>.//// Exit guards (Exit.isSuccess, Exit.isFailure) narrow the result so the// .value or .cause field becomes accessible without an unsafe cast.
class AppError extends Data.TaggedError("AppError")<{ readonly message: string}> {}
describe("15 - Testing Basics", () => { // @beat // id: run-promise // shape: one-todo // title: Run an effect as a Promise // teaches: Effect.runPromise resolves with A on success and rejects on failure. // hint: await Effect.runPromise(Effect.succeed(42)) describe("Effect.runPromise", () => { it("resolves with the success value", async () => { // ┌─── Promise<number> // ▼ const result = await /* @stub */ Effect.runPromise(Effect.succeed(42)) /* @end */ expect(result).toBe(42) })
it("resolves with a value computed inside Effect.gen", async () => { const result = await Effect.runPromise( Effect.gen(function* () { const a = yield* Effect.succeed(10) const b = yield* Effect.succeed(20) return a + b }) ) expect(result).toBe(30) }) }) // @end-beat
// @beat // id: run-promise-exit // shape: one-todo // title: Inspect both branches with Exit // teaches: Effect.runPromiseExit resolves to Exit<A, E> and never rejects — failures arrive as values. // hint: use Effect.runPromiseExit and then Exit.isFailure(exit) describe("Effect.runPromiseExit", () => { it("resolves to a Success exit on a successful effect", async () => { const exit = await Effect.runPromiseExit(Effect.succeed(1)) expect(Exit.isSuccess(exit)).toBe(true) })
it("resolves to a Failure exit on a failed effect", async () => { const exit = await /* @stub */ Effect.runPromiseExit(Effect.fail("boom")) /* @end */ expect(Exit.isFailure(exit)).toBe(true) })
it("never rejects — failures arrive as values", async () => { // runPromise would reject; runPromiseExit always resolves. const exit = await Effect.runPromiseExit( Effect.fail(new AppError({ message: "oops" })) ) expect(exit._tag).toBe("Failure") }) }) // @end-beat
// @beat // id: exit-guards // shape: multi-todo // title: Narrow with Exit.isSuccess and Exit.isFailure // teaches: Exit guards narrow the result so .value or .cause is accessible without a cast; for a Fail cause, narrow on cause._tag. // hint: combine Exit.isFailure with cause._tag === "Fail" to read cause.error describe("Exit.isSuccess", () => { it("narrows the exit so .value is accessible", async () => { const exit = await Effect.runPromiseExit(Effect.succeed(42))
if (/* @stub */ Exit.isSuccess(exit) /* @end */) { // exit.value: number expect(exit.value).toBe(42) } else { throw new Error("expected a Success exit") } }) })
describe("Exit.isFailure", () => { it("narrows the exit so .cause is accessible", async () => { const exit = await Effect.runPromiseExit( Effect.fail(new AppError({ message: "oops" })) )
if (/* @stub */ Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail" /* @end */) { // exit.cause.reasons[0].error: AppError expect(exit.cause.reasons[0].error._tag).toBe("AppError") expect(exit.cause.reasons[0].error.message).toBe("oops") } else { throw new Error("expected a Fail cause") } }) }) // @end-beat
describe("Exit.match", () => { it("branches on success", async () => { const exit = await Effect.runPromiseExit(Effect.succeed(10))
const label = Exit.match(exit, { onSuccess: (value) => `ok: ${value}`, onFailure: () => "err", })
expect(label).toBe("ok: 10") })
it("branches on failure and inspects the typed error", async () => { const exit = await Effect.runPromiseExit( Effect.fail(new AppError({ message: "not found" })) )
const label = Exit.match(exit, { onSuccess: (value) => `ok: ${value}`, onFailure: (cause) => { const reason = cause.reasons[0] return reason?._tag === "Fail" ? `err: ${reason.error.message}` : "defect" }, })
expect(label).toBe("err: not found") }) })
describe("Effect.tryPromise", () => { it("resolves with the value when the promise resolves", async () => { const fetchName = () => Effect.tryPromise({ try: () => Promise.resolve("Alice"), catch: (cause) => new AppError({ message: String(cause) }), })
const result = await Effect.runPromise(fetchName()) expect(result).toBe("Alice") })
it("places a rejection into the typed E channel", async () => { const exit = await Effect.runPromiseExit( Effect.tryPromise({ try: () => Promise.reject(new Error("Network down")), catch: (cause) => new AppError({ message: `Fetch failed: ${cause}` }), }) )
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("AppError") expect(exit.cause.reasons[0].error.message).toContain("Network down") } else { throw new Error("expected a Fail cause") } }) })})