Client

Learn how to use Probitas clients to interact with various services and protocols. This guide covers available clients, common patterns, and best practices for effective testing.

Overview

Probitas provides unified client APIs for connecting to external services during scenario testing. All clients share common patterns:

  • Unified namespace: Access all clients via client.*
  • Automatic cleanup: Clients implement AsyncDisposable for resource management
  • Consistent options: Common settings like timeout, retry, and abort signal
  • Built-in assertions: Use expect() for response validation
import { client, expect, scenario } from "jsr:@probitas/probitas";

export default scenario("API Test")
  .resource("http", () =>
    client.http.createHttpClient({
      url: "http://localhost:8080",
    }))
  .step("Make request", async (ctx) => {
    const { http } = ctx.resources;
    const res = await http.get("/health");
    expect(res).toBeOk();
  })
  .build();

Common Options

All clients accept common options like timeout, signal, and retry. See Configuration for detailed retry settings.

Resource Lifecycle

Register clients as resources for automatic lifecycle management. Resources are disposed in reverse order after the scenario completes.

import { client, expect, scenario } from "jsr:@probitas/probitas";

scenario("Example")
  .resource(
    "http",
    () => client.http.createHttpClient({ url: "http://localhost:8080" }),
  )
  .step(() => {})
  .build();
// Resource automatically disposed when scenario ends

For manual control outside scenarios, use await using:

import { client } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
});
// Automatically closed when scope exits

HTTP Client

The HTTP client provides a fluent API for making HTTP requests with built-in JSON handling and response assertions.

import { client } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
  headers: { "Content-Type": "application/json" },
  throwOnError: true,
});

See Configuration for all options.

Making Requests

The client supports all standard HTTP methods:

import { client } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
});

// GET with query parameters
const res1 = await http.get("/users", {
  query: { page: 1, limit: 10 },
});

// POST with JSON body
const res2 = await http.post("/users", {
  body: {
    name: "Alice",
    email: "alice@example.com",
  },
});

// PUT, PATCH, DELETE
await http.put("/users/1", { body: { name: "Alice Updated" } });
await http.patch("/users/1", {
  body: { email: "new@example.com" },
});
await http.delete("/users/1");

Override client settings per request:

import { client } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
});

// Custom headers for authenticated request
const res1 = await http.get("/protected", {
  headers: { Authorization: "Bearer token123" },
});

// Disable error throwing for expected failures
const res2 = await http.get("/maybe-404", {
  throwOnError: false,
});
if (!res2.ok) {
  console.log("Status:", res2.status);
}

Assertions

Validate responses with chainable assertions:

import { client, expect } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
});
const res = await http.get("/users/1");

expect(res)
  .toBeOk() // Status 2xx
  .toHaveStatus(200) // Exact status code
  .toHaveHeadersPropertyContaining("content-type", "application/json") // Content-Type contains
  .toHaveJsonMatching({ name: "Alice" }) // Partial JSON match
  .toHaveDurationLessThan(1000); // Response time limit

// Additional assertions examples
const res2 = await http.get("/error", { throwOnError: false });
expect(res2).not.toBeOk(); // Status not 2xx
expect(res).toHaveHeadersProperty("X-Request-Id");
expect(res).toHaveTextContaining("success");
expect(res).toHaveJsonPresent(); // Check if response has JSON data

SQL Clients

Probitas supports multiple SQL databases with a consistent query interface.

import { client } from "jsr:@probitas/probitas";

await using pg = await client.sql.postgres.createPostgresClient({
  url: "postgres://testuser:testpass@localhost:5432/testdb",
});

See Configuration for all options.

Queries

Run queries with parameterized values:

import { client } from "jsr:@probitas/probitas";

await using pg = await client.sql.postgres.createPostgresClient({
  url: "postgres://testuser:testpass@localhost:5432/testdb",
});

// Simple query
const result1 = await pg.query("SELECT * FROM users");

// Parameterized query (type-safe)
const userId = 1;
const result2 = await pg.query<{ id: number; name: string }>(
  "SELECT * FROM users WHERE id = $1",
  [userId],
);

// Access results
const allRows = result2.rows ?? []; // All rows
const first = result2.rows?.[0]; // First row
const rowCount = result2.rowCount; // Row count

Transactions

