Scenario

Learn how to write effective Probitas scenarios with this comprehensive guide covering the builder API, step context, resources, and best practices.

Basic Structure

A Probitas scenario follows this structure:

import { client, expect, scenario } from "probitas";

export default scenario("User API CRUD", {
  tags: ["integration", "api"],
})
  .resource("http", () =>
    client.http.createHttpClient({
      url: "http://localhost:8080",
    }))
  .setup(async (ctx) => {
    // Create test user before steps
    const { http } = ctx.resources;
    await http.post("/users", { name: "test-user" });

    return async () => {
      // Cleanup: delete test user after steps
      await http.delete("/users/test-user");
    };
  })
  .step("Get user", async (ctx) => {
    const { http } = ctx.resources;
    const res = await http.get("/users/test-user");
    expect(res).toHaveStatus(200);
    return res.json();
  })
  .build();

Export Pattern

Scenarios must be exported as the default export:

// Single scenario
export default scenario("Name").step(...).build();

You can also export multiple scenarios as an array:

// Multiple scenarios
export default [
  scenario("First").step(...).build(),
  scenario("Second").step(...).build(),
];

Scenario Builder API

scenario(name, options?)

Creates a new scenario builder.

Parameter Type Description
name string Human-readable identifier for your test
options? object Optional configuration (see below)

See Configuration for detailed options including tags, timeout, and retry settings.

.step(name?, fn, options?)

Adds a step to the scenario. Steps run sequentially, and each receives the result of the previous step.

Parameter Type Description
name? string Optional step name (auto-generated as "Step 1", "Step 2" if omitted)
fn (ctx) => T Async function that receives a context object and returns a result
options? object Per-step timeout and retry (see Configuration)
// Named step
.step("Step name", async (ctx) => {
  return result;
})

// Unnamed step (auto-named as "Step 1", "Step 2", etc.)
.step(async (ctx) => {
  return result;
})

// With options
.step("Step name", async (ctx) => {
  return result;
}, {
  timeout: 5000,
  retry: { maxAttempts: 3, backoff: "exponential" }
})

.resource(name, factory, options?)

Registers a resource with lifecycle management. Resources are created before steps run and automatically disposed after the scenario completes.

Parameter Type Description
name string Name to access this resource via ctx.resources.name
factory (ctx) => T Function that creates and returns the resource
options? object Per-resource timeout and retry (see Configuration)
.resource("http", () =>
  client.http.createHttpClient({
    url: "http://localhost:8080",
  })
)

// With options
.resource("db", () =>
  client.sql.postgres.createPostgresClient({ ... }),
  { timeout: 10000 }
)

Resource Behavior

  • Resources are initialized in declaration order
  • Resources implementing Disposable or AsyncDisposable are auto-disposed
  • Resources are available to subsequent steps, setups, and other resources
  • Disposal happens in reverse order after scenario completion

.setup(name?, fn, options?)

Registers a setup hook that runs before steps. Can return a cleanup function that runs after all steps complete (even on failure).

Parameter Type Description
name? string Optional setup name (auto-generated as "Setup step 1", etc. if omitted)
fn (ctx) => Cleanup? Setup function that optionally returns a cleanup function or Disposable
options? object Per-setup timeout and retry (see Configuration)
// Named setup with cleanup function
.setup("Seed test data", async (ctx) => {
  const { db } = ctx.resources;
  await db.query("INSERT INTO test_data ...");

  return async () => {
    await db.query("DELETE FROM test_data ...");
  };
})

// Unnamed setup (auto-named as "Setup step 1", etc.)
.setup(async (ctx) => {
  const { db } = ctx.resources;
  await db.query("INSERT INTO test_data ...");
})

// Setup with Disposable
.setup((ctx) => {
  const resource = createResource();
  return {
    [Symbol.dispose]() {
      resource.close();
    }
  };
})

// Setup with options
.setup("Long setup", async (ctx) => {
  await prepareTestEnvironment();
}, { timeout: 60000 })

.build()

Finalizes the scenario and returns an immutable definition. Always call this at the end of the builder chain.

