Skip to content

Testing Guide

Test Runner

BANA uses Bun's native test runner (bun:test). No Jest, Vitest, or external test frameworks.

typescript
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test';

Running Tests

Per-package

bash
cd packages/licensing

# Unit tests (run from source, fast, no DB required)
bun run test:unit

# Full test suite (compiled output, may need .env.test + DB)
bun run test

Via Makefile

bash
make benchmark-ledger    # ledger PDF/XLSX benchmark

There is no make test-all target — tests are run per-package.

Package.json scripts

Every package follows the same pattern:

json
{
  "test": "bun test --env-file=.env.test dist/__tests__/**/*.test.js",
  "test:unit": "bun test --run --preload ./src/__tests__/preload.ts src/__tests__/unit/",
  "pretest": "bun run rebuild"
}
ScriptRuns againstDB requiredUse case
testCompiled JS in dist/UsuallyCI, full integration
test:unitSource TS in src/NoFast local development

The pretest hook ensures the package is rebuilt before the full test suite runs.

Directory Structure

packages/{package}/src/__tests__/
├── preload.ts              # Module mocks (run before ALL test files)
├── unit/
│   ├── helpers.ts          # Mock factories and fixtures
│   ├── {service}.test.ts   # Service-level tests
│   └── services/
│       └── {service}.test.ts
└── helpers/
    └── fake-data-generators.ts   # (optional) complex fixture generators

The Preload Pattern

preload.ts uses mock.module() from bun:test to intercept import statements before test files load. This cuts the entire DI framework, database drivers, and external dependencies out of the test runtime.

typescript
// src/__tests__/preload.ts
import { mock } from 'bun:test';

// Mock the IGNIS framework
mock.module('@venizia/ignis', () => ({
  BaseService: class BaseService {
    logger = {
      for: () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} }),
    };
    constructor(_opts?: any) {}
  },
  inject: () => () => {},
  LockStrengths: { UPDATE: 'UPDATE', SHARE: 'SHARE' },
  BindingKeys: class { static build(o: any) { return `${o.namespace}/${o.key}`; } },
  BindingNamespaces: { SERVICE: 'services', REPOSITORY: 'repositories' },
}));

// Mock ignis-helpers
mock.module('@venizia/ignis-helpers', () => ({
  getError: (opts: any) => {
    const err = new Error(opts.message) as any;
    err.statusCode = opts.statusCode;
    return err;
  },
  HTTP: {
    ResultCodes: {
      RS_4: { BadRequest: 400, NotFound: 404, Conflict: 409 },
      RS_5: { InternalServerError: 500 },
    },
  },
}));

// Mock @nx/core exports
mock.module('@nx/core', () => ({
  LicenseCertSignerHelper: { getInstance: () => ({ sign: () => 'mock-cert' }) },
  // ... other needed exports
}));

// Mock repositories (cut DI chains)
mock.module('@/repositories', () => ({
  MyRepository: class {},
}));

// Mock database driver
mock.module('drizzle-orm', () => ({
  sql: (s: TemplateStringsArray, ...v: any[]) => ({ strings: s, values: v }),
}));

What to mock

ModuleWhy
@venizia/ignisDI decorators, base classes — can't construct without the full container
@venizia/ignis-helpersgetError() needs to produce real Error objects; HTTP codes are used in assertions
@nx/coreStatuses, helpers, constants — mock only what your tests reference
@/repositoriesCut the DI chain so @inject() decorators don't blow up
drizzle-ormPrevent actual database driver initialization

What NOT to mock

Do not mock the services you're testing. The whole point is to run real service logic. Only mock their dependencies (repositories, Redis, external helpers).

The Object.create Pattern

Services use constructor-based DI (@inject decorators) that requires the full IGNIS container to resolve. In tests, we bypass the constructor entirely using Object.create() and inject mock dependencies as properties:

typescript
import { LicenseManagementService } from '@/services/licensing/license-management.service';

function setupService() {
  // Create instance without calling the constructor
  const svc = Object.create(LicenseManagementService.prototype);

  // Inject mock dependencies as properties
  svc.licenseRepository = createMockRepository();
  svc.policyRepository = createMockRepository();
  svc.logger = {
    for: () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} }),
  };
  svc.redis = { client: { set: mock(() => Promise.resolve('OK')) } };

  return svc;
}

This works because:

  • JavaScript prototype methods don't care how the instance was created
  • The mock repositories satisfy the same interface the real ones do
  • The @inject decorators are already mocked to no-ops in preload.ts

Mock Factories

Create shared helpers for common mock objects:

typescript
// src/__tests__/unit/helpers.ts
import { mock } from 'bun:test';

export function createMockTransaction() {
  return {
    commit: mock(() => Promise.resolve()),
    rollback: mock(() => Promise.resolve()),
  };
}

