Bun Workspace Monorepo Testing: Cross-Package Tests and CI Setup

Bun Workspace Monorepo Testing: Cross-Package Tests and CI Setup

Bun workspaces let you run tests across every package in a monorepo with a single command. Cross-package imports resolve through the workspace protocol, TypeScript paths work without a build step, and bun test --filter targets packages by name pattern — making the monorepo test loop almost as fast as a single-package repo.

Key Takeaways

Workspace packages resolve as local symlinks. bun install links workspace packages into node_modules, so cross-package imports like import { schema } from "@acme/types" work in tests without a build step.

bun test --filter runs tests for a subset of packages. Pass a glob pattern matching package names to run only the packages you changed: bun test --filter "./packages/api".

bunx --bun runs scripts from workspace packages. Use it in CI to run type-check or build scripts across all packages without navigating directories.

Shared test helpers belong in a dedicated workspace package. Put fixtures, seed utilities, and mock factories in @acme/test-utils and import them across packages — no copy-pasting test setup code.

GitHub Actions cache Bun's global cache directory. Cache ~/.bun/install/cache across CI runs; a warm cache reduces bun install time from seconds to under 200ms.

Monorepo Structure

A Bun workspace monorepo has a root package.json that declares the workspace packages. Bun reads the workspaces field identically to npm/Yarn:

my-monorepo/
├── package.json          # root — declares workspaces
├── bunfig.toml           # optional Bun config
├── packages/
│   ├── types/            # @acme/types — shared TypeScript types
│   ├── api/              # @acme/api — Bun HTTP server
│   ├── worker/           # @acme/worker — background worker
│   └── test-utils/       # @acme/test-utils — shared test helpers
└── tsconfig.json         # root tsconfig with path aliases

Root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "test": "bun test",
    "test:watch": "bun test --watch",
    "typecheck": "tsc --noEmit -p tsconfig.json"
  },
  "devDependencies": {
    "typescript": "^5.4.0"
  }
}

Workspace Package Configuration

Each package has its own package.json. The key is the name field — it must match the import you use in other packages.

// packages/types/package.json
{
  "name": "@acme/types",
  "version": "0.1.0",
  "module": "src/index.ts",
  "types": "src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}
// packages/api/package.json
{
  "name": "@acme/api",
  "version": "0.1.0",
  "dependencies": {
    "@acme/types": "workspace:*",
    "@acme/test-utils": "workspace:*"
  }
}

The workspace:* protocol tells Bun to resolve to the local package instead of fetching from npm.

Shared Types Package

// packages/types/src/index.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "member";
}

export interface Post {
  id: number;
  authorId: number;
  title: string;
  content: string;
  publishedAt: string | null;
}

export interface ApiResponse<T> {
  data: T;
  meta: {
    total: number;
    page: number;
    pageSize: number;
  };
}

Testing the types package is mostly about ensuring the type contracts are stable — test the runtime behavior of any utility functions it exports:

// packages/types/src/guards.ts
import type { User } from "./index";

export function isAdmin(user: User): boolean {
  return user.role === "admin";
}

export function isPublished(post: { publishedAt: string | null }): boolean {
  return post.publishedAt !== null;
}
// packages/types/src/guards.test.ts
import { test, expect, describe } from "bun:test";
import { isAdmin, isPublished } from "./guards";

describe("isAdmin", () => {
  test("returns true for admin role", () => {
    expect(isAdmin({ id: 1, name: "Alice", email: "a@a.com", role: "admin" })).toBe(true);
  });

  test("returns false for member role", () => {
    expect(isAdmin({ id: 2, name: "Bob", email: "b@b.com", role: "member" })).toBe(false);
  });
});

describe("isPublished", () => {
  test("returns true when publishedAt is set", () => {
    expect(isPublished({ publishedAt: "2026-01-01T00:00:00Z" })).toBe(true);
  });

  test("returns false when publishedAt is null", () => {
    expect(isPublished({ publishedAt: null })).toBe(false);
  });
});

Shared Test Utilities Package

Centralizing test fixtures and factories prevents duplication across packages:

// packages/test-utils/src/index.ts
import type { User, Post } from "@acme/types";

let nextId = 1;

export function makeUser(overrides: Partial<User> = {}): User {
  const id = nextId++;
  return {
    id,
    name: `User ${id}`,
    email: `user${id}@example.com`,
    role: "member",
    ...overrides,
  };
}

export function makePost(overrides: Partial<Post> = {}): Post {
  const id = nextId++;
  return {
    id,
    authorId: 1,
    title: `Post ${id}`,
    content: `Content for post ${id}`,
    publishedAt: null,
    ...overrides,
  };
}

export function makeAdmin(overrides: Partial<User> = {}): User {
  return makeUser({ role: "admin", ...overrides });
}

export function resetIdCounter(): void {
  nextId = 1;
}

Testing Cross-Package Imports

The critical question for any monorepo is: do cross-package imports resolve correctly in tests?

// packages/api/src/userService.ts
import type { User } from "@acme/types";
import { isAdmin } from "@acme/types/src/guards";

export function filterAdmins(users: User[]): User[] {
  return users.filter(isAdmin);
}

