Développement Web

اختبار Angular مع Vitest و Playwright

11 دقائق للقراءة

وصول Vitest كـ runner افتراضيّ في Angular 21 يطوي صفحة عصر. Karma، الرفيق الوفيّ منذ 2013، اُستُبدِل رسميّاً بأداة حديثة، سريعة، ومُندمجة أفضل مع النظام البيئيّ الحاليّ لـ JavaScript. بالموازاة، Playwright يفرض نفسه كمعيار لاختبارات end-to-end منذ توقّف دعم Protractor. هذا الدرس يغطّي الهجرة من Karma إلى Vitest، كتابة اختبارات unit لمكوّنات standalone و signals، ثمّ تنظيم اختبارات E2E مع Playwright للتحقّق من المسارات الحرجة.

المتطلّبات

  • Angular 16 كحدّ أدنى (21 مُوصى به للاستفادة من Vitest افتراضيّاً).
  • Node.js 20 LTS أو أحدث.
  • مشروع Angular موجود (مع أو بدون Karma حالياً).
  • معرفة أساسيّة بـ Jasmine/Vitest ومفاهيم الـ assertion (expect، toBe، إلخ).
  • ساعة لمتابعة الدرس كاملاً، تنفيذ كلّ أمر، ومعاينة النتائج.

الخطوة 1 — الهجرة من Karma إلى Vitest

إذا كنت على Angular 21 وتُنشئ مشروعاً جديداً، Vitest مُثبَّت ونشط مسبقاً: ng test يُطلق الـ runner الجديد مباشرة. لمشروع تاريخيّ على Karma، Angular يوفّر schematic مُخصَّص يحوّل الإعداد، يستبدل Karma بـ Vitest في angular.json، يُعدّل tsconfig.spec.json ويُثبّت التبعيّات. الهجرة محافِظة: لا تلمس اختباراتك القائمة طالما تستعمل Jasmine أو Vitest كـ API لـ assertion.

ng update @angular/cli
ng generate @angular/core:karma-to-vitest

الأمر يُحلّل angular.json، يحدّد أهداف test المبنيّة على Karma، ويقترح التحويل. اقبل، ثمّ شغّل ng test: تلاحظ فوراً الإقلاع الآنيّ (Vitest لا يحتاج متصفّحاً لمعظم الاختبارات)، ومُخرَج console أكثر تكثيفاً. على مشروع بـ 200 اختبار، المكسب النموذجيّ يتراوح من 35 ثانية (Karma + Chrome) إلى 4 ثوان (Vitest + jsdom). للاختبارات النادرة التي تتطلّب متصفّحاً حقيقياً (قياسات layout، وصول Canvas)، Vitest يدعم أيضاً Playwright كبيئة اختبار.

الخطوة 2 — كتابة أوّل اختبار unit

اختبار Vitest يُشبه كثيراً اختبار Jasmine — هذا متعمَّد لتسهيل الهجرة. الدوال describe و it و expect هي ذاتها. بعض الفروق الدقيقة: vi يحلّ محلّ jasmine للـ spies (vi.fn() بدل jasmine.createSpy())، والـ matchers غير المتناظرة تستعمل expect.any() بنفس الطريقة.

import { describe, it, expect } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  it('يزيد القيمة عند النقر', () => {
    const fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();
    fixture.detectChanges();

    const display = fixture.nativeElement.querySelector('span');
    expect(display.textContent).toBe('1');
  });
});

النمط يبقى مألوفاً: ننشئ المكوّن، نُحاكي نقرة، نتحقّق من الـ DOM. الفرق الكبير في أداء التنفيذ. Vitest يُعيد استعمال نفس worker Node بين الاختبارات، ممّا يُلغي overhead إقلاع متصفّح. شغّل الاختبار في وضع watch (ng test --watch) وعدّل المكوّن: إعادة التنفيذ تستغرق حوالي 200 ms، حيث كان Karma يطلب 5 إلى 10 ثوان لإعادة دورته الكاملة.

الخطوة 3 — اختبار مكوّن مع signals

المكوّنات الحديثة في Angular تستعمل signals بقدر كبير. اختبارها يتطلّب فهم خصوصيّة: signal مقروء في قالب لا يُطلق مباشرة إعادة رسم في اختبار، يجب استدعاء fixture.detectChanges() لإجبار التحديث. لاختبار قيمة signal وحده (خارج قالب)، يكفي استدعاؤه كدالّة.