Wrap multiple queries in a transaction:

import { client } from "jsr:@probitas/probitas";

await using pg = await client.sql.postgres.createPostgresClient({
  url: "postgres://testuser:testpass@localhost:5432/testdb",
});

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"],
  );

  await tx.query(
    "INSERT INTO profiles (user_id, bio) VALUES ($1, $2)",
    [insert.rows?.[0]!.id, "Hello!"],
  );

  return insert.rows?.[0];
});

Assertions

Validate query results:

import { client, expect } from "jsr:@probitas/probitas";

await using pg = await client.sql.postgres.createPostgresClient({
  url: "postgres://testuser:testpass@localhost:5432/testdb",
});

const result = await pg.query("SELECT * FROM users WHERE name = $1", ["Alice"]);

expect(result)
  .toBeOk()
  .toHaveRowCount(1)
  .toHaveRowsMatching({ name: "Alice" });

// Match multiple rows
const allResult = await pg.query("SELECT * FROM users");
expect(allResult).toHaveRowsMatching([
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
]);

// Additional row assertions
expect(result).not.toHaveRowsEmpty(); // Check if query returned any rows
expect(result).toHaveRowCountGreaterThan(0); // More than 0 rows
expect(result).toHaveRowCountLessThanOrEqual(10); // At most 10 rows

gRPC Client

The gRPC client supports unary calls, server streaming, client streaming, and bidirectional streaming.

import { client } from "jsr:@probitas/probitas";

await using grpc = client.grpc.createGrpcClient({
  url: "localhost:50051",
  metadata: { authorization: "Bearer token" },
});

See Configuration for all options.

Unary Calls

Standard request-response pattern:

import { client, expect } from "jsr:@probitas/probitas";

await using grpc = client.grpc.createGrpcClient({ url: "localhost:50051" });

const res = await grpc.call("echo.EchoService", "Echo", {
  message: "Hello",
});

expect(res).toBeOk().toHaveDataMatching({ message: "Hello" });
// Note: For gRPC responses, use toHaveDataMatching (not toHaveJsonMatching)
const data = res.data;

Server Streaming

Receive multiple responses from a single request:

import { client, expect } from "jsr:@probitas/probitas";

await using grpc = client.grpc.createGrpcClient({ url: "localhost:50051" });

const messages: unknown[] = [];
for await (
  const res of grpc.serverStream("echo.EchoService", "ServerStream", {
    count: 3,
  })
) {
  expect(res).toBeOk();
  messages.push(res.data);
}

Client Streaming

Send multiple requests, receive a single response:

import { client, expect } from "jsr:@probitas/probitas";

await using grpc = client.grpc.createGrpcClient({ url: "localhost:50051" });

const res = await grpc.clientStream(
  "echo.EchoService",
  "ClientStream",
  (async function* () {
    yield { message: "First" };
    yield { message: "Second" };
    yield { message: "Third" };
  })(),
);
expect(res).toBeOk();

Bidirectional Streaming

Stream in both directions simultaneously:

import { client, expect } from "jsr:@probitas/probitas";

await using grpc = client.grpc.createGrpcClient({ url: "localhost:50051" });

for await (
  const res of grpc.bidiStream(
    "echo.EchoService",
    "BidiStream",
    (async function* () {
      yield { message: "Ping 1" };
      yield { message: "Ping 2" };
    })(),
  )
) {
  expect(res).toBeOk();
  console.log("Received:", res.data);
}

ConnectRPC Client

The ConnectRPC client supports Connect, gRPC, and gRPC-Web protocols with a unified API.

import { client } from "jsr:@probitas/probitas";

await using connect = client.connectrpc.createConnectRpcClient({
  url: "localhost:8080",
});

Unary Calls

import { client, expect } from "jsr:@probitas/probitas";

await using connect = client.connectrpc.createConnectRpcClient({
  url: "localhost:8080",
});

const res = await connect.call("echo.EchoService", "Echo", {
  message: "Hello",
});

expect(res).toBeOk().toHaveDataMatching({ message: "Hello" });
// Note: For gRPC responses, use toHaveDataMatching (not toHaveJsonMatching)

Server Streaming

import { client, expect } from "jsr:@probitas/probitas";

await using connect = client.connectrpc.createConnectRpcClient({
  url: "localhost:8080",
});