export function paginateUsers(
  users: User[],
  page: number,
  pageSize: number
): User[] {
  const start = (page - 1) * pageSize;
  return users.slice(start, start + pageSize);
}
// packages/api/src/userService.test.ts
import { describe, test, expect, beforeEach } from "bun:test";
import { makeUser, makeAdmin, resetIdCounter } from "@acme/test-utils";
import { filterAdmins, paginateUsers } from "./userService";

describe("filterAdmins", () => {
  beforeEach(() => resetIdCounter());

  test("returns only admin users", () => {
    const users = [makeUser(), makeAdmin(), makeUser(), makeAdmin()];
    const admins = filterAdmins(users);

    expect(admins).toHaveLength(2);
    admins.forEach((u) => expect(u.role).toBe("admin"));
  });

  test("returns empty array when no admins", () => {
    const users = [makeUser(), makeUser(), makeUser()];
    expect(filterAdmins(users)).toHaveLength(0);
  });
});

describe("paginateUsers", () => {
  beforeEach(() => resetIdCounter());

  test("returns correct page slice", () => {
    const users = Array.from({ length: 10 }, () => makeUser());

    const page1 = paginateUsers(users, 1, 3);
    const page2 = paginateUsers(users, 2, 3);

    expect(page1).toHaveLength(3);
    expect(page1[0].id).toBe(users[0].id);

    expect(page2).toHaveLength(3);
    expect(page2[0].id).toBe(users[3].id);
  });

  test("returns partial page at the end", () => {
    const users = Array.from({ length: 7 }, () => makeUser());
    const lastPage = paginateUsers(users, 3, 3);
    expect(lastPage).toHaveLength(1);
  });
});

Running Tests Across All Packages

From the monorepo root:

# Run all tests across all workspace packages
bun <span class="hljs-built_in">test

<span class="hljs-comment"># Run tests matching a path pattern
bun <span class="hljs-built_in">test packages/api

<span class="hljs-comment"># Run tests for a specific package using --filter (package name pattern)
bun <span class="hljs-built_in">test --filter <span class="hljs-string">"@acme/api"

<span class="hljs-comment"># Run with coverage across all packages
bun <span class="hljs-built_in">test --coverage

<span class="hljs-comment"># Watch mode — reruns affected tests on any file save
bun <span class="hljs-built_in">test --watch

When you run bun test from the root, Bun discovers all *.test.ts and *.spec.ts files recursively across all workspace packages. The output groups results by file path, making it easy to see which package a failure belongs to.

CI Setup with GitHub Actions

# .github/workflows/test.yaml
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Cache Bun dependencies
        uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            bun-${{ runner.os }}-

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Type check
        run: bun tsc --noEmit

      - name: Run tests
        run: bun test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage/lcov.info

Per-Package CI Matrix

For large monorepos where packages are independent, a matrix strategy avoids running all packages on every change:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: [types, api, worker, test-utils]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Test ${{ matrix.package }}
        run: bun test packages/${{ matrix.package }}

Testing Package Build Outputs

If packages publish compiled output (for npm publishing or for consumers that don't use Bun), test that the build produces the expected files:

// packages/api/src/build.test.ts
import { test, expect, beforeAll } from "bun:test";
import { existsSync } from "fs";
import { join } from "path";
import { $ } from "bun";

const DIST = join(import.meta.dir, "../dist");

beforeAll(async () => {
  await $`bun run build`.cwd(join(import.meta.dir, ".."));
});

test("dist directory is created", () => {
  expect(existsSync(DIST)).toBe(true);
});

test("dist/index.js exists", () => {
  expect(existsSync(join(DIST, "index.js"))).toBe(true);
});

test("dist/index.d.ts exists", () => {
  expect(existsSync(join(DIST, "index.d.ts"))).toBe(true);
});

Handling Module Resolution Differences

If a package uses exports map conditions that behave differently at test time versus build time, configure the test resolver in bunfig.toml:

# bunfig.toml
[test]
# Treat test imports as "development" condition
conditions = ["development", "browser", "module", "main"]

# Preload a global test setup file
preload = ["./test/setup.ts"]

The preload file runs before any test file, making it the right place for global beforeAll setup like database connection pooling or environment variable validation.

What to Test vs. What to Skip

Test:

  • Cross-package imports — write at least one test per package that imports from another workspace package to catch resolution failures early
  • Shared utility functions in test-utils — these are code too; test them
  • Package exports — verify the exported symbols from each package match what downstream consumers expect
  • Migration of a shared type — when you change a type in @acme/types, run the full monorepo test suite to find all breakage at once
  • CI cache correctness — check that bun.lockb is committed and --frozen-lockfile fails on CI if it drifts

Skip:

  • Build output correctness in unit tests — testing that dist/ contains the right files is a slow build test; run it separately from fast unit tests
  • Cross-package performance — benchmark tests for cross-package calls belong in a dedicated benchmark suite
  • Workspace symlink internals — trust that bun install creates the correct symlinks; don't write tests that inspect node_modules
  • Duplicate coverage — if @acme/types has its own tests, don't re-test type guards from @acme/api tests

Read more