import { signal, Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';

@Component({
  selector: 'app-greeter',
  standalone: true,
  template: `<h1>مرحباً {{ name() }}</h1>`,
})
class GreeterComponent {
  name = signal('مجهول');
}

describe('GreeterComponent', () => {
  it('يعكس قيمة الـ signal في القالب', () => {
    const fixture = TestBed.createComponent(GreeterComponent);
    fixture.componentInstance.name.set('عائشة');
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('مرحباً عائشة');
  });
});

الاختبار يُعدّل الـ signal بـ set()، يُطلق دورة change detection، ثمّ يتحقّق من الرسم. هو نفس النمط مع @Input تقليديّ. لاختبار computed، اقرأه مباشرة بعد تعديل تبعيّاته — لا حاجة لـ detectChanges طالما تبقى خارج الـ DOM. لـ effect، افتح سياق حقن عبر TestBed.runInInjectionContext(() => effect(...)) وأَطلِق التغييرات في هذا السياق.

الخطوة 4 — اختبار خدمة مع تبعيّات مُحقَنة

الخدمات تُشكّل طبقة الأعمال لمعظم تطبيقات Angular. اختبارها بمعزل يتطلّب mock تبعيّاتها — نموذجيّاً HttpClient وخدمات أخرى. Vitest يوفّر vi.fn() لإنشاء دوال مُجَسَّسة، و Angular يوفّر provideHttpClientTesting() لاعتراض طلبات HTTP دون لمس الشبكة.

import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        UserService,
      ],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('يستدعي الـ API ويُرجع المستخدمين', async () => {
    const promise = service.list();
    const req = httpMock.expectOne('/api/users');
    req.flush([{ id: 1, name: 'عائشة' }]);
    const data = await promise;
    expect(data).toHaveLength(1);
    expect(data[0].name).toBe('عائشة');
  });
});

ثلاثة عناصر مفتاحيّة. provideHttpClientTesting() يستبدل التنفيذ الحقيقي بـ mock قابل للتحكّم. expectOne() يتحقّق من أنّ طلباً واحداً ووحيداً أُرسل إلى الـ URL المتوقَّع. flush() يُوفّر الردّ المُحاكى ويحلّ الـ promise. الاختبار لا يُجري أبداً استدعاء شبكة حقيقياً، ممّا يجعله حتميّاً وسريعاً (عادة بضع ميلّي ثوان). لاختبار الخطأ، استبدل req.flush(...) بـ req.flush('', { status: 500, statusText: 'Server Error' }) وتحقّق من إدارة الخطأ في الخدمة.

الخطوة 5 — اختبار NgRx Signal Store

Signal Store من NgRx يُختبَر بدون subscription RxJS ولا TestBed ثقيل. الفكرة هي إنشاء الـ store في سياق حقن، إطلاق methodsه، وقراءة signalsه كما في مكوّن. هذه إحدى أكبر منافع DX لـ Signal Store.

import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { patchState } from '@ngrx/signals';
import { ProductStore } from './product.store';

describe('ProductStore', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ProductStore] });
  });

  it('يُرشّح المنتجات حسب الـ query', () => {
    const store = TestBed.inject(ProductStore);
    patchState(store, { products: [
      { id: 1, name: 'Clavier' },
      { id: 2, name: 'Souris' },
    ]});
    store.setQuery('Clavier');
    expect(store.filtered()).toHaveLength(1);
    expect(store.filtered()[0].name).toBe('Clavier');
  });
});

الاختبار يُغطّي منطق الأعمال دون رسم DOM. سريع (بضع ميلّي ثوان لكلّ اختبار)، معزول، وسهل الفهم. لـ methods غير المتزامنة في الـ store، أَعلِن it بـ async واستعمل await عادياً — لا marble testing تحتاج تعلّمه. لـ mock تبعيّات الـ store (نموذجياً HttpClient)، اتبع نفس النمط في الخطوة السابقة مع provideHttpClientTesting().

الخطوة 6 — تثبيت وإعداد Playwright

لاختبارات end-to-end، Playwright فرض نفسه كحلّ مرجعيّ. يُحرّك متصفّحات حقيقيّة (Chromium و Firefox و WebKit)، يدعم الاختبار بالتوازي، ويُقدّم نمط headless سريعاً جدّاً. التثبيت يمرّ عبر CLI خاصّ به، باستقلال عن Angular.

npm init playwright@latest

# الردود المقترحة:
# - مجلّد الاختبار: e2e
# - GitHub Actions: نعم (إذا CI مُعَدّة)
# - تثبيت متصفّحات Playwright: نعم
# - TypeScript: نعم

