Hướng dẫn Kiểm thử
Test Runner
BANA dùng test runner gốc của Bun (bun:test). Không dùng Jest, Vitest hay framework test bên ngoài nào khác.
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test';Chạy Test
Theo từng package
cd packages/licensing
# Unit test (chạy từ source, nhanh, không cần DB)
bun run test:unit
# Toàn bộ test suite (output đã biên dịch, có thể cần .env.test + DB)
bun run testQua Makefile
make benchmark-ledger # benchmark PDF/XLSX của ledgerKhông có target make test-all — test được chạy theo từng package.
Script package.json
Mỗi package tuân theo cùng 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 | Chạy trên | Cần DB | Trường hợp dùng |
|---|---|---|---|
test | JS đã biên dịch trong dist/ | Thường là có | CI, integration đầy đủ |
test:unit | TS source trong src/ | Không | Phát triển cục bộ nhanh |
Hook pretest đảm bảo package được build lại trước khi toàn bộ test suite chạy.
Cấu trúc Thư mục
packages/{package}/src/__tests__/
├── preload.ts # Mock module (chạy trước TẤT CẢ file test)
├── unit/
│ ├── helpers.ts # Mock factory và fixture
│ ├── {service}.test.ts # Test cấp service
│ └── services/
│ └── {service}.test.ts
└── helpers/
└── fake-data-generators.ts # (tùy chọn) generator fixture phức tạpPattern Preload
preload.ts dùng mock.module() từ bun:test để chặn các câu lệnh import trước khi các file test được nạp. Điều này loại bỏ toàn bộ framework DI, driver database và các phụ thuộc bên ngoài khỏi runtime test.
// src/__tests__/preload.ts
import { mock } from 'bun:test';
// Mock framework IGNIS
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 các export của @nx/core
mock.module('@nx/core', () => ({
LicenseCertSignerHelper: { getInstance: () => ({ sign: () => 'mock-cert' }) },
// ... các export cần thiết khác
}));
// Mock các repository (cắt chuỗi DI)
mock.module('@/repositories', () => ({
MyRepository: class {},
}));
// Mock driver database
mock.module('drizzle-orm', () => ({
sql: (s: TemplateStringsArray, ...v: any[]) => ({ strings: s, values: v }),
}));Cần mock cái gì
| Module | Tại sao |
|---|---|
@venizia/ignis | Decorator DI, lớp cơ sở — không thể construct nếu không có container đầy đủ |
@venizia/ignis-helpers | getError() cần tạo ra object Error thực; mã HTTP được dùng trong assertion |
@nx/core | Status, helper, hằng số — chỉ mock những gì test của bạn tham chiếu |
@/repositories | Cắt chuỗi DI để decorator @inject() không bị nổ |
drizzle-orm | Ngăn không cho khởi tạo driver database thực |
KHÔNG nên mock cái gì
Đừng mock các service mà bạn đang test. Toàn bộ điểm là chạy logic service thật. Chỉ mock các phụ thuộc của chúng (repository, Redis, helper bên ngoài).
Pattern Object.create
Các service dùng DI dựa trên constructor (decorator @inject) yêu cầu container IGNIS đầy đủ để giải quyết. Trong test, ta bỏ qua hoàn toàn constructor bằng Object.create() và inject các phụ thuộc mock dưới dạng property:
import { LicenseManagementService } from '@/services/licensing/license-management.service';
function setupService() {
// Tạo instance mà không gọi constructor
const svc = Object.create(LicenseManagementService.prototype);
// Inject các phụ thuộc mock dưới dạng property
svc.licenseRepository = createMockRepository();
svc.policyRepository = createMockRepository();
svc.logger = {
for: () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} }),
};
svc.redis = { client: { set: mock(() => Promise.resolve('OK')) } };
return svc;
}Cách này hoạt động vì:
- Phương thức prototype của JavaScript không quan tâm instance được tạo như thế nào
- Repository mock thỏa mãn cùng interface mà repository thật thỏa mãn
- Decorator
@injectđã được mock thành no-op trongpreload.ts
Mock Factory
Tạo các helper dùng chung cho các object mock phổ biến:
// 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 Factory
Tạo các fixture có tên cho object miền:
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',
// ... timestamp, v.v.
...overrides,
};
}Viết Test
Pattern file test
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,
});
});
});
});Quy ước
| Quy ước | Chi tiết |
|---|---|
| Tên test | Mô tả hành vi: "creates entity and returns it", không phải "test create" |
| Một assertion mỗi test | Ưu tiên test tập trung hơn là test nhiều assertion |
| Setup tươi mới | Dùng beforeEach hoặc setupService() mỗi test để cô lập |
| Test trường hợp lỗi | Xác minh status code và thông báo lỗi cho mọi đường thất bại |
Dùng toMatchObject | Cho assertion lỗi — khớp statusCode mà không yêu cầu hình dạng Error chính xác |
| Kiểm tra side effect | Xác minh toHaveBeenCalled() / toHaveBeenCalledWith() trên repo được mock |
Cần test gì
- Đường happy — vận hành bình thường trả về kết quả mong đợi
- Bảo vệ trạng thái — chuyển trạng thái không hợp lệ ném đúng lỗi (409 Conflict)
- Không tìm thấy — entity bị thiếu ném 404
- Quy tắc nghiệp vụ — ràng buộc miền được thực thi (giới hạn kích hoạt, perpetual không thể renew, v.v.)
- An toàn transaction — xác minh
commit()khi thành công,rollback()khi thất bại - Fire-and-forget — xác minh side effect (log sự kiện, publish chứng chỉ) được gọi
KHÔNG cần test gì
- Nội bộ framework — đừng test rằng
@injecthoạt động hayControllerFactorytạo route - Query database — đó là trách nhiệm của Drizzle
- Hành vi mock — xác minh code của bạn, không phải setup mock
Triển khai Tham khảo
Package licensing (packages/licensing/src/__tests__/) là chuẩn mực chất lượng:
| File | Mục đích |
|---|---|
preload.ts | Mock IGNIS, ignis-helpers, @nx/core, @/common, @/repositories, drizzle-orm |
unit/helpers.ts | createMockRepository, createMockRedis, createMockTransaction, fixture factory cho Policy/License/Activation/PolicyFeature, helper createServiceInstance + wireBaseService |
unit/licensing-lifecycle.test.ts | 37 test trên 3 service: LicenseManagement (14), Validation (16), Activation (7) |
cd packages/licensing
bun run test:unit
# 37 pass, 0 fail, 31msThêm Test cho Package Mới
- Tạo
src/__tests__/preload.ts— sao chép từ licensing, điều chỉnh các module được mock cho các import của package bạn - Tạo
src/__tests__/unit/helpers.ts— factory mock repo + fixture miền - Tạo các file test trong
src/__tests__/unit/ - Thêm script
test:unitvàopackage.json:json"test:unit": "bun test --run --preload ./src/__tests__/preload.ts src/__tests__/unit/" - Chạy:
bun run test:unit
Trang Liên quan
| Trang | Mô tả |
|---|---|
| Bắt đầu | Cách chạy test |
| IGNIS Patterns | Các pattern DI mà test mock xung quanh |
| Hệ thống Build | make benchmark-ledger và các target liên quan đến test |