Testing Guide
Test Runner
BANA uses Bun's native test runner (bun:test). No Jest, Vitest, or external test frameworks.
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test';Running Tests
Per-package
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 testVia Makefile
make benchmark-ledger # ledger PDF/XLSX benchmarkThere is no make test-all target — tests are run per-package.
Package.json scripts
Every package follows the same pattern:
{
"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"
}| Script | Runs against | DB required | Use case |
|---|---|---|---|
test | Compiled JS in dist/ | Usually | CI, full integration |
test:unit | Source TS in src/ | No | Fast 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 generatorsThe 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.
// 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
| Module | Why |
|---|---|
@venizia/ignis | DI decorators, base classes — can't construct without the full container |
@venizia/ignis-helpers | getError() needs to produce real Error objects; HTTP codes are used in assertions |
@nx/core | Statuses, helpers, constants — mock only what your tests reference |
@/repositories | Cut the DI chain so @inject() decorators don't blow up |
drizzle-orm | Prevent 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:
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
@injectdecorators are already mocked to no-ops inpreload.ts
Mock Factories
Create shared helpers for common mock objects:
// 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:
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
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
| Convention | Detail |
|---|---|
| Test names | Describe the behavior: "creates entity and returns it", not "test create" |
| One assertion per test | Prefer focused tests over multi-assertion tests |
| Fresh setup | Use beforeEach or per-test setupService() for isolation |
| Test error cases | Verify status codes and error messages for all failure paths |
Use toMatchObject | For error assertions — matches statusCode without requiring exact Error shape |
| Check side effects | Verify toHaveBeenCalled() / toHaveBeenCalledWith() on mocked repos |
What to test
- Happy paths — normal operation returns expected result
- State guards — invalid state transitions throw correct errors (409 Conflict)
- Not found — missing entities throw 404
- Business rules — domain constraints enforced (activation limits, perpetual can't renew, etc.)
- Transaction safety — verify
commit()on success,rollback()on failure - Fire-and-forget — verify side effects (event logging, certificate publishing) are called
What NOT to test
- Framework internals — don't test that
@injectworks or thatControllerFactorygenerates 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:
| File | Purpose |
|---|---|
preload.ts | Mocks IGNIS, ignis-helpers, @nx/core, @/common, @/repositories, drizzle-orm |
unit/helpers.ts | createMockRepository, createMockRedis, createMockTransaction, fixture factories for Policy/License/Activation/PolicyFeature, createServiceInstance + wireBaseService helpers |
unit/licensing-lifecycle.test.ts | 37 tests across 3 services: LicenseManagement (14), Validation (16), Activation (7) |
cd packages/licensing
bun run test:unit
# 37 pass, 0 fail, 31msAdding Tests to a New Package
- Create
src/__tests__/preload.ts— copy from licensing, adjust mocked modules for your package's imports - Create
src/__tests__/unit/helpers.ts— mock repo factory + domain fixtures - Create test files in
src/__tests__/unit/ - Add
test:unitscript topackage.json:json"test:unit": "bun test --run --preload ./src/__tests__/preload.ts src/__tests__/unit/" - Run:
bun run test:unit
Related Pages
| Page | Description |
|---|---|
| Getting Started | How to run tests |
| IGNIS Patterns | The DI patterns that tests mock around |
| Build System | make benchmark-ledger and test-related targets |