التثبيت يُنشئ مجلّد e2e/ بملفّ مثال، playwright.config.ts في الجذر، ويُنزّل ثنائيّات المتصفّحات الثلاثة (حوالي 300 MB إجمالاً). تحقّق من التثبيت بـ npx playwright test --list الذي يجب أن يُظهر اختبار المثال على الأقلّ. لكي يُطلق Playwright خادم Angular تلقائياً قبل الاختبارات، أضف قسم webServer في playwright.config.ts.

الخطوة 7 — إعداد الإقلاع التلقائيّ للخادم

بدون إعداد، Playwright يفترض أنّ الخادم مُشغَّل سلفاً حين يُطلق الاختبارات. نادراً ما يكون هذا في CI. التوجيه webServer يطلب من Playwright إقلاع أمر، انتظار استجابة URL، ثمّ إطلاق الاختبارات — وإيقاف كلّ شيء في النهاية.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    timeout: 120_000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
  },
});

الخيار reuseExistingServer: !process.env.CI ثمين في التطوير: إذا كان لديك ng serve يدور بالفعل، Playwright يُعيد استعماله بدل إقلاع ثانٍ. في CI، حيث لا خادم مُشغَّل، Playwright يُقلِعه بنفسه. الـ trace: 'on-first-retry' يلتقط trace كاملاً (DOM، network، console) عند فشل، قابل للوصول عبر npx playwright show-trace trace.zip للتنقيح post-mortem.

الخطوة 8 — كتابة مسار مستخدم end-to-end

اختبار Playwright يصف تفاعل مستخدم من البداية إلى النهاية: تنقّل، ملء نماذج، انتظار ردّ، تحقّق من النتيجة المعروضة. الـ API page يكشف كلّ الـ methods المفيدة (goto، click، fill، locator) مع نظام retries آليّ يجعل الاختبارات موثوقة حتى حين تظهر العناصر بشكل غير متزامن.

import { test, expect } from '@playwright/test';

test('مستخدم يستطيع البحث وحذف منتج', async ({ page }) => {
  await page.goto('/');

  await expect(page.locator('h1')).toHaveText('فهرس المنتجات');

  await page.fill('input[placeholder="بحث"]', 'Clavier');
  await expect(page.locator('article')).toHaveCount(1);

  await page.click('article button:has-text("حذف")');
  await expect(page.locator('article')).toHaveCount(0);
  await expect(page.locator('p')).toHaveText('لا منتج موجود.');
});

الاختبار يُحاكي مساراً واقعيّاً. expect(locator).toHaveText(...) ينتظر تلقائياً أن يجد الـ selector عنصراً بالنصّ المتوقَّع، حتى 5 ثوان افتراضياً. لا حاجة لـ waitForSelector يدوي ولا setTimeout هشّ. لتشغيل هذا الاختبار، شغّل npx playwright test ولاحظ: Playwright يُقلع ng serve، ينتظر استجابته، ينفّذ الاختبار على Chromium، ويوقف كلّ شيء. على اختبار بسيط مثل هذا، احسب 3 إلى 5 ثوان إجمالاً.

الخطوة 9 — الاختبار في نمط موبايل وسطح مكتب

Playwright يسمح باختبار تطبيقك على عدّة إعدادات بالتوازي: Chromium desktop، Firefox، WebKit Safari، وحتى محاكيات موبايل دقيقة (iPhone 14، Pixel 7). فرصة لاصطياد bugs خاصّة بـ viewport أو user agent دون تثبيت كلّ بيئة يدوياً.

// playwright.config.ts (امتداد)
import { devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
  ],
});

شغّل npx playwright test: كلّ المشاريع تُنفَّذ بالتوازي، والتقرير يُشير لكلّ اختبار على أيّ إعداد مرّ أو فشل. لتنفيذ مشروع واحد فقط أثناء التطوير، أَضِف --project=chromium. لموقع عامّ، راقب خصوصاً نسخ الموبايل — هناك تنكشف أغلبيّة bugs UX. أمر مفيد كملحق: npx playwright codegen http://localhost:4200 يفتح متصفّحاً في نمط تسجيل، وكلّ نقرة تُنتج كود Playwright المقابل، ممّا يُسرّع كتابة الاختبارات الأولى.

الخطوة 10 — دمج الاختبارات في CI

لكي تحفظ سلسلة اختبارات قيمتها، يجب أن تُنفَّذ عند كلّ pull request. GitHub Actions الأداة الأكثر انتشاراً لـ Angular؛ إليك إعداداً أدنى ينفّذ Vitest ثمّ Playwright. الـ cache لـ npm والـ cache لثنائيّات Playwright يقسمان وقت التنفيذ على 3 في الـ runs المتتالية.

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx ng test --watch=false
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