for await (
  const res of connect.serverStream("echo.EchoService", "ServerStream", {
    count: 3,
  })
) {
  expect(res).toBeOk();
  console.log("Received:", res.data);
}

GraphQL Client

The GraphQL client provides methods for queries, mutations, and subscriptions.

import { client } from "jsr:@probitas/probitas";

await using graphql = client.graphql.createGraphqlClient({
  url: "http://localhost:4000/graphql",
  headers: { Authorization: "Bearer token" },
});

See Configuration for all options.

Queries

Fetch data with GraphQL queries:

import { client, expect, outdent } from "jsr:@probitas/probitas";

await using graphql = client.graphql.createGraphqlClient({
  url: "http://localhost:4000/graphql",
});

const res = await graphql.query(
  outdent`
    query GetUser($id: ID!) {
      user(id: $id) {
        id
        name
        email
      }
    }
  `,
  { id: "1" },
);

expect(res).toBeOk().toHaveDataMatching({
  user: { name: "Alice" },
});
const user =
  res.data<{ user: { id: string; name: string; email: string } }>()!.user;

Mutations

Modify data with GraphQL mutations:

import { client, expect, outdent } from "jsr:@probitas/probitas";

await using graphql = client.graphql.createGraphqlClient({
  url: "http://localhost:4000/graphql",
});

const res = await graphql.mutation(
  outdent`
    mutation CreateUser($input: CreateUserInput!) {
      createUser(input: $input) {
        id
        name
      }
    }
  `,
  { input: { name: "Alice", email: "alice@example.com" } },
);

expect(res).toBeOk();
const newUser =
  res.data<{ createUser: { id: string; name: string } }>()!.createUser;

Subscriptions

Listen for real-time updates:

import { client, expect, outdent } from "jsr:@probitas/probitas";

await using graphql = client.graphql.createGraphqlClient({
  url: "http://localhost:4000/graphql",
});

const subscription = graphql.subscribe(outdent`
  subscription OnUserCreated {
    userCreated {
      id
      name
    }
  }
`);

for await (const res of subscription) {
  expect(res).toBeOk();
  console.log(
    "New user:",
    res.data<{ userCreated: { id: string; name: string } }>()!.userCreated,
  );
}

Error and Extension Assertions

Check for GraphQL errors and extensions:

import { client, expect } from "jsr:@probitas/probitas";

await using graphql = client.graphql.createGraphqlClient({
  url: "http://localhost:4000/graphql",
});

const res = await graphql.query("query { user(id: 1) { name } }");

// Check for errors
expect(res).toHaveErrorPresent(); // Has GraphQL errors
expect(res).toHaveError({ message: "Not found" }); // Specific error

// Or check for no errors
expect(res).toHaveErrorNullish(); // No GraphQL errors

// Check for extensions
expect(res).toHaveExtensionsProperty("tracing"); // Has tracing extension
expect(res).toHaveExtensionsPropertyContaining("tracing", { version: 1 }); // Extension with value

Redis Client

The Redis client provides operations for strings, hashes, lists, and sets.

import { client } from "jsr:@probitas/probitas";

await using redis = await client.redis.createRedisClient({
  url: "redis://localhost:6379",
});

See Configuration for all options.

Operations

Common Redis operations:

import { client, expect } from "jsr:@probitas/probitas";

await using redis = await client.redis.createRedisClient({
  url: "redis://localhost:6379",
});

// Strings
await redis.set("key", "value");
await redis.set("key", "value", { ex: 3600 }); // With TTL
const result = await redis.get("key");
expect(result).toBeOk().toHaveValue("value");

// Hashes
await redis.hset("user:1", "name", "Alice");
await redis.hset("user:1", "age", "30");
const user = await redis.hgetall("user:1");

// Lists
await redis.lpush("queue", ["task1", "task2"]);
const task = await redis.rpop("queue");

// Sets
await redis.sadd("tags", ["typescript", "deno"]);
const tags = await redis.smembers("tags");

// Delete
await redis.del(["key"]);

// Value assertions
expect(result).toHaveValuePresent(); // Check if value exists
expect(result).toHaveValue("expected"); // Match exact value
expect(result).toHaveValueContaining("substring"); // Value contains substring
// expect(result).toHaveValueCount(5); // String length or collection size

MongoDB Client

The MongoDB client provides document operations with a familiar API.

import { client } from "jsr:@probitas/probitas";

await using mongo = await client.mongodb.createMongoClient({
  url: "mongodb://localhost:27017",
  database: "testdb",
});

See Configuration for all options.

Operations

Work with collections and documents:

import { client, expect } from "jsr:@probitas/probitas";

interface User {
  name: string;
  email: string;
  age?: number;
}

await using mongo = await client.mongodb.createMongoClient({
  url: "mongodb://localhost:27017",
  database: "testdb",
});

const users = mongo.collection<User>("users");

// Insert
const result = await users.insertOne({
  name: "Alice",
  email: "alice@example.com",
});
expect(result).toBeOk();

// Find
const user = await users.findOne({ _id: result.insertedId });
expect(user).toBeOk().toHaveDocMatching({ name: "Alice" });

// Find many
const allUsersResult = await users.find({ age: { $gte: 18 } });
const allUsers = allUsersResult.docs;

// Update
await users.updateOne(
  { _id: result.insertedId },
  { $set: { name: "Bob" } },
);

// Delete
const deleteResult = await users.deleteOne({ _id: result.insertedId });

// Document assertions
expect(result).toHaveInsertedId(result.insertedId); // Check inserted ID
expect(allUsersResult).toHaveDocsCount(5); // Exactly 5 documents
expect(allUsersResult).toHaveDocsMatching([{ name: "Alice" }]); // Match multiple documents
expect(deleteResult).toHaveDeletedCount(1); // 1 document deleted

Deno KV Client

The Deno KV client provides access to Deno's built-in key-value store.

import { client } from "jsr:@probitas/probitas";

await using kv = await client.deno_kv.createDenoKvClient();

By default, an in-memory database is used for testing.

Operations

import { client, expect } from "jsr:@probitas/probitas";

await using kv = await client.deno_kv.createDenoKvClient();

// Set and get
await kv.set(["users", "1"], { name: "Alice" });
const result = await kv.get(["users", "1"]);
expect(result).toBeOk().toHaveValue({ name: "Alice" });

// List by prefix
const listResult = await kv.list({ prefix: ["users"] });
for (const entry of listResult.entries) {
  console.log(entry.key, entry.value);
}

// Atomic operations
const atomic = kv.atomic();
atomic
  .check({ key: ["users", "1"], versionstamp: null })
  .set(["users", "1"], { name: "Alice" });
const commitResult = await atomic.commit();

// Delete
await kv.delete(["users", "1"]);

RabbitMQ Client

The RabbitMQ client provides AMQP messaging for publish/subscribe patterns.

import { client } from "jsr:@probitas/probitas";

await using rabbitmq = await client.rabbitmq.createRabbitMqClient({
  url: "amqp://guest:guest@localhost:5672",
});

See Configuration for all options.

Operations

import { client, expect } from "jsr:@probitas/probitas";

await using rabbitmq = await client.rabbitmq.createRabbitMqClient({
  url: "amqp://guest:guest@localhost:5672",
});

const channel = await rabbitmq.channel();

// Declare queue
await channel.assertQueue("my-queue", { durable: false });

// Send message
const content = new TextEncoder().encode(JSON.stringify({ message: "Hello" }));
await channel.sendToQueue("my-queue", content);

// Receive message
const result = await channel.get("my-queue");
expect(result).toBeOk();
if (result.message) {
  await channel.ack(result.message);
}

await channel.close();

SQS Client

The AWS SQS client provides cloud message queue operations.

import { client } from "jsr:@probitas/probitas";

await using sqs = await client.sqs.createSqsClient({
  url: "http://localhost:4566", // LocalStack or AWS endpoint
  region: "us-east-1",
  credentials: {
    accessKeyId: "test",
    secretAccessKey: "test",
  },
});

See Configuration for all options.

Operations

import { client, expect } from "jsr:@probitas/probitas";

await using sqs = await client.sqs.createSqsClient({
  url: "http://localhost:4566",
  region: "us-east-1",
  credentials: { accessKeyId: "test", secretAccessKey: "test" },
});

// Ensure queue exists
await sqs.ensureQueue("my-queue");

// Send message
const result = await sqs.send(JSON.stringify({ event: "user.created" }));
expect(result).toBeOk().toHaveMessageId(result.messageId);

// Receive and process
const receiveResult = await sqs.receive({ maxMessages: 10 });
for (const msg of receiveResult.messages ?? []) {
  console.log("Received:", msg.body);
  await sqs.delete(msg.receiptHandle);
}

Available Clients

ClientFactory FunctionUse Case
HTTP`client.http.createHttpClient()`REST APIs, webhooks
PostgreSQL`client.sql.postgres.createPostgresClient()`PostgreSQL databases
MySQL`client.sql.mysql.createMySqlClient()`MySQL databases
SQLite`client.sql.sqlite.createSqliteClient()`Embedded databases
DuckDB`client.sql.duckdb.createDuckDbClient()`Analytics databases
gRPC`client.grpc.createGrpcClient()`gRPC services
ConnectRPC`client.connectrpc.createConnectRpcClient()`Connect/gRPC-Web
GraphQL`client.graphql.createGraphqlClient()`GraphQL APIs
Redis`client.redis.createRedisClient()`Cache, pub/sub
MongoDB`client.mongodb.createMongoClient()`Document databases
Deno KV`client.deno_kv.createDenoKvClient()`Deno KV store
RabbitMQ`client.rabbitmq.createRabbitMqClient()`AMQP message queues
SQS`client.sqs.createSqsClient()`AWS message queues

Best Practices

Register Clients as Resources

Always register clients as resources for automatic cleanup:

import { client, expect, scenario } from "jsr:@probitas/probitas";

// Good - automatic lifecycle management
scenario("Example")
  .resource(
    "http",
    () => client.http.createHttpClient({ url: "http://localhost:8080" }),
  )
  .step(() => {})
  .build();

// Avoid - manual cleanup required
scenario("Bad Example")
  .step("Make request", async () => {
    const http = client.http.createHttpClient({ url: "http://localhost:8080" });
    // Must manually dispose
  })
  .build();

Use Environment-Driven Endpoints

Parameterize service URLs so scenarios run consistently across environments and avoid hard-coded localhost values.

import { client, expect, scenario } from "jsr:@probitas/probitas";

const apiUrl = Deno.env.get("API_URL") ?? "http://localhost:8080";

scenario("API check")
  .resource("http", () => client.http.createHttpClient({ url: apiUrl }))
  .step("Ping service", async (ctx) => {
    const res = await ctx.resources.http.get("/health");
    expect(res).toBeOk();
  })
  .build();

Use Type Parameters

Provide type parameters for type-safe responses:

import { client } from "jsr:@probitas/probitas";

await using pg = await client.sql.postgres.createPostgresClient({
  url: "postgres://testuser:testpass@localhost:5432/testdb",
});

const userId = 1;

// Good - typed response
const result1 = await pg.query<{ id: number; name: string }>(
  "SELECT id, name FROM users WHERE id = $1",
  [userId],
);
const user = result1.rows?.[0]; // Type: { id: number; name: string } | undefined

// Avoid - untyped response
const result2 = await pg.query("SELECT * FROM users");

Handle Errors Appropriately

Use assertions for expected successes, explicit checks for expected failures:

import { client, expect } from "jsr:@probitas/probitas";

await using http = client.http.createHttpClient({
  url: "http://localhost:8080",
});

// Expected success - use assertions
const res1 = await http.get("/users/1");
expect(res1).toBeOk().toHaveStatus(200);

// Expected failure - disable throwing, check manually
const res2 = await http.get("/users/nonexistent", { throwOnError: false });
expect(res2).toHaveStatus(404);

Configure Retries

Use retry configuration for network-dependent operations. See Configuration for all retry options.

import { client, expect, scenario } from "jsr:@probitas/probitas";

scenario("Retry Example")
  .resource(
    "http",
    () => client.http.createHttpClient({ url: "http://localhost:8080" }),
  )
  .step(
    "External API call",
    async (ctx) => {
      const { http } = ctx.resources;
      const res = await http.get("/external-api", {
        retry: { maxAttempts: 3, backoff: "exponential" },
      });
      expect(res).toBeOk();
    },
    {
      timeout: 10000, // Allow time for retries
    },
  )
  .build();
Search Documentation