This guide covers unit testing best practices and conventions for the Bitbybit monorepo.
For more information about our unit testing philosophy and live coverage reports, visit:
- Unit Testing Approach - Learn about our pragmatic approach to testing 3D algorithms
- The report every CI run publishes - every suite's tests and coverage, and what each moved against its floor
- Language: TypeScript
- Test Framework: Vitest
- Assertion Style: Vitest's built-in expect assertions
- Imports: every test API is imported explicitly -
import { describe, it, expect, vi } from "vitest"- so nothing a test uses is invisible
Each folder under packages/dev/ is an independent npm package with its own:
package.jsonwith dependencies and test scripts- a
vitest.config.tsthat calls the shared factory inpackages/dev/vitest.shared.tsand states only what differs: the coverage globs, and where needed a jsdom environment, a process per file, or a module to stand in for another - Coverage report (generated in the package directory when running
npm run test:coverage)
packages/dev/
├── base/ ← @bitbybit-dev/base
├── babylonjs/ ← @bitbybit-dev/babylonjs
├── threejs/ ← @bitbybit-dev/threejs
├── playcanvas/ ← @bitbybit-dev/playcanvas
├── occt/ ← @bitbybit-dev/occt
├── occt-worker/ ← @bitbybit-dev/occt-worker
├── jscad/ ← @bitbybit-dev/jscad
├── jscad-worker/ ← @bitbybit-dev/jscad-worker
├── manifold/ ← @bitbybit-dev/manifold
├── manifold-worker/← @bitbybit-dev/manifold-worker
├── ifc/ ← @bitbybit-dev/ifc
├── ifc-worker/ ← @bitbybit-dev/ifc-worker
└── core/ ← @bitbybit-dev/core
Navigate to a specific package to run tests or view coverage for that package.
- Naming convention:
<file-name-that-is-tested>.test.ts - Location: Test files are placed next to the file being tested, not in a separate test directory.
lib/api/
├── bitbybit-base.ts
├── bitbybit-base.test.ts ← test file adjacent to source
├── context.ts
├── context.test.ts ← test file adjacent to source
└── draw-helper.ts
└── draw-helper.test.ts ← test file adjacent to source
Reusable mocks are placed in the __mocks__ folder at the highest level of the source code directory:
lib/api/
├── __mocks__/
│ ├── babylonjs.mock.ts ← reusable BabylonJS mocks
│ └── test-helpers.ts ← factory functions for test setup
├── bitbybit-base.ts
└── bitbybit-base.test.ts
vi.mock("@babylonjs/core", async () => {
const { createBabylonJSMock } = await vi.importActual("./__mocks__/babylonjs.mock");
return createBabylonJSMock();
});vi.mock is hoisted above the imports, so its factory may not close over anything declared in the
file; reach for the mock module inside the factory, as above. vi.importActual is asynchronous,
which is why the factory is async.
A mock the code under test reaches through new must be a function, never an arrow: only a
function can be constructed, and one that returns an object hands that object back to new.
MeshInstance: vi.fn(function (mesh, material, node = mockNode) {
return { mesh, material, node };
}),| Command | Description |
|---|---|
npm run test |
Run all package tests |
npm run test-base |
Test base package |
npm run test-occt |
Test OCCT package |
npm run test-core |
Test core package |
npm run test-jscad |
Test JSCAD package |
npm run test-manifold |
Test Manifold package |
npm run test-ifc |
Test IFC package |
npm run test-threejs |
Test ThreeJS package |
npm run test-playcanvas |
Test PlayCanvas package |
npm run test-babylonjs |
Test BabylonJS package |
npm run test-occt-worker |
Test OCCT worker package |
npm run test-jscad-worker |
Test JSCAD worker package |
npm run test-manifold-worker |
Test Manifold worker package |
npm run test-ifc-worker |
Test IFC worker package |
Within individual packages:
npm run test- one runnpm run test:coverage- one run with coverage (what CI runs)npm run test:watch- re-run as you edit
Coverage is a floor, not a report: packages/dev/coverage-baseline.json records what each suite
reaches, and npm run check:coverage-baseline at the root fails a run that reaches less or that
lost tests. Raise the floor with npm run coverage-baseline:save when a suite genuinely improves.
packages/dev/vitest.shared.ts is the configuration every package suite shares. A package's
vitest.config.ts passes it only what differs: the coverage globs, which are that package's
denominator; environment: "jsdom" where the code under test reaches for a document or a canvas;
pool: "forks" where a kernel holds global state, so each file gets its own process; and
siblingsFromDist.
- Siblings resolve through the
@bitbybit-dev/sourcecondition their exports maps put first, so a suite reads a sibling's TypeScript sources and sees an edit without a rebuild. Node's own conditions follow it, spelled out, because naming a condition replaces the default list rather than adding to it. The packages directory as a whole is readable to the dev server, since siblings sit beside a package, not under it. siblingsFromDistnames the siblings a suite reads from their builtdistinstead, both the bare package name and any subpath under it. The renderer suites need it: they mount an engine against compiled siblings, and a sibling's sources would drag its own untranspiled dependencies into the run. Build those siblings before running such a suite.- The coverage exclusions are spelled out in full, because naming one replaces vitest's defaults:
the tests,
__mocks__,__test__(the kernel boot helpers the suites share), and the inputs fragments, which are the authoring form of an assembled namespace already counted. - Results are written as JSON to
test-results/vitest.jsonfor the repository's test report. On GitHub Actions the annotations stay and the runner's own job summary is switched off, leaving the summary to that report. The SDK, the scaffolder and the MCP server keep their own configs and write the same results.
Structure every test using the AAA pattern:
it("should calculate distance between two points", () => {
// Arrange
const startPoint = [0, 0, 0];
const endPoint = [3, 4, 0];
// Act
const result = bitByBit.point.distance({ startPoint, endPoint });
// Assert
expect(result).toBeCloseTo(5, 5);
});bitbybit/no-loose-comments runs over every *.test.ts, and it allows exactly // Arrange,
// Act, // Assert and // Act & Assert - the marker on its own, with nothing after it. Every
other comment in a test file is an error, and there is no fixer, so the rule never rewrites your file.
The markers are structure rather than description: they say which part of the test a line belongs to, and a marker cannot drift from the code because it makes no claim about it. A sentence explaining why can drift, and in a test it has somewhere better to go:
| What you wanted to write in a comment | Where it goes instead |
|---|---|
| what this test proves | the name of the it |
| what this group of tests is about | the name of the describe |
| why this input, and not another | the name of the value: const unevenlySpacedCorners = ... |
| what this arrangement is | a named helper: const documentOfOneBox = () => ... |
| what a magic number means | a named constant: const FULL_TURN_DEGREES = 360; |
| a caveat the code cannot express | the it name, as behaviour: "should ... , because a turn is baked into the geometry" |
// Wrong - the note is a second description, and the name says nothing
it("should subdivide", () => {
// a face longer than it is wide, so the two sides differ
const f = face.createRectangleFace({ width: 20, length: 5, center: [0, 0, 0], direction: [0, 0, 1] });
...
});
// Right - the name carries the claim, the value carries the reason
it("should round the rectangles by the shorter of their two sides", () => {
// Arrange
const longerThanItIsWide = face.createRectangleFace({ width: 20, length: 5, center: [0, 0, 0], direction: [0, 0, 1] });
...
});Both discard what the compiler knew. as any says nothing is checked; as unknown as T widens
until nothing is left to check and then declares a type, so the declared type can be wrong in every
way and the build stays green. The lint rule bitbybit/no-double-assertion fails a double assertion
wherever it appears, tests included, so reach for one of these instead:
- A mock class that satisfies the contract, so no assertion is needed at all. Extending the
stand-in a package already ships is usually the shortest route:
class RecordingWorker extends JSCADWorkerMock, orclass RecordingWorker extends EventTarget implements Workerwhere a wholeWorkeris what the method declares. - A single
asfrom a value declared opaque.unknownconverts to anything in one step, which is honest about what the test is doing:const SENTINEL: unknown = { ... }and thenSENTINEL as Tat the point of use. Partial<T> as Tfor a mock that carries only the members the code under test reaches.- A helper in
__mocks__that reads an engine-typed handle as the stand-in it actually is, so the one assertion lives beside the mock rather than in every suite.
// ❌ Avoid - nothing is checked
const mockScene = {} as any;
// ❌ Avoid - the assertion is unfalsifiable, and lint fails it
const mockScene = new MockScene() as unknown as BABYLON.Scene;
// ✅ A mock class that satisfies the contract, asserted once from Partial
const mockScene: Partial<BABYLON.Scene> = { meshes: [] };
const scene = mockScene as BABYLON.Scene;
// ✅ Or, where the engine can run headless, no stand-in at all
const engine = new BABYLON.NullEngine();
const scene = new BABYLON.Scene(engine);Where a library can run outside a browser, run it. BabylonJS ships NullEngine for exactly this, the
OCCT, JSCAD and Manifold kernels load under the test runner, and verb is plain JavaScript. A suite
over the real library asserts what the library ends up holding, which is what a user gets; a suite
over a mock asserts which call was made, which is only what the code says it does. Stand something in
when the real one cannot reach the case at all - a kernel that throws a string, a transport that
refuses a message - and say so in the file.
// ❌ Avoid vague assertions
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result).toBeTruthy();
// ✅ Test specific expected values
expect(result).toEqual([5, 7, 9]);
expect(result.length).toBe(3);
expect(result).toEqual({ r: 255, g: 0, b: 0 });// For floating point comparisons
expect(result).toBeCloseTo(3.14, 2);
// For object structure
expect(result).toEqual({ start: [0, 0, 0], end: [1, 1, 1] });
// For arrays
expect(result).toEqual([[0, 0, 0], [1, 0, 0], [1, 1, 0]]);
// For instance checks
expect(bitByBit).toBeInstanceOf(BitByBitBase);
// For function behavior
expect(() => fn()).not.toThrow();
expect(() => fn()).toThrow(ExpectedError);Every test suite should include tests for failure cases and edge conditions:
describe("error handling", () => {
it("should throw error when input is undefined", () => {
expect(() => service.process(undefined)).toThrow();
});
it("should throw error with specific message for invalid input", () => {
expect(() => service.validate(-1)).toThrow("Value must be positive");
});
it("should handle empty array gracefully", () => {
const result = service.processArray([]);
expect(result).toEqual([]);
});
it("should handle null values", () => {
expect(() => service.compute(null)).toThrow();
});
});describe("ServiceName unit tests", () => {
let service: ServiceName;
beforeEach(() => {
service = new ServiceName();
});
describe("Constructor initialization", () => {
it("should create instance", () => { /* ... */ });
});
describe("methodName", () => {
it("should return expected result for valid input", () => { /* ... */ });
it("should throw for invalid input", () => { /* ... */ });
it("should handle edge case", () => { /* ... */ });
});
describe("Integration with dependencies", () => {
it("should work with dependency service", () => { /* ... */ });
});
});Each test should be independent and not rely on state from other tests:
// ✅ Use beforeEach to reset state
beforeEach(() => {
bitByBit = new BitByBitBase();
});Create helper functions for common test setups in test-helpers.ts:
export function createMockContext(): Context {
const mockScene = new MockScene();
return {
scene: mockScene as unknown as BABYLON.Scene,
engine: null,
havokPlugin: null,
} as unknown as Context;
}
export function createMockWorkerManagers() {
return {
mockJscadWorkerManager: { /* ... */ } as unknown as JSCADWorkerManager,
mockManifoldWorkerManager: { /* ... */ } as unknown as ManifoldWorkerManager,
mockOccWorkerManager: { /* ... */ } as unknown as OCCTWorkerManager
};
}- Test file placed next to source file
- File named
<source-file>.test.ts - Reusable mocks in
__mocks__folder - AAA pattern followed (Arrange-Act-Assert)
- No comment in the file except those three markers, each on its own
- Specific assertions used (not
toBeDefined,toBeGreaterThan(0)) - Failure scenarios included
-
as anyavoided, andas unknown as Ttoo -bitbybit/no-double-assertionbans both - Each test is independent
- Descriptive test names that explain expected behavior
Each test should verify a single behavior. If a test fails, it should be immediately clear what broke.
// ❌ Testing multiple behaviors
it("should create and validate user", () => {
const user = service.create({ name: "John" });
expect(user.id).toBeDefined();
expect(service.validate(user)).toBe(true);
expect(service.save(user)).resolves.toBe(true);
});
// ✅ Separate tests for each behavior
it("should create user with generated id", () => { /* ... */ });
it("should validate user successfully", () => { /* ... */ });
it("should save user to database", () => { /* ... */ });Test names should describe the expected behavior, not the implementation:
// ❌ Implementation-focused names
it("calls calculateDistance", () => { /* ... */ });
it("returns number", () => { /* ... */ });
// ✅ Behavior-focused names
it("should return distance of 5 for 3-4-5 triangle", () => { /* ... */ });
it("should throw when start point is undefined", () => { /* ... */ });Tests should be straightforward without conditionals, loops, or complex logic:
// ❌ Logic in test
it("should process items", () => {
const items = [1, 2, 3];
let sum = 0;
for (const item of items) {
sum += service.process(item);
}
expect(sum).toBe(6);
});
// ✅ Direct assertion
it("should process items and return sum", () => {
const result = service.processAll([1, 2, 3]);
expect(result).toBe(6);
});Always test edge cases and boundaries:
- Empty inputs (empty arrays, empty strings)
- Null/undefined values
- Zero values
- Negative numbers
- Maximum/minimum values
- Single element collections
describe("boundary conditions", () => {
it("should handle empty array", () => { /* ... */ });
it("should handle single element", () => { /* ... */ });
it("should handle maximum value", () => { /* ... */ });
it("should handle negative input", () => { /* ... */ });
});Unit tests should execute quickly:
- Mock external dependencies (APIs, databases, file system)
- Avoid real network calls
- Use minimal test data
- Avoid unnecessary
setTimeoutor delays
Test the public interface, not private methods or internal state:
// ❌ Testing internal implementation
it("should set internal cache", () => {
service.getData();
expect(service["_cache"]).toBeDefined();
});
// ✅ Testing observable behavior
it("should return cached data on second call", () => {
const first = service.getData();
const second = service.getData();
expect(second).toBe(first);
});For complex test data, create reusable fixtures:
// In test-helpers.ts or fixtures.ts
export const validUserData = {
name: "Test User",
email: "test@example.com",
age: 25
};
export const invalidUserData = {
name: "",
email: "invalid",
age: -1
};Use strict matchers when possible:
// ❌ Loose matching
expect(result == 5).toBe(true);
// ✅ Strict matching
expect(result).toBe(5);
expect(result).toStrictEqual({ a: 1, b: 2 });Ensure clean state for each test:
describe("StatefulService", () => {
let service: StatefulService;
beforeEach(() => {
service = new StatefulService();
vi.clearAllMocks();
});
afterEach(() => {
service.cleanup();
});
});Tests should pass regardless of execution order. Never rely on:
- State from previous tests
- Specific test execution order
- Shared mutable data between tests