Skip to content

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.

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

Chạy Test

Theo từng package

bash
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 test

Qua Makefile

bash
make benchmark-ledger    # benchmark PDF/XLSX của ledger

Khô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:

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"
}
ScriptChạy trênCần DBTrường hợp dùng
testJS đã biên dịch trong dist/Thường là cóCI, integration đầy đủ
test:unitTS source trong src/KhôngPhá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ạp

Pattern 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.

typescript
// 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ì

ModuleTại sao
@venizia/ignisDecorator DI, lớp cơ sở — không thể construct nếu không có container đầy đủ
@venizia/ignis-helpersgetError() cần tạo ra object Error thực; mã HTTP được dùng trong assertion
@nx/coreStatus, helper, hằng số — chỉ mock những gì test của bạn tham chiếu
@/repositoriesCắt chuỗi DI để decorator @inject() không bị nổ
drizzle-ormNgă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:

typescript
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 trong preload.ts

Mock Factory

Tạo các helper dùng chung cho các object mock phổ biến:

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 Factory

Tạo các fixture có tên cho object miền:

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',
    // ... timestamp, v.v.
    ...overrides,
  };
}

Viết Test

Pattern file test

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,
      });
    });
  });
});

Quy ước

Quy ướcChi tiết
Tên testMô 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ớiDùng beforeEach hoặc setupService() mỗi test để cô lập
Test trường hợp lỗiXác minh status code và thông báo lỗi cho mọi đường thất bại
Dùng toMatchObjectCho 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 effectXác minh toHaveBeenCalled() / toHaveBeenCalledWith() trên repo được mock

Cần test gì

  1. Đường happy — vận hành bình thường trả về kết quả mong đợi
  2. 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)
  3. Không tìm thấy — entity bị thiếu ném 404
  4. 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.)
  5. An toàn transaction — xác minh commit() khi thành công, rollback() khi thất bại
  6. 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 @inject hoạt động hay ControllerFactory tạ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:

FileMục đích
preload.tsMock IGNIS, ignis-helpers, @nx/core, @/common, @/repositories, drizzle-orm
unit/helpers.tscreateMockRepository, createMockRedis, createMockTransaction, fixture factory cho Policy/License/Activation/PolicyFeature, helper createServiceInstance + wireBaseService
unit/licensing-lifecycle.test.ts37 test trên 3 service: LicenseManagement (14), Validation (16), Activation (7)
bash
cd packages/licensing
bun run test:unit
# 37 pass, 0 fail, 31ms

Thêm Test cho Package Mới

  1. 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
  2. Tạo src/__tests__/unit/helpers.ts — factory mock repo + fixture miền
  3. Tạo các file test trong src/__tests__/unit/
  4. Thêm script test:unit vào package.json:
    json
    "test:unit": "bun test --run --preload ./src/__tests__/preload.ts src/__tests__/unit/"
  5. Chạy: bun run test:unit

Trang Liên quan

TrangMô tả
Bắt đầuCách chạy test
IGNIS PatternsCác pattern DI mà test mock xung quanh
Hệ thống Buildmake benchmark-ledger và các target liên quan đến test

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