Step Context

Every step, resource factory, and setup function receives a context object (ctx) with these properties:

Property Type Description
previous T Return value from the immediately preceding step
results tuple Tuple containing ALL previous step results in order
resources object Object containing all registered resources by name
store Map Shared Map that persists across all steps
signal AbortSignal Fires when the step times out; pass to cancelable operations
index number Zero-based index of the current step

ctx.previous

Use this to chain data between steps:

.step("Create user", async (ctx) => {
  return { id: 1, name: "Alice" };
})
.step("Update user", async (ctx) => {
  const user = ctx.previous; // { id: 1, name: "Alice" }
  return { ...user, updated: true };
})

ctx.results

Useful when you need data from steps other than the immediately previous one:

.step("Step 1", () => "first")
.step("Step 2", () => 42)
.step("Step 3", (ctx) => {
  const [step1, step2] = ctx.results;
  // step1: "first"
  // step2: 42
})

ctx.resources

Access registered resources:

.resource("http", () => createHttpClient(...))
.resource("db", () => createPostgresClient(...))
.step("Use resources", async (ctx) => {
  const { http, db } = ctx.resources;
  // Both clients are available
})

ctx.store

Pass data that doesn't fit the step return value pattern:

.step("Save to store", (ctx) => {
  ctx.store.set("key", "value");
})
.step("Read from store", (ctx) => {
  const value = ctx.store.get("key"); // "value"
})

ctx.signal

Pass to fetch calls or other cancelable operations:

.step("Long operation", async (ctx) => {
  const response = await fetch(url, { signal: ctx.signal });
  return response.json();
})

Resources

Resources are dependencies like database connections or HTTP clients that need proper setup and teardown.

Basic Pattern

Register a resource with a factory function. The resource becomes available to all subsequent steps via ctx.resources.

.resource("name", (ctx) => {
  // Create and return resource
  return client.http.createHttpClient({ url: "..." });
})

Resource Dependencies

Resources can depend on earlier resources. They're created in declaration order.

.resource("config", () => ({
  url: Deno.env.get("API_URL") ?? "http://localhost:8080",
}))
.resource("http", (ctx) => {
  const { config } = ctx.resources;
  return client.http.createHttpClient({ url: config.url });
})

Auto-Disposal

All Probitas clients implement AsyncDisposable, so they're automatically cleaned up when the scenario ends.

// All Probitas clients implement AsyncDisposable
.resource("http", () => client.http.createHttpClient(...))
// Automatically disposed after scenario completes

Setup and Cleanup

Setup hooks prepare the test environment before steps run. Cleanup functions restore the environment afterward.

Multiple Setups

You can chain multiple setup hooks. Each can return a cleanup function.

scenario("Multi-setup")
  .resource("db", () => createPostgresClient(...))
  .setup(async (ctx) => {
    // First setup: create schema
    await ctx.resources.db.query("CREATE TABLE IF NOT EXISTS ...");
  })
  .setup(async (ctx) => {
    // Second setup: seed data
    await ctx.resources.db.query("INSERT INTO ...");
    return async () => {
      // Cleanup: remove seeded data
      await ctx.resources.db.query("DELETE FROM ...");
    };
  })
  .step("Test with data", async (ctx) => {
    // Schema and data are ready
  })
  .build();

Cleanup Order

