Testing NestJS with Vitest: Unit and Integration Patterns

Complete guide to testing NestJS applications with Vitest. Covers unit tests, integration tests, mock patterns, database testing with Drizzle, and CI pipeline configuration.

E
ECOSIRE Research and Development Team
|19 مارچ، 202610 منٹ پڑھیں2.2k الفاظ|

Vitest کے ساتھ NestJS کی جانچ کرنا: یونٹ اور انٹیگریشن پیٹرنز

بغیر ٹیسٹ کے NestJS درخواست ایک ذمہ داری ہے۔ آپ اعتماد کے ساتھ ریفیکٹر نہیں کر سکتے، آپ موجودہ رویے کو توڑے بغیر فیچرز شامل نہیں کر سکتے، اور آپ ریگریشنز کو پروڈکشن تک پہنچنے سے پہلے پکڑ نہیں سکتے۔ لیکن خراب ٹیسٹ لکھنا بغیر ٹیسٹوں سے تقریباً بدتر ہے — سست ٹیسٹ جو کوئی نہیں چلاتا، عمل درآمد کی تفصیلات کے ساتھ مل کر ٹوٹنے والے ٹیسٹ، اور ایسے مضحکہ خیز ٹیسٹ جو حقیقی رویے کی عکاسی نہیں کرتے ہیں، سبھی ڈویلپر کے اعتماد کو ختم کرتے ہیں۔

یہ گائیڈ اس بات کا احاطہ کرتا ہے کہ NestJS کے لیے Vitest کا استعمال کرتے ہوئے تیز، قابل بھروسہ ٹیسٹ کیسے لکھیں — جدید Jest متبادل جو 1,300+ ٹیسٹوں کے لیے 30 سیکنڈ سے کم میں چلتا ہے۔ ہم مکمل اسپیکٹرم کا احاطہ کرتے ہیں: مضحکہ خیز انحصار کے ساتھ خالص یونٹ ٹیسٹ، حقیقی ڈیٹا بیس ٹرانزیکشن کے ساتھ انضمام کے ٹیسٹ، سیکیورٹی ٹیسٹ، اور CI کنفیگریشن جو یہ سب ہر کمٹ پر چلتا ہے۔

اہم ٹیک ویز

  • Vitest Jest سے مطابقت رکھتا ہے لیکن 2-5x تیز — وہی API، کوئی منتقلی رگڑ نہیں
  • vi.mock() کے ساتھ عارضی ڈیڈ زون کے مسائل سے بچنے کے لیے فرضی فیکٹریوں کے لیے vi.hoisted() استعمال کریں
  • ٹیسٹ رویے، عمل درآمد نہیں - آؤٹ پٹ اور ضمنی اثرات پر زور دیں، اندرونی طریقہ کالوں پر نہیں۔
  • بوندا باندی SELECT فرضی زنجیریں .limit() کے ساتھ ختم ہونی چاہئیں، INSERT/UPDATE کے ساتھ .returning()
  • انٹیگریشن ٹیسٹوں میں حقیقی ڈیٹا بیس ٹرانزیکشنز کا استعمال کرنا چاہیے جو ہر ٹیسٹ کے بعد واپس لوٹ جاتے ہیں۔
  • مناسب DI ٹیسٹنگ کے لیے NestJS سے createTestingModule استعمال کریں، نہ کہ براہ راست کلاس انسٹی ٹیشن
  • سیکیورٹی ٹیسٹ (انجیکشن، تصدیق بائی پاس، شرح کی حد) ایک علیحدہ __security__ ڈائریکٹری میں ہیں
  • سروس لیئر کے لیے 80%+ لائن کوریج کا ہدف؛ تصنیف اور بلنگ ماڈیولز کے لیے 100%

Vitest کنفیگریشن

// 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();
    });
  });
});

بوندا باندی چین کے موکس کے لیے پھر قابل پراکسی

بوندا باندی کے سوالات روانی کی زنجیریں ہیں۔ انضمام کے ٹیسٹ کے منظرناموں کے لیے جہاں آپ کو مکمل چین کا مذاق اڑانے کی ضرورت ہے:

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

اکثر پوچھے گئے سوالات

NestJS کے لیے Jest پر کیوں ووٹ دیں؟

Vitest Babel کے بجائے Vite کے تیز بنڈلر (esbuild/Rollup) کا استعمال کرتا ہے، جس سے 2-5x تیزی سے ٹیسٹ کی تکمیل ہوتی ہے۔ اس میں Jest-compatible API ہے — زیادہ تر Jest ٹیسٹ بغیر کسی تبدیلی کے منتقل ہو جاتے ہیں۔ Vitest میں مقامی ESM سپورٹ (جدید پیکجز کے لیے اہم)، ts-jest کے بغیر بلٹ ان TypeScript، اور بہتر واچ موڈ ہے۔ NestJS کے لیے، اہم فائدہ واچ موڈ میں ڈرامائی طور پر تیز فیڈ بیک لوپس ہے۔

فرضی فیکٹریوں کے لیے vi.hoisted() کیوں استعمال کریں؟