export function createMockRepository(overrides: Record<string, unknown> = {}) {
  return {
    find: mock(() => Promise.resolve([])),
    findOne: mock(() => Promise.resolve(null)),
    findById: mock(() => Promise.resolve(null)),
    create: mock(() => Promise.resolve({ data: {} })),
    updateById: mock(() => Promise.resolve({})),
    updateBy: mock(() => Promise.resolve({ count: 0 })),
    deleteById: mock(() => Promise.resolve({})),
    count: mock(() => Promise.resolve({ count: 0 })),
    beginTransaction: mock(() => Promise.resolve(createMockTransaction())),
    ...overrides,
  };
}

export function createMockRedis() {
  return {
    client: {
      set: mock(() => Promise.resolve('OK')),
      get: mock(() => Promise.resolve(null)),
      del: mock(() => Promise.resolve(1)),
    },
  };
}

Fixture factories

Create named fixtures for domain objects:

typescript
export function createPolicy(overrides = {}) {
  return {
    id: 'policy-001',
    name: 'BANA Professional',
    type: '100_SUBSCRIPTION',
    status: 'activated',
    duration: { unit: 'day', value: 30 },
    activation: { limit: 3 },
    gracePeriod: { unit: 'day', value: 7 },
    ...overrides,
  };
}

export function createLicense(overrides = {}) {
  return {
    id: 'lic-001',
    policyId: 'policy-001',
    key: 'BANA-AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD',
    status: 'activated',
    entityType: 'merchants',
    entityId: 'merchant-001',
    // ... timestamps, etc.
    ...overrides,
  };
}

Writing Tests

Test file pattern

typescript
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { MyService } from '@/services/my.service';
import { createMockRepository, createFixture } from './helpers';

function setupService(repoOverrides = {}) {
  const repo = createMockRepository(repoOverrides);
  const svc = Object.create(MyService.prototype);
  svc.myRepository = repo;
  svc.logger = { for: () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} }) };
  return { svc, repo };
}

describe('MyService', () => {
  describe('create()', () => {
    test('creates entity and returns it', async () => {
      const expected = createFixture({ id: 'new-001' });
      const { svc, repo } = setupService({
        create: mock(() => Promise.resolve({ data: expected })),
      });

      const result = await svc.create({ name: 'Test' });

      expect(result.data.id).toBe('new-001');
      expect(repo.create).toHaveBeenCalledWith(
        expect.objectContaining({ data: expect.objectContaining({ name: 'Test' }) }),
      );
    });

    test('rejects invalid input with 400', async () => {
      const { svc } = setupService();

      await expect(svc.create({ name: '' })).rejects.toMatchObject({
        statusCode: 400,
      });
    });
  });
});

Conventions

ConventionDetail
Test namesDescribe the behavior: "creates entity and returns it", not "test create"
One assertion per testPrefer focused tests over multi-assertion tests
Fresh setupUse beforeEach or per-test setupService() for isolation
Test error casesVerify status codes and error messages for all failure paths
Use toMatchObjectFor error assertions — matches statusCode without requiring exact Error shape
Check side effectsVerify toHaveBeenCalled() / toHaveBeenCalledWith() on mocked repos

What to test

  1. Happy paths — normal operation returns expected result
  2. State guards — invalid state transitions throw correct errors (409 Conflict)
  3. Not found — missing entities throw 404
  4. Business rules — domain constraints enforced (activation limits, perpetual can't renew, etc.)
  5. Transaction safety — verify commit() on success, rollback() on failure
  6. Fire-and-forget — verify side effects (event logging, certificate publishing) are called

What NOT to test

  • Framework internals — don't test that @inject works or that ControllerFactory generates routes
  • Database queries — those are Drizzle's responsibility
  • Mock behavior — verify your code, not the mock setup

Reference Implementation

The licensing package (packages/licensing/src/__tests__/) is the quality benchmark:

FilePurpose
preload.tsMocks IGNIS, ignis-helpers, @nx/core, @/common, @/repositories, drizzle-orm
unit/helpers.tscreateMockRepository, createMockRedis, createMockTransaction, fixture factories for Policy/License/Activation/PolicyFeature, createServiceInstance + wireBaseService helpers
unit/licensing-lifecycle.test.ts37 tests across 3 services: LicenseManagement (14), Validation (16), Activation (7)
bash
cd packages/licensing
bun run test:unit
# 37 pass, 0 fail, 31ms

Adding Tests to a New Package

  1. Create src/__tests__/preload.ts — copy from licensing, adjust mocked modules for your package's imports
  2. Create src/__tests__/unit/helpers.ts — mock repo factory + domain fixtures
  3. Create test files in src/__tests__/unit/
  4. Add test:unit script to package.json:
    json
    "test:unit": "bun test --run --preload ./src/__tests__/preload.ts src/__tests__/unit/"
  5. Run: bun run test:unit
PageDescription
Getting StartedHow to run tests
IGNIS PatternsThe DI patterns that tests mock around
Build Systemmake benchmark-ledger and test-related targets

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.