Cleanup functions run in reverse order (last setup's cleanup runs first). This ensures proper teardown of dependent resources.

.setup(() => {
  console.log("Setup 1");
  return () => console.log("Cleanup 1"); // Runs last
})
.setup(() => {
  console.log("Setup 2");
  return () => console.log("Cleanup 2"); // Runs first
})

// Output:
// Setup 1
// Setup 2
// (steps run)
// Cleanup 2
// Cleanup 1

Skip and Error Handling

Skipping Scenarios

Throw Skip to conditionally skip the remaining steps. This is useful for environment-specific tests.

import { Skip } from "probitas";

.step("Check precondition", () => {
  if (!Deno.env.get("INTEGRATION_ENABLED")) {
    throw new Skip("Integration tests disabled");
  }
})
.step("Integration test", async (ctx) => {
  // This step is skipped if Skip was thrown
})

You can also skip from resources or setup hooks:

.resource("external", () => {
  if (!checkExternalService()) {
    throw new Skip("External service unavailable");
  }
  return createExternalClient();
})

Error Handling

When a step throws an error, the scenario fails but cleanup still runs. This ensures resources are properly disposed.

.setup(() => {
  return () => console.log("Cleanup runs even on error");
})
.step("Failing step", () => {
  throw new Error("Step failed");
})
// Cleanup still executes

Retry on Failure

For flaky operations, configure automatic retries. See Configuration for detailed retry options.

.step("Flaky operation", async (ctx) => {
  const res = await http.get("/sometimes-fails");
  expect(res).toBeSuccessful();
  return res.json();
}, {
  retry: { maxAttempts: 3, backoff: "exponential" }
})

Complete Examples

HTTP API Testing

A typical CRUD test that creates, reads, updates, and deletes a resource.

import { client, expect, scenario } from "probitas";

export default scenario("User CRUD API", { tags: ["api", "integration"] })
  .resource("http", () =>
    client.http.createHttpClient({
      url: "http://localhost:8080",
    }))
  .step("Create user", async (ctx) => {
    const { http } = ctx.resources;
    const res = await http.post("/users", {
      name: "Alice",
      email: "alice@example.com",
    });
    expect(res).toBeSuccessful().toHaveStatus(201).toHaveContentContaining({
      name: "Alice",
    });
    return res.json<{ id: number }>();
  })
  .step("Get user", async (ctx) => {
    const { http } = ctx.resources;
    const { id } = ctx.previous;
    const res = await http.get(`/users/${id}`);
    expect(res).toBeSuccessful().toHaveStatus(200).toHaveContentContaining({
      id,
      name: "Alice",
    });
    return res.json();
  })
  .step("Update user", async (ctx) => {
    const { http } = ctx.resources;
    const { id } = ctx.previous;
    const res = await http.patch(`/users/${id}`, { name: "Bob" });
    expect(res).toBeSuccessful().toHaveStatus(200).toHaveContentContaining({
      name: "Bob",
    });
    return { id };
  })
  .step("Delete user", async (ctx) => {
    const { http } = ctx.resources;
    const { id } = ctx.previous;
    const res = await http.delete(`/users/${id}`);
    expect(res).toBeSuccessful().toHaveStatus(204);
  })
  .build();

Database Integration

Testing database operations with setup/cleanup for table management.

import { client, expect, scenario } from "probitas";

export default scenario("Database Transaction", { tags: ["db", "postgres"] })
  .resource("pg", () =>
    client.sql.postgres.createPostgresClient({
      url: {
        host: "localhost",
        port: 5432,
        database: "testdb",
        user: "testuser",
        password: "testpass",
      },
    }))
  .setup(async (ctx) => {
    const { pg } = ctx.resources;
    await pg.query(`
      CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL
      )
    `);
    return async () => {
      await pg.query("DROP TABLE IF EXISTS users");
    };
  })
  .step("Insert user with transaction", async (ctx) => {
    const { pg } = ctx.resources;
    const result = await pg.transaction(async (tx) => {
      const insert = await tx.query<{ id: number }>(
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
        ["Alice", "alice@example.com"],
      );
      return insert.rows.first();
    });
    return result;
  })
  .step("Verify user exists", async (ctx) => {
    const { pg } = ctx.resources;
    const { id } = ctx.previous;
    const result = await pg.query<{ name: string }>(
      "SELECT name FROM users WHERE id = $1",
      [id],
    );
    expect(result).toBeSuccessful().toHaveRowCount(1).toHaveContentContaining({
      name: "Alice",
    });
  })
  .build();

gRPC Service Testing

Testing gRPC services including unary calls and streaming.

import { client, expect, scenario } from "probitas";

export default scenario("gRPC Echo Service", { tags: ["grpc"] })
  .resource("grpc", () =>
    client.grpc.createGrpcClient({
      url: "localhost:50051",
    }))
  .step("Unary call", async (ctx) => {
    const { grpc } = ctx.resources;
    const res = await grpc.call("echo.EchoService", "Echo", {
      message: "Hello",
    });
    expect(res).toBeSuccessful().toHaveContentContaining({ message: "Hello" });
    return res.data();
  })
  .step("Server streaming", async (ctx) => {
    const { grpc } = ctx.resources;
    const messages: unknown[] = [];
    for await (
      const res of grpc.serverStream("echo.EchoService", "ServerStream", {
        count: 3,
      })
    ) {
      expect(res).toBeSuccessful();
      messages.push(res.data());
    }
    return messages;
  })
  .build();

Multi-Client Scenario

Combining multiple clients (HTTP, database, Redis) in a single end-to-end test.

import { client, expect, scenario, Skip } from "probitas";

export default scenario("Full Stack Test", {
  tags: ["integration", "e2e"],
})
  .resource("http", () =>
    client.http.createHttpClient({
      url: "http://localhost:8080",
    }))
  .resource("pg", () =>
    client.sql.postgres.createPostgresClient({
      url: {
        host: "localhost",
        port: 5432,
        database: "testdb",
        user: "testuser",
        password: "testpass",
      },
    }))
  .resource("redis", () =>
    client.redis.createRedisClient({
      url: "redis://localhost:6379",
    }))
  .setup(async (ctx) => {
    const { redis } = ctx.resources;
    await redis.set("api:enabled", "true");
    return async () => {
      await redis.del("api:enabled");
    };
  })
  .step("Check API enabled", async (ctx) => {
    const { redis } = ctx.resources;
    const result = await redis.get("api:enabled");
    if (result.value !== "true") {
      throw new Skip("API is disabled");
    }
  })
  .step("Create via API", async (ctx) => {
    const { http } = ctx.resources;
    const res = await http.post("/items", { name: "Test Item" });
    expect(res).toBeSuccessful().toHaveStatus(201);
    return res.json<{ id: number }>();
  })
  .step("Verify in database", async (ctx) => {
    const { pg } = ctx.resources;
    const { id } = ctx.previous;
    const result = await pg.query(
      "SELECT * FROM items WHERE id = $1",
      [id],
    );
    expect(result).toBeSuccessful().toHaveRowCount(1).toHaveContentContaining({
      name: "Test Item",
    });
  })
  .build();

Best Practices

Use Descriptive Step Names

Good names make test output readable and debugging easier.

// Good
.step("Create user with valid email", ...)
.step("Verify email confirmation sent", ...)

// Avoid
.step("Step 1", ...)
.step("Test", ...)

Return Meaningful Values

Return data that subsequent steps need. This enables type-safe data flow through ctx.previous.

// Good - returns data needed by next step
.step("Create user", async (ctx) => {
  const res = await http.post("/users", data);
  return res.json<{ id: number }>();
})

// Avoid - loses useful data
.step("Create user", async (ctx) => {
  await http.post("/users", data);
})

Use Tags for Filtering

Tags let you run subsets of tests (e.g., only fast tests, or only tests that don't need Docker).

scenario("Slow Integration Test", {
  tags: ["integration", "slow", "requires-docker"],
});

Keep Steps Focused

Each step should do one thing. This makes failures easier to diagnose and tests easier to maintain.

// Good - single responsibility
.step("Create order", ...)
.step("Process payment", ...)
.step("Send confirmation", ...)

// Avoid - too many concerns
.step("Create order, process payment, and send confirmation", ...)

Use Setup for Test Data

Setup hooks with cleanup are better than steps for managing test fixtures. They guarantee cleanup even on failure.

// Good - setup manages test data lifecycle
.setup(async (ctx) => {
  await seedTestData(ctx.resources.db);
  return () => cleanupTestData(ctx.resources.db);
})

// Avoid - pollutes step logic
.step("Setup test data", async (ctx) => {
  await seedTestData(ctx.resources.db);
})
Search Documentation