اختبار NestJS باستخدام Vitest: أنماط الوحدة والتكامل
يعد تطبيق NestJS بدون اختبارات بمثابة مسؤولية. لا يمكنك إعادة البناء بثقة، ولا يمكنك إضافة ميزات دون كسر السلوك الحالي، ولا يمكنك اكتشاف الانحدارات قبل أن تصل إلى مرحلة الإنتاج. لكن كتابة اختبارات سيئة تكاد تكون أسوأ من عدم وجود اختبارات - فالاختبارات البطيئة التي لا يجريها أحد، والاختبارات الهشة المقترنة بتفاصيل التنفيذ، والاختبارات الساخرة التي لا تعكس سلوكًا حقيقيًا، كلها تؤدي إلى تآكل ثقة المطور.
يغطي هذا الدليل كيفية كتابة اختبارات سريعة وموثوقة لـ NestJS باستخدام Vitest - بديل Jest الحديث الذي يعمل في أقل من 30 ثانية لأكثر من 1300 اختبار. نحن نغطي النطاق الكامل: اختبارات الوحدة النقية مع التبعيات الساخرة، واختبارات التكامل مع معاملة قاعدة بيانات حقيقية، واختبارات الأمان، وتكوين CI الذي يجعل كل ذلك يعمل عند كل التزام.
الوجبات الرئيسية
- Vitest متوافق مع Jest ولكنه أسرع بمقدار 2-5 مرات — نفس واجهة برمجة التطبيقات، دون أي احتكاك في الترحيل
- استخدم
vi.hoisted()للمصانع الوهمية لتجنب مشكلات المنطقة الميتة المؤقتة معvi.mock()- سلوك الاختبار، وليس التنفيذ - التأكيد على المخرجات والآثار الجانبية، وليس استدعاءات الأساليب الداخلية
- يجب أن تنتهي السلاسل الوهمية Drizzle SELECT بـ
.limit()، INSERT/UPDATE بـ.returning()- يجب أن تستخدم اختبارات التكامل معاملات قاعدة البيانات الحقيقية التي يتم التراجع عنها بعد كل اختبار
- استخدم
createTestingModuleمن NestJS لاختبار DI المناسب، وليس إنشاء مثيل مباشر للفئة- تنتمي اختبارات الأمان (الحقن، تجاوز المصادقة، حد المعدل) إلى دليل
__security__منفصل- الهدف 80%+ تغطية الخط لطبقة الخدمة؛ 100% لوحدات المصادقة والفوترة
تكوين فيتيست
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
test: {
globals: true,
root: './',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.spec.ts',
'**/*.dto.ts', // DTOs are just type definitions
'**/index.ts', // Barrel exports
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
},
},
reporters: ['verbose'],
maxConcurrency: 10,
pool: 'forks', // Better isolation for database tests
poolOptions: {
forks: { singleFork: false },
},
},
plugins: [
swc.vite({
module: { type: 'es6' },
}),
],
});
# Install
pnpm add -D vitest @vitest/coverage-v8 unplugin-swc @swc/core
# Run
cd apps/api && npx vitest run # All tests once
cd apps/api && npx vitest # Watch mode
cd apps/api && npx vitest run --coverage # With coverage report
نمط اختبار الوحدة
الخدمة تحت الاختبار
// src/modules/contacts/contacts.service.ts
@Injectable()
export class ContactsService {
constructor(
@InjectDatabase() private db: Database,
private readonly cacheService: CacheService,
) {}
async findAll(organizationId: string, page = 1, limit = 20) {
const cached = await this.cacheService.get(`contacts:${organizationId}:${page}`);
if (cached) return cached;
const result = await this.db.query.contacts.findMany({
where: eq(contacts.organizationId, organizationId),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
await this.cacheService.set(`contacts:${organizationId}:${page}`, result, 300);
return result;
}
async create(organizationId: string, dto: CreateContactDto) {
const [contact] = await this.db
.insert(contacts)
.values({ ...dto, organizationId })
.returning();
return contact;
}
}
اختبار الوحدة
// src/modules/contacts/contacts.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsService } from './contacts.service';
import { CacheService } from '../cache/cache.service';
// vi.hoisted() ensures mock factories run before vi.mock() calls
const mockDb = vi.hoisted(() => ({
query: {
contacts: {
findMany: vi.fn(),
},
},
insert: vi.fn(),
}));
const mockCache = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('@ecosire/db', () => ({ DATABASE_TOKEN: 'DATABASE_TOKEN' }));
describe('ContactsService', () => {
let service: ContactsService;
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
providers: [
ContactsService,
{
provide: 'DATABASE_TOKEN',
useValue: mockDb,
},
{
provide: CacheService,
useValue: mockCache,
},
],
}).compile();
service = module.get<ContactsService>(ContactsService);
});
describe('findAll', () => {
it('returns cached result when cache hit', async () => {
const cached = [{ id: '1', name: 'Alice' }];
mockCache.get.mockResolvedValue(cached);
const result = await service.findAll('org_1');
expect(result).toEqual(cached);
expect(mockDb.query.contacts.findMany).not.toHaveBeenCalled();
});
it('queries database on cache miss and caches result', async () => {
const dbResult = [{ id: '1', name: 'Bob', organizationId: 'org_1' }];
mockCache.get.mockResolvedValue(null);
// Drizzle SELECT chains end with .limit()
mockDb.query.contacts.findMany.mockResolvedValue(dbResult);
const result = await service.findAll('org_1');
expect(result).toEqual(dbResult);
expect(mockCache.set).toHaveBeenCalledWith(
'contacts:org_1:1',
dbResult,
300
);
});
});
describe('create', () => {
it('inserts contact and returns created record', async () => {
const newContact = { id: '2', name: 'Carol', organizationId: 'org_1' };
// Drizzle INSERT chains end with .returning()
const returning = vi.fn().mockResolvedValue([newContact]);
const values = vi.fn().mockReturnValue({ returning });
mockDb.insert.mockReturnValue({ values });
const result = await service.create('org_1', { name: 'Carol' });
expect(result).toEqual(newContact);
expect(mockDb.insert).toHaveBeenCalled();
});
});
});
وكيل يمكن استخدامه لنماذج سلسلة الرذاذ
استعلامات Drizzle هي سلاسل بطلاقة. بالنسبة لسيناريوهات اختبار التكامل حيث تحتاج إلى محاكاة السلسلة الكاملة:
// test/helpers/drizzle-mock.ts
export function createDrizzleChainMock(resolveValue: unknown) {
const handler: ProxyHandler<object> = {
get(target, prop) {
if (prop === 'then') {
// Make the chain thenable so await works
return (resolve: (v: unknown) => void) => resolve(resolveValue);
}
// Return a proxy that returns itself for any method call
return (..._args: unknown[]) => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
// Usage in tests
mockDb.select.mockReturnValue(createDrizzleChainMock([{ id: '1' }]));
اختبارات التكامل مع قاعدة البيانات الحقيقية
// src/modules/__integration__/contacts.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '@ecosire/db/schema';
import { ContactsModule } from '../contacts/contacts.module';
import { ContactsService } from '../contacts/contacts.service';
let sql: ReturnType<typeof postgres>;
let db: ReturnType<typeof drizzle>;
let service: ContactsService;
beforeAll(async () => {
// Use a separate test database
const testDbUrl = process.env.TEST_DATABASE_URL ||
'postgres://postgres:postgres@localhost:5433/ecosire_test';
sql = postgres(testDbUrl, { max: 1 });
db = drizzle(sql, { schema });
// Run migrations on test DB
await migrate(db, { migrationsFolder: './drizzle' });
const module = await Test.createTestingModule({
imports: [ContactsModule],
})
.overrideProvider('DATABASE_TOKEN')
.useValue(db)
.compile();
service = module.get<ContactsService>(ContactsService);
});
afterAll(async () => {
await sql.end();
});
beforeEach(async () => {
// Clean up test data between tests
await db.delete(schema.contacts);
});
describe('ContactsService (integration)', () => {
it('creates and retrieves a contact from the real database', async () => {
const created = await service.create('org_test_1', {
name: 'Integration Test User',
email: '[email protected]',
});
expect(created.id).toBeDefined();
expect(created.name).toBe('Integration Test User');
const all = await service.findAll('org_test_1');
expect(all).toHaveLength(1);
expect(all[0].id).toBe(created.id);
});
it('scopes results by organizationId (multi-tenancy)', async () => {
await service.create('org_1', { name: 'Org 1 Contact' });
await service.create('org_2', { name: 'Org 2 Contact' });
const org1Contacts = await service.findAll('org_1');
const org2Contacts = await service.findAll('org_2');
expect(org1Contacts).toHaveLength(1);
expect(org2Contacts).toHaveLength(1);
expect(org1Contacts[0].name).toBe('Org 1 Contact');
});
});
اختبارات وحدة التحكم
// src/modules/contacts/contacts.controller.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';
describe('ContactsController', () => {
let controller: ContactsController;
const mockService = {
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
controllers: [ContactsController],
providers: [
{ provide: ContactsService, useValue: mockService },
],
}).compile();
controller = module.get<ContactsController>(ContactsController);
});
it('calls service.findAll with organizationId from authenticated user', async () => {
const mockContacts = [{ id: '1', name: 'Test' }];
mockService.findAll.mockResolvedValue(mockContacts);
const mockUser = { id: 'user_1', organizationId: 'org_1', role: 'admin' };
const result = await controller.findAll(
{ user: mockUser } as AuthenticatedRequest,
1,
20
);
expect(result).toEqual(mockContacts);
expect(mockService.findAll).toHaveBeenCalledWith('org_1', 1, 20);
});
});
اختبارات الأمان
// src/modules/__security__/injection.security.spec.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
describe('SQL Injection Prevention', () => {
const injectionPayloads = [
"'; DROP TABLE contacts; --",
"' OR '1'='1",
"1; SELECT * FROM users",
"' UNION SELECT * FROM refresh_tokens --",
];
it.each(injectionPayloads)(
'should safely handle SQL injection attempt: %s',
async (payload) => {
const response = await request(app.getHttpServer())
.get('/contacts')
.query({ search: payload })
.set('Authorization', `Bearer ${validToken}`);
// Should return 200 with empty results or 400 for invalid input
// Should NEVER return 500 (which would indicate unhandled SQL error)
expect(response.status).not.toBe(500);
expect(response.body).not.toMatchObject({
message: expect.stringContaining('syntax error'),
});
}
);
});
describe('Authentication Bypass Prevention', () => {
it('should reject requests without authentication', async () => {
const response = await request(app.getHttpServer())
.get('/admin/contacts');
expect(response.status).toBe(401);
});
it('should reject tampered JWT', async () => {
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${tamperedToken}`);
expect(response.status).toBe(401);
});
it('should reject expired JWT', async () => {
// Token with exp in the past
const expiredToken = generateTokenWithExpiry(-3600);
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${expiredToken}`);
expect(response.status).toBe(401);
});
});
تكوين خط أنابيب CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter=@ecosire/db --filter=@ecosire/types
- name: Run unit tests
run: cd apps/api && npx vitest run --coverage
env:
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: apps/api/coverage/lcov.info
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: ecosire_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: cd apps/api && npx vitest run src/modules/__integration__
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/ecosire_test
الأسئلة المتداولة
لماذا Vitest over Jest لـ NestJS؟
يستخدم Vitest حزمة Vite السريعة (esbuild/Rollup) بدلاً من Babel، مما يوفر تنفيذ اختبار أسرع بمقدار 2-5 مرات. يحتوي على واجهة برمجة تطبيقات متوافقة مع Jest — يتم ترحيل معظم اختبارات Jest بدون أي تغييرات. يتمتع Vitest بدعم ESM أصلي (مهم للحزم الحديثة)، وTypeScript مدمج بدون ts-jest، ووضع مراقبة أفضل. بالنسبة لـ NestJS، الميزة الرئيسية هي حلقات ردود الفعل الأسرع بشكل كبير في وضع المراقبة.
لماذا نستخدم vi.hoisted() للمصانع الوهمية؟
يتم رفع مكالمات vi.mock() إلى أعلى الملف بواسطة محول Vitest. إذا كان المصنع الوهمي الخاص بك يشير إلى متغير تم تعريفه لاحقًا في الملف (مثل const mockDb = { ... })، فسيتم تشغيله قبل تهيئة المتغير - خطأ منطقة ميتة مؤقتة. يقوم vi.hoisted() بإنشاء قيم يتم رفعها أيضًا، بحيث تكون متاحة عند تشغيل المصنع الوهمي.
كيف يمكنني اختبار حراس NestJS والمعترضين؟
قم بإنشاء اختبار تكامل باستخدام createTestingModule مع overrideGuard() لتجاوز الحارس أو اختباره بشكل منفصل. لاختبار أن الحارس يرفض بالفعل الطلبات غير المصرح بها، استخدم Supertest مقابل التطبيق الكامل (app.getHttpServer()) مع الرموز المميزة غير الصالحة أو المفقودة، وأكد رمز الحالة 401/403 الصحيح.
هل يجب أن تنتهي سلاسل Drizzle الوهمية بـ .limit() أو .returning()؟
بالنسبة لاستعلامات SELECT، تنتهي السلسلة عادةً بـ findMany() أو findFirst() أو استدعاء .limit(). بالنسبة للإدراج/التحديث/الحذف بـ values().returning()، تنتهي السلسلة بـ .returning(). استهزئ بأعمق طريقة في السلسلة - مهما كان رمز الخدمة await الأخير. يتعامل نمط الوكيل القابل لذلك مع سلاسل ذات عمق تعسفي.
كيف يمكنني اختبار مهام NestJS المجدولة (@Cron Decorators)؟
قم بإدخال المجدول في وحدة الاختبار الخاصة بك واستدعاء طريقة المهمة مباشرة - لا تنتظر مؤقت cron. استخدم jest.useFakeTimers() (نفس واجهة برمجة التطبيقات في Vitest مع vi.useFakeTimers()) إذا كنت بحاجة إلى اختبار سلوك التوقيت. لاختبار جدولة مهمة، تحقق من أن @nestjs/schedule SchollerRegistry يحتوي على الوظيفة المتوقعة.
ما هي بنية دليل الاختبار الموصى بها لـ NestJS؟
شارك في تحديد موقع اختبارات الوحدة مع الملفات المصدر (contacts.service.spec.ts بجوار contacts.service.ts). ضع اختبارات التكامل في src/modules/__integration__/ واختبارات الأمان في src/modules/__security__/. يتيح لك هذا الفصل تشغيل اختبارات الوحدة فقط أثناء التطوير (vitest run --exclude **/__integration__/**) وجميع الاختبارات في CI.
الخطوات التالية
مجموعة الاختبار الشاملة ليست رفاهية - إنها البنية التحتية التي تسمح لك بالشحن بشكل أسرع وبثقة. مع قيام Vitest بإجراء 1300 اختبار في أقل من 30 ثانية، ليس هناك عذر لتخطي التشغيل التجريبي قبل الدفع.
تقوم ECOSIRE بإنشاء واجهات NestJS الخلفية بتغطية اختبار كاملة من اليوم الأول - 76 ملف اختبار و1301 وحدة واختبار تكامل وتغطية اختبار الأمان للحقن وتجاوز المصادقة وتحديد المعدل. استكشف خدماتنا الهندسية الخلفية للتعرف على كيفية تقديم واجهات برمجة التطبيقات المختبرة والجاهزة للإنتاج.
بقلم
ECOSIRE Research and Development Team
بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.
مقالات ذات صلة
AI + ERP Integration: How AI is Transforming Enterprise Resource Planning
Learn how AI is transforming ERP systems in 2026—from intelligent automation and predictive analytics to natural language interfaces and autonomous operations.
All-in-One vs Best-of-Breed: The Software Stack Decision
All-in-one vs best-of-breed software strategy for 2026: integration complexity, total cost, vendor risk, and when each approach is right for your business.
The API Economy: Building an Integration-First Business
How to leverage the API economy for competitive advantage—building integration-first architecture, monetizing APIs, selecting iPaaS platforms, and creating ecosystem value.