هذا الإعداد يقع في حوالي عشرين سطراً. ينفّذ اختبارات unit أوّلاً (سريعة، تفشل بسرعة إذا أُدخل bug منطقيّ)، ثمّ E2E (أبطأ، تتحقّق من التكامل). عند الفشل، تقرير Playwright مع traces ولقطات يُرفَع كـ artifact، ممّا يسمح بالتنقيح من واجهة GitHub دون إعادة تشغيل الاختبار محليّاً. للتأكيد، اطلب الـ branch وتحقّق في تبويب Actions من ظهور الـ job test وعبوره.

أخطاء متكرّرة

الخطأ السبب الحلّ
Cannot find module 'zone.js/testing' مشروع zoneless لكن setup test قديم حدّث test.ts حسب توثيق Angular 21
اختبار Vitest يمرّ محليّاً لكنّه يفشل في CI فرق توقيت أو locale حدّد TZ=UTC و LANG=fr_FR.UTF-8 في CI
Playwright timeout على assertion selector عامّ جدّاً استهدف بـ role (page.getByRole('button', { name: 'حذف' }))
HttpClient يستدعي API الحقيقيّ في اختبار provideHttpClientTesting() منسيّ أضفه إلى providers في TestBed
اختبار E2E بطيء: إقلاع Angular طويل build dev مُجمَّع عند كلّ run فعّل reuseExistingServer واحتفظ بـ ng serve مفتوحاً
NG0203 في اختبار signals effect مُنشَأ خارج injection context استعمل TestBed.runInInjectionContext()

التكيّف مع البيئات المُقيَّدة

Vitest يُخفّض جذرياً الحاجة لموارد الجهاز لتشغيل سلسلة الاختبارات. حيث كان Karma يتطلّب Chrome headless بـ 200 MB من RAM لكلّ worker، Vitest يعمل في workers Node بـ 30-50 MB. ميزة صافية للمطوّرين على laptops متواضعة أو لـ pipelines CI بميزانيّة محدودة — runner GitHub Actions قياسيّ ينفّذ السلسلة الكاملة في أقلّ من خمس دقائق لمشروع متوسّط، حيث كانت نفس السلسلة على Karma قد تتجاوز خمس عشرة دقيقة. لـ Playwright، الخيار --workers=2 يحدّ من استعمال RAM إذا كنت تختبر على جهاز متواضع، مقابل وقت إجماليّ أطول قليلاً.

أسئلة شائعة

هل Vitest متوافق مع اختبارات Jasmine القائمة لديّ؟
نعم في أغلب الحالات. الـ APIs describe و it و expect و beforeEach متطابقة. الـ spies (jasmine.createSpy) يجب استبدالها بـ vi.fn()، ما يفعله schematic الهجرة تلقائياً. بعض الـ matchers الخاصّة جدّاً بـ Jasmine قد تطلب مهايئاً، لكنّ ذلك نادر.

هل يجب الإبقاء على Karma لبعض الاختبارات؟
لا، ليس في مشروع حديث. Karma يبقى مدعوماً لحفظ القائم التاريخيّ، لكن لا تطوير جديد له سبب لاستعماله. كلّ استعمالات Karma مُغطّاة بـ Vitest، أحياناً أفضل (نمط HMR، تكرار التنفيذ).

Cypress أم Playwright لـ E2E؟
الاثنان صالحان. Playwright له ميزة دعم تعدّد المتصفّحات أصلياً، نمط متوازٍ أنضج، واستراتيجيّة retry آليّة. Cypress له studio مرئيّ أكثر اكتمالاً للتطوير التفاعليّ. لمشروع Angular حديث، Playwright هو الخيار الأفضل عموماً بسبب اندماجه المباشر مع runner Node وغياب حدود cross-origin.

كيف نُغطّي اختباراً يعتمد على فُلك زمنيّ؟
ثبّت الفُلك في إعداد الاختبار: process.env.TZ = 'Europe/Paris' في بداية ملفّ setup. للمكوّنات التي تُنسّق التواريخ، استعمل mock لـ Date.now() عبر vi.useFakeTimers() لجعل الاختبارات حتميّة.

كم اختباراً E2E معقول؟
القاعدة التجريبيّة: مسار مستخدم حرج لكلّ ميزة كبرى (تسجيل، دخول، شراء، بحث). اختبارات E2E مُكلِفة في وقت التنفيذ؛ على سلسلة من 50 مساراً، نلاحظ نموذجياً 10 إلى 15 دقيقة من التنفيذ في CI. أبعد من ذلك، أَعِد الأولويّة لاختبارات unit وtests التكامل الأسرع.

للاستزادة

المراجع

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité