home / skills / cameronapak / bknd-skills / bknd-testing

bknd-testing skill

/skills/bknd-testing

This skill helps you write and run unit and integration tests for Bknd apps using in-memory databases, test helpers, and mocks.

npx playbooks add skill cameronapak/bknd-skills --skill bknd-testing

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
14.4 KB
---
name: bknd-testing
description: Use when writing tests for Bknd applications, setting up test infrastructure, creating unit/integration tests, or testing API endpoints. Covers in-memory database setup, test helpers, mocking, and test patterns.
---

# Testing Bknd Applications

Write and run tests for Bknd applications using Bun Test or Vitest with in-memory databases for isolation.

## Prerequisites

- Bknd project set up locally
- Test runner installed (Bun or Vitest)
- Understanding of async/await patterns

## When to Use UI Mode

- Manual integration testing via admin panel
- Verifying data after test runs
- Quick smoke testing

## When to Use Code Mode

- Automated unit tests
- Integration tests
- CI/CD pipelines
- Regression testing

## Test Runner Setup

### Bun (Recommended)

Bun has a built-in test runner:

```bash
# Run all tests
bun test

# Run specific file
bun test tests/posts.test.ts

# Watch mode
bun test --watch
```

### Vitest

```bash
# Install
bun add -D vitest

# Configure vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
  },
});

# Run
npx vitest
```

## In-Memory Database Setup

Use in-memory SQLite for fast, isolated tests.

### Test Helper Module

Create `tests/helper.ts`:

```typescript
import { App, createApp as baseCreateApp } from "bknd";
import { em, entity, text, number, boolean } from "bknd";
import Database from "libsql";

// Schema for tests
export const testSchema = em({
  posts: entity("posts", {
    title: text().required(),
    content: text(),
    published: boolean(),
  }),
  comments: entity("comments", {
    body: text().required(),
    author: text(),
  }),
}, (fn, s) => {
  fn.relation(s.comments).manyToOne(s.posts);
});

// Create isolated test app with in-memory DB
export async function createTestApp(options?: {
  seed?: (app: App) => Promise<void>;
}) {
  const db = new Database(":memory:");

  const app = new App({
    connection: { database: db },
    schema: testSchema,
  });

  await app.build();

  if (options?.seed) {
    await options.seed(app);
  }

  return {
    app,
    cleanup: () => {
      db.close();
    },
  };
}

// Create test API client
export async function createTestClient(app: App) {
  const baseUrl = "http://localhost:0"; // Placeholder

  return {
    data: app.modules.data,
    auth: app.modules.auth,
  };
}
```

### Bun-Specific Helper

For Bun's native SQLite:

```typescript
import { bunSqlite } from "bknd/adapter/bun";
import { Database } from "bun:sqlite";

export function createTestConnection() {
  const db = new Database(":memory:");
  return bunSqlite({ database: db });
}
```

## Unit Testing Patterns

### Testing Entity Operations

```typescript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { createTestApp } from "./helper";

describe("Posts", () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;

  beforeEach(async () => {
    app = await createTestApp();
  });

  afterEach(() => {
    app.cleanup();
  });

  test("creates a post", async () => {
    const result = await app.app.em
      .mutator("posts")
      .insertOne({ title: "Test Post", content: "Hello" });

    expect(result.id).toBeDefined();
    expect(result.title).toBe("Test Post");
  });

  test("reads posts", async () => {
    // Seed data
    await app.app.em.mutator("posts").insertOne({ title: "Post 1" });
    await app.app.em.mutator("posts").insertOne({ title: "Post 2" });

    const posts = await app.app.em.repo("posts").findMany();

    expect(posts).toHaveLength(2);
  });

  test("updates a post", async () => {
    const created = await app.app.em
      .mutator("posts")
      .insertOne({ title: "Original" });

    const updated = await app.app.em
      .mutator("posts")
      .updateOne(created.id, { title: "Updated" });

    expect(updated.title).toBe("Updated");
  });

  test("deletes a post", async () => {
    const created = await app.app.em
      .mutator("posts")
      .insertOne({ title: "To Delete" });

    await app.app.em.mutator("posts").deleteOne(created.id);

    const found = await app.app.em.repo("posts").findOne(created.id);
    expect(found).toBeNull();
  });
});
```

### Testing Relationships

```typescript
describe("Comments", () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;

  beforeEach(async () => {
    app = await createTestApp();
  });

  afterEach(() => app.cleanup());

  test("creates comment with relation", async () => {
    const post = await app.app.em
      .mutator("posts")
      .insertOne({ title: "Parent Post" });

    const comment = await app.app.em
      .mutator("comments")
      .insertOne({
        body: "Great post!",
        posts_id: post.id,
      });

    expect(comment.posts_id).toBe(post.id);
  });

  test("loads comments with post", async () => {
    const post = await app.app.em
      .mutator("posts")
      .insertOne({ title: "Post" });

    await app.app.em.mutator("comments").insertOne({
      body: "Comment 1",
      posts_id: post.id,
    });

    const comments = await app.app.em.repo("comments").findMany({
      with: { posts: true },
    });

    expect(comments[0].posts).toBeDefined();
    expect(comments[0].posts.title).toBe("Post");
  });
});
```

## Integration Testing

### HTTP API Testing

Test the full HTTP stack:

```typescript
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { serve } from "bknd/adapter/bun";

describe("API Integration", () => {
  let server: ReturnType<typeof Bun.serve>;
  const port = 3999;
  const baseUrl = `http://localhost:${port}`;

  beforeAll(async () => {
    server = Bun.serve({
      port,
      fetch: (await serve({
        connection: { url: ":memory:" },
        schema: testSchema,
      })).fetch,
    });
  });

  afterAll(() => {
    server.stop();
  });

  test("GET /api/data/posts returns 200", async () => {
    const res = await fetch(`${baseUrl}/api/data/posts`);
    expect(res.status).toBe(200);

    const data = await res.json();
    expect(data).toEqual({ data: [] });
  });

  test("POST /api/data/posts creates record", async () => {
    const res = await fetch(`${baseUrl}/api/data/posts`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "API Test" }),
    });

    expect(res.status).toBe(201);

    const { data } = await res.json();
    expect(data.title).toBe("API Test");
  });
});
```

### Testing with SDK Client

```typescript
import { Api } from "bknd/client";

describe("SDK Integration", () => {
  let api: Api;
  let server: ReturnType<typeof Bun.serve>;

  beforeAll(async () => {
    // Start test server
    server = await startTestServer();
    api = new Api({ host: "http://localhost:3999" });
  });

  afterAll(() => server.stop());

  test("creates and reads via SDK", async () => {
    const created = await api.data.createOne("posts", {
      title: "SDK Test",
    });

    expect(created.ok).toBe(true);

    const read = await api.data.readOne("posts", created.data.id);
    expect(read.data.title).toBe("SDK Test");
  });
});
```

## Testing Authentication

### Auth Flow Testing

```typescript
describe("Authentication", () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;

  beforeEach(async () => {
    app = await createTestApp({
      auth: {
        enabled: true,
        strategies: {
          password: {
            hashing: "plain", // Only for tests!
          },
        },
      },
    });
  });

  afterEach(() => app.cleanup());

  test("registers a user", async () => {
    const auth = app.app.modules.auth;

    const result = await auth.register({
      email: "[email protected]",
      password: "password123",
    });

    expect(result.user).toBeDefined();
    expect(result.user.email).toBe("[email protected]");
  });

  test("login with correct password", async () => {
    const auth = app.app.modules.auth;

    // Register first
    await auth.register({
      email: "[email protected]",
      password: "password123",
    });

    // Then login
    const result = await auth.login({
      email: "[email protected]",
      password: "password123",
    });

    expect(result.token).toBeDefined();
  });

  test("login with wrong password fails", async () => {
    const auth = app.app.modules.auth;

    await auth.register({
      email: "[email protected]",
      password: "correct",
    });

    await expect(
      auth.login({
        email: "[email protected]",
        password: "wrong",
      })
    ).rejects.toThrow();
  });
});
```

## Mocking Patterns

### Mocking Fetch

```typescript
import { mock, jest } from "bun:test";

describe("External API calls", () => {
  let originalFetch: typeof fetch;

  beforeAll(() => {
    originalFetch = global.fetch;
    // @ts-ignore
    global.fetch = jest.fn(() =>
      Promise.resolve(
        new Response(JSON.stringify({ success: true }), {
          status: 200,
          headers: { "Content-Type": "application/json" },
        })
      )
    );
  });

  afterAll(() => {
    global.fetch = originalFetch;
  });

  test("FetchTask uses mocked fetch", async () => {
    const task = new FetchTask("test", {
      url: "https://api.example.com/data",
      method: "GET",
    });

    const result = await task.run();
    expect(result.success).toBe(true);
    expect(global.fetch).toHaveBeenCalled();
  });
});
```

### Mocking Drivers

```typescript
describe("Email sending", () => {
  test("uses mock email driver", async () => {
    const sentEmails: any[] = [];

    const app = await createTestApp({
      drivers: {
        email: {
          send: async (to, subject, body) => {
            sentEmails.push({ to, subject, body });
            return { id: "mock-id" };
          },
        },
      },
    });

    // Trigger something that sends email
    await app.app.drivers.email.send(
      "[email protected]",
      "Test",
      "Body"
    );

    expect(sentEmails).toHaveLength(1);
    expect(sentEmails[0].to).toBe("[email protected]");

    app.cleanup();
  });
});
```

## Test Data Factories

Create reusable factories for test data:

```typescript
// tests/factories.ts
let counter = 0;

export function createPostData(overrides = {}) {
  counter++;
  return {
    title: `Test Post ${counter}`,
    content: `Content for post ${counter}`,
    published: false,
    ...overrides,
  };
}

export function createUserData(overrides = {}) {
  counter++;
  return {
    email: `user${counter}@test.com`,
    password: "password123",
    ...overrides,
  };
}

// Usage in tests
test("creates multiple posts", async () => {
  const posts = await Promise.all([
    app.em.mutator("posts").insertOne(createPostData()),
    app.em.mutator("posts").insertOne(createPostData({ published: true })),
    app.em.mutator("posts").insertOne(createPostData()),
  ]);

  expect(posts).toHaveLength(3);
});
```

## Testing Flows

```typescript
import { Flow, FetchTask, Condition } from "bknd/flows";

describe("Flows", () => {
  test("executes flow with tasks", async () => {
    const task1 = new FetchTask("fetch", {
      url: "https://example.com/api",
      method: "GET",
    });

    const flow = new Flow("testFlow", [task1]);

    const execution = await flow.start({ input: "value" });

    expect(execution.hasErrors()).toBe(false);
    expect(execution.getResponse()).toBeDefined();
  });

  test("handles task errors", async () => {
    const failingTask = new FetchTask("fail", {
      url: "https://invalid-url-that-fails.test",
      method: "GET",
    });

    const flow = new Flow("failFlow", [failingTask]);
    const execution = await flow.start({});

    expect(execution.hasErrors()).toBe(true);
    expect(execution.getErrors()).toHaveLength(1);
  });
});
```

## CI/CD Configuration

### GitHub Actions

```yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - run: bun install
      - run: bun test
```

### Pre-commit Hook

```bash
# .husky/pre-commit
#!/bin/sh
bun test --bail
```

## Project Structure

```
my-bknd-app/
├── src/
│   └── ...
├── tests/
│   ├── helper.ts          # Test utilities
│   ├── factories.ts       # Data factories
│   ├── unit/
│   │   ├── posts.test.ts
│   │   └── auth.test.ts
│   └── integration/
│       ├── api.test.ts
│       └── flows.test.ts
├── bknd.config.ts
└── package.json
```

## Common Pitfalls

### Database Not Isolated

**Problem:** Tests share state, causing flaky tests.

**Solution:** Create fresh in-memory DB per test:

```typescript
beforeEach(async () => {
  app = await createTestApp();  // New DB each time
});

afterEach(() => {
  app.cleanup();  // Close connection
});
```

### Async Cleanup Issues

**Problem:** Tests hang or leak resources.

**Solution:** Always await cleanup:

```typescript
afterEach(async () => {
  await app.cleanup();
});

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

### Missing await on Assertions

**Problem:** Test passes before async operation completes.

**Solution:** Always await async operations:

```typescript
// WRONG
test("fails silently", () => {
  expect(api.data.readMany("posts")).resolves.toBeDefined();
});

// CORRECT
test("properly awaited", async () => {
  const result = await api.data.readMany("posts");
  expect(result).toBeDefined();
});
```

### Testing Against Production DB

**Problem:** Tests modify real data.

**Solution:** Always use `:memory:` or test-specific file:

```typescript
// SAFE
connection: { url: ":memory:" }

// ALSO SAFE
connection: { url: "file:test-${Date.now()}.db" }

// DANGEROUS - never in tests
connection: { url: process.env.DB_URL }
```

## DOs and DON'Ts

**DO:**
- Use in-memory databases for speed and isolation
- Clean up resources in afterEach/afterAll
- Create test helpers and factories
- Test both success and error paths
- Use meaningful test descriptions
- Keep tests independent of each other

**DON'T:**
- Share database state between tests
- Use production credentials in tests
- Skip await on async operations
- Write tests that depend on execution order
- Use `plain` password hashing outside tests
- Commit test database files

## Related Skills

- **bknd-local-setup** - Development environment setup
- **bknd-debugging** - Troubleshooting test failures
- **bknd-seed-data** - Creating test data patterns
- **bknd-crud-create** - Understanding data operations
- **bknd-setup-auth** - Auth configuration for tests

Overview

This skill provides practical guidance and helpers for testing Bknd applications. It focuses on fast, isolated tests using in-memory databases, test helpers, factories, and common test patterns for unit, integration, and auth flows. It also covers mocking external calls and configuring CI to run tests reliably.

How this skill works

The skill describes how to create an isolated test app with an in-memory SQLite connection and helper modules that build and tear down the app per test. It explains patterns for mutator/repo-based entity tests, HTTP integration tests using a local server, SDK-based testing, auth flows, mocking fetch and drivers, and reusable data factories. It includes CI and local runner commands for Bun and Vitest.

When to use it

  • Writing unit tests for entity operations and relationships
  • Building integration tests for HTTP endpoints or SDK clients
  • Setting up CI pipelines to run automated tests
  • Testing authentication flows and drivers (email, external APIs)
  • Creating test helpers, factories, and isolated databases for reliability

Best practices

  • Use an in-memory database per test to avoid shared state and flakiness
  • Always await cleanup and server stop to avoid resource leaks
  • Seed only the data needed per test; keep tests independent
  • Mock external network calls and drivers to make tests deterministic
  • Use factories to produce consistent test data and reduce duplication

Example use cases

  • Unit test CRUD operations for posts and comments using mutators and repos
  • Integration test REST endpoints by starting a local server against :memory: DB
  • Verify auth workflows: register, login, token issuance and failure cases
  • Mock external fetch responses inside tasks and flows to simulate APIs
  • Run tests in CI with Bun test runner and pre-commit hooks to prevent regressions

FAQ

Which test runner should I use?

Bun's built-in test runner is recommended for speed and native SQLite support; Vitest is an alternative if you prefer Jest-like APIs.

How do I avoid tests interfering with each other?

Create a fresh in-memory database per test and call cleanup after each test to close connections and reset state.

Should I mock external services?

Yes. Mock fetch and external drivers during unit tests to make behavior deterministic and keep tests fast.