vi.mock() کالز کو فائل کے اوپری حصے پر وائٹسٹ ٹرانسفارمر کے ذریعے لہرایا جاتا ہے۔ اگر آپ کی فرضی فیکٹری فائل میں بعد میں متعین کردہ متغیر کا حوالہ دیتی ہے (جیسے const mockDb = { ... })، یہ متغیر کے شروع ہونے سے پہلے چلتا ہے — ایک عارضی ڈیڈ زون کی خرابی۔ vi.hoisted() قدریں تخلیق کرتا ہے جو لہرایا بھی جاتا ہے، لہذا جب فرضی فیکٹری چلتی ہے تو وہ دستیاب ہوتی ہیں۔

میں NestJS گارڈز اور انٹرسیپٹرز کی جانچ کیسے کروں؟

تنہائی میں گارڈ کو نظرانداز کرنے یا جانچنے کے لیے createTestingModule کے ساتھ overrideGuard() کا استعمال کرتے ہوئے انٹیگریشن ٹیسٹ بنائیں۔ یہ جانچنے کے لیے کہ گارڈ دراصل غیر مجاز درخواستوں کو مسترد کرتا ہے، غلط یا گمشدہ ٹوکنز کے ساتھ مکمل ایپلیکیشن (app.getHttpServer()) کے خلاف Supertest استعمال کریں، اور درست 401/403 اسٹیٹس کوڈ پر زور دیں۔

کیا بوندا باندی کی فرضی زنجیریں .limit() یا .returning() کے ساتھ ختم ہونی چاہئیں؟

SELECT سوالات کے لیے، سلسلہ عام طور پر findMany()، findFirst()، یا .limit() کال کے ساتھ ختم ہوتا ہے۔ values().returning() کے ساتھ INSERT/UPDATE/DELETE کے لیے، سلسلہ .returning() کے ساتھ ختم ہوتا ہے۔ سلسلہ کے سب سے گہرے طریقہ کا مذاق اڑائیں — جو بھی سروس کوڈ awaits آخری ہو۔ پھر قابل پراکسی پیٹرن من مانی گہرائی کی زنجیروں کو ہینڈل کرتا ہے۔

میں طے شدہ NestJS ٹاسکس (@Cron decorators) کی جانچ کیسے کروں؟

شیڈولر کو اپنے ٹیسٹ ماڈیول میں داخل کریں اور کام کے طریقہ کار کو براہ راست کال کریں — کرون ٹائمر کا انتظار نہ کریں۔ اگر آپ کو ٹائمنگ رویے کو جانچنے کی ضرورت ہو تو jest.useFakeTimers() (vi.useFakeTimers() کے ساتھ Vitest میں وہی API) استعمال کریں۔ یہ جانچنے کے لیے کہ کوئی کام طے شدہ تھا، تصدیق کریں کہ @nestjs/schedule SchedulerRegistry میں متوقع جاب موجود ہے۔

NestJS کے لیے تجویز کردہ ٹیسٹ ڈائرکٹری ڈھانچہ کیا ہے؟

ماخذ فائلوں کے ساتھ اکائی کے ٹیسٹ کو تلاش کریں (contacts.service.spec.ts کے آگے contacts.service.ts)۔ انضمام کے ٹیسٹ src/modules/__integration__/ میں اور سیکورٹی ٹیسٹ src/modules/__security__/ میں رکھیں۔ یہ علیحدگی آپ کو ترقی (vitest run --exclude **/__integration__/**) کے دوران صرف یونٹ ٹیسٹ اور CI میں تمام ٹیسٹ چلانے دیتی ہے۔


اگلے اقدامات

ایک جامع ٹیسٹ سویٹ عیش و آرام کی چیز نہیں ہے — یہ وہ بنیادی ڈھانچہ ہے جو آپ کو اعتماد کے ساتھ تیزی سے جہاز بھیجنے کی اجازت دیتا ہے۔ Vitest کے 30 سیکنڈ سے کم میں 1,300 ٹیسٹ چلانے کے ساتھ، آگے بڑھانے سے پہلے ٹیسٹ رن کو چھوڑنے کا کوئی عذر نہیں ہے۔

ECOSIRE پہلے دن سے مکمل ٹیسٹ کوریج کے ساتھ NestJS بیک اینڈز بناتا ہے — 76 ٹیسٹ فائلیں، 1,301 یونٹ اور انٹیگریشن ٹیسٹ، اور انجیکشن، تصدیق بائی پاس، اور شرح کو محدود کرنے کے لیے سیکیورٹی ٹیسٹ کوریج۔ ہماری بیک اینڈ انجینئرنگ سروسز کو دریافت کریں یہ جاننے کے لیے کہ ہم کس طرح آزمائشی، پروڈکشن کے لیے تیار APIs فراہم کرتے ہیں۔

E

تحریر

ECOSIRE Research and Development Team

ECOSIRE میں انٹرپرائز گریڈ ڈیجیٹل مصنوعات بنانا۔ Odoo انٹیگریشنز، ای کامرس آٹومیشن، اور AI سے چلنے والے کاروباری حل پر بصیرت شیئر کرنا۔

Chat on WhatsApp