استراتيجيات الاختبار الحديثة: من الوحدة إلى الطرف إلى الطرف

كل فريق برمجي يكتب اختبارات. لكن ليس كل فريق يكتب اختبارات تمنع العلل، وتنجو من إعادة الهيكلة، وتمنح الثقة للنشر عصر يوم جمعة. الفرق بين الاختبارات التي تنفع وتلك التي تضرّ لا يعود إلى إطار العمل ولا إلى نسبة التغطية، بل إلى الاستراتيجية: أن تعرف ماذا تختبر، وعلى أي مستوى، وبأي مقايضة.

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

يغطي هذا الدليل طيف الاستراتيجيات الحديثة كاملًا — من اختبارات الوحدة السريعة المعزولة إلى اختبارات الطرف إلى الطرف البطيئة لكن الموثوقة — ويشرح متى تضيف كل استراتيجية قيمة ومتى تخلق عبئًا بلا طائل. والهدف ليس إقناعك بكتابة اختبارات أكثر، بل أن تكتب اختبارات تبرّر وجودها: تلتقط العلل التي تهمّ، وتعمل بسرعة تكفي لتشغيلها كثيرًا، وتنجو من إعادة تنظيم شيفرتك التي لا مفرّ منها.

أفضل ممارسات اختبار الوحدة وما يستحق الاختبار

اختبارات الوحدة أساس معظم الاستراتيجيات لأنها سريعة وحتمية ومعزولة. والاختبار المكتوب جيدًا ينفَّذ في أجزاء من الألف من الثانية، ويغطي سلوكًا منطقيًا واحدًا، ولا يعتمد على أنظمة خارجية كقواعد البيانات أو أنظمة الملفات أو واجهات الشبكة. وحين يفشل، تعرف بالضبط أي جزء من المنطق تعطّل ولماذا.

أشيع خطأ هو اختبار تفاصيل التنفيذ بدل السلوك. فالتحقق من أن دالة تستدعي أسلوبًا داخليًا بعينه أو تضبط حقلًا خاصًا يجعل الاختبار هشًّا: إعادة هيكلة تحافظ على السلوك الخارجي ستكسره مع ذلك. عندها يتحول الاختبار من شبكة أمان إلى عبء. اكتب اختبارات تتحقق من المخرَج المرصود أو الآثار الجانبية للدالة، لا من كيفية بلوغها تلك النتيجة.

المرشّحون الجيدون هم الدوالّ المساعدة الصِّرفة، ومنطق العمل الخالي من الإدخال والإخراج، والتحقّق والتحليل النصي، وتحويلات البيانات، والحسابات الخوارزمية، وآلات الحالة. أما المرشّحون الضعفاء فهم الدوالّ التي تجري نداءات شبكية، والمكوّنات التي ترسم الواجهة، وكل شيفرة مرتبطة ارتباطًا وثيقًا بدواخل إطار العمل. هذه تُختبر على نحو أفضل في مستوى التكامل أو الطرف إلى الطرف.

import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for orders below the threshold", () => {
    const order: Order = {
      items: [{ price: 30, quantity: 1 }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });

  it("applies 10 percent for orders over $100", () => {
    const order: Order = {
      items: [
        { price: 80, quantity: 1 },
        { price: 40, quantity: 1 },
      ],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(12);
  });

  it("applies loyalty bonus for customers over 2 years", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 1 }],
      customerSince: new Date("2022-06-01"),
    };

    expect(calculateDiscount(order)).toBe(15);
  });

  it("does not apply discount when items are on sale", () => {
    const order: Order = {
      items: [{ price: 200, quantity: 1, onSale: true }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });
});

يختبر المثال أعلاه أربعة سلوكيات متمايزة لدالة واحدة، بمدخلات ومخرجات متوقّعة مختلفة. وكل اختبار مستقل وحتمي وينفَّذ في أقل من عشرة أجزاء من الألف من الثانية. وإذا تغيّر منطق التسعير، أخبرتك الاختبارات بالضبط أي السيناريوهات تأثّرت. هذه هي قيمة اختبارات الوحدة: تغذية راجعة سريعة على منطق صِرف.

  • اختبر السلوكيات لا التنفيذ. تحقّق من المخرجات والآثار الجانبية، لا من النداءات الداخلية أو الحالة الخاصة.
  • تأكيد منطقي واحد لكل حالة. ينبغي أن يفشل الاختبار لسبب واحد بالضبط، حتى يتضح فورًا ما الذي تعطّل.
  • استخدم أسماء واصفة تُقرأ كجمل. «يعيد صفرًا للطلبات دون العتبة» أفضل من «test_discount_low_amount».
  • تجنّب الحالة المشتركة القابلة للتغيّر بين الاختبارات. كل اختبار يهيّئ بياناته وينظّف بعده.
  • أبقِ قيم الاختبار بسيطة ودالّة. استخدم بيانات واقعية تعكس كائنات المجال الفعلية.

اختبارات التكامل مع اعتماديات حقيقية

تقع اختبارات التكامل بين اختبارات الوحدة واختبارات الطرف إلى الطرف في السرعة والنطاق. وهي تتحقّق من تعاون وحدات متعددة: مستودع يخاطب قاعدة بيانات حقيقية، أو خدمة تكلّم نقطة واجهة فعلية، أو مكوّن يُرسم مع مخزن حالة حقيقي. والفارق الجوهري عن اختبارات الوحدة أنها تستعمل اعتماديات حقيقية أو محزومة في حاويات بدل البدائل المحاكاة.

أنفع اختبارات التكامل تتحقّق من أن شيفرتك تتعامل بشكل صحيح مع الأنظمة الخارجية. فاختبار وحدة يحاكي طبقة قاعدة البيانات يخبرك إن كان منطق الاستعلام صحيحًا بمعزل عن غيره، لكنه لا يخبرك إن كان استعلام SQL الفعلي يعمل على المخطط الحقيقي. واختبارات التكامل التي تستخدم Testcontainers أو قاعدة اختبار تلتقط تباينات المخطط، وانتهاكات القيود، وعلل حدود المعاملات، ومشكلات التسلسل التي تفوت اختبارات الوحدة تمامًا.

وهي تتألّق في طبقات المستودعات، ومعالجات مسارات الواجهات، ومستهلكي طوابير الرسائل، وترحيلات قواعد البيانات، وكل شيفرة تُسلسِل البيانات أو تفكّ تسلسلها عبر حدّ. والثمن هو السرعة — فاختبار يشغّل حاوية Postgres يستغرق ثوانٍ بدل أجزاء من الألف من الثانية — لكن العلل التي يلتقطها أغلى بما يوازي ذلك إن اكتُشفت في الإنتاج.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";

describe("OrderRepository", () => {
  let db: Awaited<ReturnType<typeof createDatabase>>;
  let repo: OrderRepository;

  beforeAll(async () => {
    db = await createDatabase();
    repo = new OrderRepository(db);
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it("persists and retrieves an order", async () => {
    const order = {
      id: "ord_001",
      customerId: "cus_001",
      total: 1250,
      status: "pending" as const,
      createdAt: new Date("2025-06-01"),
    };

    await repo.save(order);
    const retrieved = await repo.findById("ord_001");

    expect(retrieved).toEqual(order);
  });

  it("returns null for a non-existent order", async () => {
    const result = await repo.findById("ord_nonexistent");
    expect(result).toBeNull();
  });

  it("updates order status", async () => {
    await repo.save({
      id: "ord_002",
      customerId: "cus_001",
      total: 500,
      status: "pending",
      createdAt: new Date(),
    });

    await repo.updateStatus("ord_002", "shipped");
    const updated = await repo.findById("ord_002");

    expect(updated?.status).toBe("shipped");
  });
});

القاعدة الحاسمة هي عزل حالة قاعدة البيانات بين التشغيلات. فينبغي لكل طقم اختبارات أن ينشئ مخططه أو قاعدته، ويطبّق الترحيلات، وينفّذ الاختبارات، ثم يفكّك كل شيء. والعمل على قاعدة مشتركة ينتج اختبارات متذبذبة، تتسرّب فيها بيانات اختبار إلى تأكيدات آخر. استعمل Testcontainers أو Docker Compose أو دعم قواعد الاختبار في إطار عملك، حتى يبدأ كل تشغيل من حالة معلومة.

اختبار تكامل واحد مع قاعدة بيانات حقيقية يساوي مئة اختبار وحدة تحاكيها. المحاكاة تخبرك بما تتوقّعه من القاعدة. والقاعدة الحقيقية تخبرك بما تفعله فعلًا. وبعد أول ترحيل للمخطط، نادرًا ما يتطابق الاثنان.

الاختبار من الطرف إلى الطرف بـ Playwright

تحاكي اختبارات الطرف إلى الطرف تفاعلات مستخدم حقيقي عبر المكدّس كلّه: المتصفح، والواجهة الأمامية، وواجهة البرمجة، وقاعدة البيانات، والخدمات الخارجية. وهي أبطأ الأنواع وأغلاها، لكنها تلتقط أكثر العلل واقعية: تنقّل مكسور، وتحقّق مفقود في النماذج، واستجابات واجهة تُعرض خطأً، وإخفاقات في مسار التوثيق، ومشكلات عرض بين المتصفحات.

صار Playwright الأداة المهيمنة بفضل دعمه لعدة متصفحات، وتأكيداته ذات الانتظار التلقائي، واعتراض الشبكة، وتجربة المطوّر. وخلافًا للأدوات الأقدم التي كانت تتطلّب انتظارات وإعادات محاولة صريحة، ينتظر Playwright أن يصير العنصر قابلًا للتعامل قبل أن يتفاعل معه، فيزيل أشيع أسباب تذبذب الاختبارات.

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

test("user completes a purchase flow", async ({ page }) => {
  await page.goto("/products");

  await page.getByRole("link", { name: "Wireless Headphones" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();
  await page.getByRole("button", { name: "View Cart" }).click();

  await expect(page.getByText("Wireless Headphones")).toBeVisible();
  await expect(page.getByText("$89.99")).toBeVisible();

  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Card Number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay Now" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
  await expect(page.getByText("ord_001")).toBeVisible();
});

أكبر خطأ في هذه الاختبارات هو الإفراط في عددها. فكل اختبار يضيف دقائق إلى خط التكامل، وطقمٌ من مئتي اختبار قد يبلغ نصف ساعة بسهولة. واقتصاديات الأمر تفرض الانتقاء. ركّز على الرحلات الحاسمة — التسجيل، والدخول، والشراء، والبحث، والدفع — ودع المستويات الأدنى تغطّي الحالات الحدّية وحالات الخطأ.

  • أعطِ الأولوية للمسارات المتصلة بالإيراد: إتمام الشراء، وترقية الاشتراك، ومعالجة المدفوعات.
  • أبقِ هذه الاختبارات مستقلة. كل اختبار ينشئ بياناته وينظّف بعده.
  • استعمل نداءات الواجهة في التهيئة بدل التفاعل عبر الشاشة، لتبلغ حالة البدء أسرع.
  • شغّلها على بيئة تجريبية أو بيئة معاينة، لا على الإنتاج. وتحكّم في ظهور بيانات الاختبار عبر أعلام الخصائص.
  • استثمر في قابلية الرصد: اللقطات والتتبّعات وتسجيلات الفيديو للاختبارات الفاشلة توفّر ساعات من التقصّي.

التطوير المقاد بالاختبار عمليًا

التطوير المقاد بالاختبار ليس عن الاختبار، بل عن التصميم. فدورة الأحمر ثم الأخضر ثم إعادة الهيكلة تُلزمك بالتفكير في واجهة شيفرتك قبل كتابتها. وكتابة الاختبار أولًا تفرض عليك الإجابة: ماذا ينبغي أن تقبل هذه الدالة وماذا ينبغي أن تعيد؟ وهذا القيد يقود إلى واجهات أفضل، وترابط أقل، وشيفرة أكثر تفكيكًا إلى وحدات.

المقاومة تأتي عادةً من أحد موضعين: إما أن أحدهم جرّبه على مشكلة غير مناسبة له (شيفرة واجهة، عمل استكشافي)، وإما أنه طُبِّق بتزمّت فقُضي في مصارعة إطار الاختبار وقتٌ أطول مما قُضي في كتابة شيفرة نافعة. إنه أداة لا عقيدة. ويعمل في أفضل حالاته مع المنطق الخوارزمي، وتحويلات البيانات، وتصميم الواجهات، وكل شيفرة يكون عقد المدخل والمخرج فيها واضحًا قبل البدء.

سير العمل العملي هكذا: اكتب اختبارًا واحدًا يصف السلوك التالي الذي تريده. شغّله وشاهده يفشل — فهذا يؤكّد أنه صالح وقادر على كشف غياب الخاصية. اكتب أقلّ شيفرة تجعله يمرّ، دون هندسة زائدة: أبسط تنفيذ يفي بالاختبار هو الصواب. ثم حسّن البنية دون تغيير السلوك، معتمدًا على الاختبارات المارّة لالتقاط الانحدارات.

التطوير المقاد بالاختبار لا يجعلك تكتب اختبارات أكثر، بل يجعلك تكتب شيفرة أفضل. الاختبار أثر جانبي لعملية التصميم لا هدفها. فإن كتبت الاختبارات بعد التنفيذ فأنت تختبر، وإن كتبتها قبله فأنت تصمّم.

وهو فعّال بوجه خاص في إصلاح العلل. فحين يُبلَّغ عن علّة، اكتب اختبارًا يعيد إنتاجها (أحمر)، ثم أصلح الشيفرة (أخضر)، ثم تأكّد أن الإصلاح لم يكسر سلوكًا قائمًا (كل الاختبارات ما زالت تمرّ). ويصير الاختبار الذي كتبته حماية دائمة من تكرارها. ومع الوقت ينشأ طقم يعكس التاريخ الفعلي لعلل المشروع، وهو أثمن كثيرًا من طقم صُنع لبلوغ رقم تغطية.

الاختبار القائم على الخصائص والتشويش

تتحقّق الاختبارات التقليدية القائمة على أمثلة من مدخلات بعينها مقابل مخرجات متوقّعة. أما الاختبار القائم على الخصائص فيسلك مسلكًا آخر: يعرّف خاصية ينبغي أن تصدق على كل المدخلات، ثم يولّد مئات أو آلاف المدخلات العشوائية للتحقّق منها. وتلتقط هذه التقنية حالات حدّية ما كان لأحد أن يفكّر في كتابتها مثالًا.

فبدل كتابة خمس حالات محدّدة لدالة ترتيب، تعرّف خاصية: لأي قائمة من عناصر قابلة للمقارنة، ينبغي أن تحتوي النتيجة العناصر نفسها بترتيب غير تنازلي. ويولّد إطار الاختبار قوائم عشوائية بأحجام متفاوتة، فيها مكرّرات وقوائم فارغة وقيم طرفية، ويتحقّق من الخاصية في كل واحدة.

import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";

describe("sortByPrice", () => {
  it("returns items in ascending price order for any input", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      for (let j = 1; j < sorted.length; j++) {
        expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
      }
    }
  });

  it("preserves all original elements", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      expect(sorted).toHaveLength(items.length);
      expect(sorted.map((i) => i.name).sort()).toEqual(
        items.map((i) => i.name).sort()
      );
    }
  });
});

و fast-check مكتبة شائعة للاختبار القائم على الخصائص في TypeScript، تتكامل مع Vitest وJest. توفّر مولّدات لأنواع البيانات الشائعة، ومركّبات للبنى المعقّدة، وتقليصًا تلقائيًا: فحين تجد حالة فاشلة، تحاول ردّها إلى أصغر مدخل يظلّ فاشلًا، وهو ما يسهّل التقصّي كثيرًا.

وتزداد قيمة هذا النهج في منطق التسلسل وفكّه، وخوارزميات الترتيب والترشيح، وقواعد التحقّق، وانتقالات آلات الحالة، وكل دالة يتّسع فضاء مدخلاتها. وباقترانه بتقنيات التشويش التي تحقن بيانات مشوّهة أو غير متوقّعة، يكشف ثغرات أمنية وانهيارات ما كانت لتُكتشف إلا في الإنتاج.

اختبارات الانحدار البصري

تتحقّق الاختبارات الوظيفية من أن التطبيق يتصرّف على نحو صحيح. أما اختبارات الانحدار البصري فتتحقّق من أنه يبدو على نحو صحيح. فتغييرات CSS، وتحديثات الاعتماديات، والمكوّنات المعاد هيكلتها قد تُدخل إزاحات دقيقة في التخطيط، أو تباينات في الألوان، أو تبدّل خطوط، أو مشكلات عند نقاط التوقّف — ولا يلتقط شيئًا من ذلك أي اختبار وظيفي. ويلتقط الانحدار البصري صورًا للمكوّنات أو الصفحات، ويقارنها بصور مرجعية، ويشير إلى أي اختلاف لمراجعة بشرية.

وتنفّذ أدوات مثل Chromatic وPercy وقدرة اللقطات المدمجة في Playwright ذلك على مستويات مختلفة. فـ Chromatic يتكامل مع Storybook ويقارن بكسلًا ببكسل بمنطق فروق يتجاهل التنعيم وفروق العرض دون البكسل. ويقدّم Percy قدرات مشابهة بدعم أوسع لأطر العمل. أما تأكيد toHaveScreenshot في Playwright فيتيح كتابة تحقّقات بصرية مباشرة داخل اختبارات الطرف إلى الطرف، بلا أدوات إضافية.

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

test("product page renders correctly", async ({ page }) => {
  await page.goto("/products/wireless-headphones");

  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    fullPage: true,
  });
});

test("checkout form is visually stable across states", async ({ page }) => {
  await page.goto("/checkout");

  await expect(page).toHaveScreenshot("checkout-empty.png");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page).toHaveScreenshot("checkout-validation-error.png");
});

والتحدي الأكبر إدارة الصور المرجعية. فكل تغيير بصري مقصود يستلزم تحديثها، وقد تصير عملية الاعتماد عنق زجاجة في فريق يمسّ الواجهة كثيرًا. والحل أن يُعامَل تحديث المراجع جزءًا من سير العمل: اعتماد الفروق البصرية في دورة المراجعة نفسها التي تمرّ بها الشيفرة، عبر منصّة متكاملة مع مسار طلبات الدمج.

  • ابدأ بالصفحات الحاسمة: الصفحة الرئيسة، وإتمام الشراء، والدخول، وتفاصيل المنتج، وكل صفحة ذات تخطيط معقّد.
  • اضبط عتبات مناسبة لتجاهل آثار التنعيم وفروق رسم الخطوط بين أنظمة التشغيل.
  • استعمل لقطات على مستوى المكوّن (عبر Storybook) لتغطية موجّهة، بدل لقطات الصفحة الكاملة الحسّاسة لكل تغيير.
  • أدمج المراجعة البصرية في مسار طلبات الدمج. واشترط موافقة بشرية على الفروق في الصفحات المعدّلة.
  • شغّل الاختبارات البصرية في بيئة موحّدة (Docker) لإزالة فروق العرض بين أجهزة المطوّرين.

اختبار مكوّنات الخادم في React وتطبيقات Next.js

غيّرت مكوّنات الخادم في React مشهد الاختبار. فهي تعمل على الخادم حصرًا ولا ترسل أي JavaScript إلى العميل. وتستطيع الوصول مباشرة إلى قواعد البيانات وأنظمة الملفات وخدمات الخلفية دون كشف أي من ذلك للمتصفح. وهذا يجعل اختبارها ممكنًا بنهج مختلف عن مكوّنات العميل.

هي في جوهرها دوالّ غير متزامنة تعيد JSX. ويمكنك اختبارها كأي دالة غير متزامنة: نادِها بالخصائص، وانتظر النتيجة، وتحقّق من المخرَج. لا بيئة متصفح، ولا محاكاة لـ fetch أو لنداءات قاعدة البيانات إن كنت تعمل على قاعدة اختبار حقيقية. وهذه البساطة من فضائل مكوّنات الخادم التي لا تُقدَّر حقّ قدرها: تهيئة الاختبار أخفّ بكثير من مكوّنات العميل التي تحتاج بيئة DOM.

import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";

describe("ProductList (Server Component)", () => {
  it("renders products from the database", async () => {
    // No mocking needed - the component calls the database directly
    const { props } = await ProductList({ category: "electronics" });

    expect(props.products.length).toBeGreaterThan(0);
    expect(props.products.every((p) => p.category === "electronics")).toBe(
      true
    );
  });

  it("shows empty state when no products match", async () => {
    const { props } = await ProductList({ category: "nonexistent" });

    expect(props.products).toHaveLength(0);
  });
});

أما مكوّنات العميل التي تستعمل useState أو useEffect أو واجهات المتصفح أو معالجات الأحداث فتحتاج بيئة DOM للاختبار. ويوفّر Vitest بيئة happy-dom أو jsdom لمحاكاة متصفح. وتوفّر Testing Library أدوات للاستعلام عن المخرَج المرسوم ومحاكاة تفاعلات المستخدم. والفارق عن مكوّنات الخادم أنك تحتاج إلى رسم المكوّن، وانتظار تنفيذ الآثار، ثم التحقّق من حالة DOM الناتجة.

وتضيف تطبيقات Next.js التوجيه وجلب البيانات وإجراءات الخادم، وهي تستدعي اعتبارات إضافية. ففي صفحات تستعمل getServerSideProps أو generateStaticParams، اختبر دوالّ الجلب مستقلّة عن المكوّن. واختبر إجراءات الخادم كدوالّ غير متزامنة عادية، فليست سوى ذلك. وفي تخطيطات App Router وصفحاته، اختبر طبقة البيانات منفصلة عن طبقة العرض حتى لا ترتبط اختباراتك بدواخل الرسم في إطار العمل.

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";

describe("CheckoutForm", () => {
  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();

    render(<CheckoutForm />);

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Card number is required")).toBeVisible();
  });

  it("submits the form with valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<CheckoutForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
    await user.type(screen.getByLabelText("Expiry"), "12/28");
    await user.type(screen.getByLabelText("CVC"), "123");

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      cardNumber: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    });
  });
});

وأهمّ تمييز في نموذج React الجديد هو فصل اختبارات مكوّنات الخادم عن اختبارات مكوّنات العميل. فالأولى دوالّ غير متزامنة صِرفة: اختبرها بلا بيئة DOM. والثانية تحتاج محاكاة تفاعل المستخدم: اختبرها بـ Testing Library وuserEvent. وخلط الأمرين يجعل اختباراتك أبطأ وأعقد مما يلزم.

استراتيجيات المحاكاة ومتى تتجنّبها

المحاكاة من أكثر موضوعات الاختبار جدلًا. فالبدائل المحاكاة تحلّ محلّ الاعتماديات الحقيقية ببدائل محكومة، وتتيح اختبار الشيفرة بمعزل عن غيرها دون تهيئة قواعد بيانات، أو استدعاء واجهات خارجية، أو انتظار مؤقّتات. والمقايضة أنك تتحقّق من شيفرتك مقابل نسخة محاكاة من الاعتمادية لا مقابل الحقيقية. وإن لم يعكس البديل السلوك الفعلي بدقّة، مرّ الاختبار بينما تفشل الشيفرة الحقيقية.

والقاعدة العامة أن تحاكي عند حدّ نظامك. فاستبدل الخدمات الخارجية التي لا تتحكّم فيها (واجهات أطراف ثالثة، ومعالجات دفع، وخدمات بريد)، والبنية التحتية المكلفة أو غير الحتمية (قواعد بيانات، وأنظمة ملفات، وساعات). ولا تحاكِ وحداتك الداخلية ولا دوالّك المساعدة ولا كائنات مجالك: هذه تُختبر بتنفيذاتها الحقيقية في اختبارات التكامل.

ويوفّر Vitest آلية vi.mock التي ترفع تصريحات المحاكاة إلى أعلى الملف وتستبدل تنفيذات الوحدات قبل تشغيل الاختبارات. وهذا مفيد لمتغيّرات البيئة وحزم الأطراف الثالثة والكائنات العامة. والخطر أن المحاكاة على مستوى الوحدة غير مرئية وقد تنتج اختبارات تمرّ منفردة وتفشل مجتمعة بسبب التداخل.

import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";

const mockStripe = {
  charges: {
    create: vi.fn(),
  },
};

vi.mock("stripe", () => ({
  default: vi.fn(() => mockStripe),
}));

describe("PaymentService", () => {
  it("creates a charge successfully", async () => {
    mockStripe.charges.create.mockResolvedValue({
      id: "ch_001",
      status: "succeeded",
    });

    const service = new PaymentService();
    const result = await service.charge(5000, "tok_visa");

    expect(result.status).toBe("succeeded");
    expect(mockStripe.charges.create).toHaveBeenCalledWith({
      amount: 5000,
      currency: "usd",
      source: "tok_visa",
    });
  });

  it("throws on failed payment", async () => {
    mockStripe.charges.create.mockRejectedValue(
      new Error("card_declined")
    );

    const service = new PaymentService();

    await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
      "card_declined"
    );
  });
});

وثمّة بديل أوثق من المحاكاة: بدائل اختبار تحقّق الواجهة نفسها التي تحقّقها الاعتمادية الحقيقية، لكن بتنفيذ مبسّط في الذاكرة. فبدل محاكاة وحدة قاعدة البيانات، أنشئ نسخة في الذاكرة من مستودعك تخزّن البيانات في Map. هذا البديل خفيف لكنه أمين، ويمرّ بمسارات الشيفرة نفسها التي تمرّ بها القاعدة الحقيقية، دون كلفة التهيئة.

كل محاكاة تفتح فجوة بين اختباراتك والواقع. وفي تلك الفجوة تختبئ العلل. فقبل أن تمدّ يدك إلى محاكاة، اسأل: أيمكنني اختبار هذا بالاعتمادية الحقيقية في بيئة محكومة؟ إن كان الجواب نعم، فخذ الحقيقية. وإن كان لا — بطيئة جدًا، أو مكلفة جدًا، أو غير حتمية — فحاكِ، لكن بضيق وعند حدّ النظام.

  • حاكِ عند الحدود: واجهات الأطراف الثالثة، وحزم التطوير، وبوابات الدفع، والخدمات الخارجية التي يتعذّر تشغيلها محليًا.
  • استعمل بدائل في الذاكرة لتجريداتك أنت (المستودعات، والذواكر المؤقتة، والطوابير) بدل محاكاتها.
  • لا تحاكِ ما لا تملكه: فمحاكاة دواخل مكتبة تربط اختباراتك بتفاصيل التنفيذ.
  • فضّل حقن الاعتماديات على المحاكاة عند مستوى الوحدة. فتمريرها صراحةً يجعل تهيئة الاختبار شفّافة.
  • احصر نطاق كل محاكاة في الاختبار الذي يحتاجها. وأعد ضبط كل المحاكاة بين اختبار وآخر.

الاختبار في خطوط CI/CD

لا قيمة لطقم اختبارات إلا إذا عمل باتّساق وأعطى تغذية راجعة سريعة. فالاختبارات البطيئة أو المتذبذبة أو التي لا تعمل إلا على أجهزة المطوّرين عبء. ودمجها في خط CI/CD يضمن تشغيلها عند كل طلب دمج، وعند كل ضمّ إلى الفرع الرئيس، وقبل كل نشر في المسارات الحاسمة.

ويترجَم الهرم طبيعيًا إلى مراحل في الخط. فاختبارات الوحدة تعمل أولًا، عند كل دفع، لأنها سريعة وتلتقط أخطاء المنطق الأساسية. ثم تليها اختبارات التكامل، على التوازي حيث أمكن، باعتماديات في حاويات. وتأتي اختبارات الطرف إلى الطرف أخيرًا، على طلبات الدمج وقبل النشر فقط، لأنها بطيئة ومكلفة. وتعمل الاختبارات البصرية إلى جانبها، مقارِنةً اللقطات بمرجع الفرع الرئيس.

وأداء الخط مهمّ. فطقمٌ يستغرق خمسًا وأربعين دقيقة يشجّع على تخطّي الاختبار محليًا ودفع شيفرة مكسورة. ومن الاستراتيجيات المفيدة: تشغيل الاختبارات المتأثّرة بالتغيير الحالي فقط، وتوزيع الأطقم على منفّذين متوازيين، وتخزين node_modules ومتصفحات Playwright مؤقتًا بين التشغيلات، وجدولة اختبارات الطرف إلى الطرف البطيئة بدل تشغيلها عند كل إيداع.

# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx vitest run --project unit

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run --project integration

  e2e:
    needs: [unit, integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

والاختبارات المتذبذبة أكبر تهديد للثقة بالخط. فاختبار يفشل متقطّعًا لأسباب لا صلة لها بتغييرات الشيفرة يقوّض الثقة بالبناء كلّه. فيبدأ الفريق بتجاهل الإخفاقات، ويدمج والخطّ أحمر، وينتهي الطقم ضوضاء. استثمر في كشفها: علّمها، واعزلها، وأعطِ الأولوية لإصلاحها أو إزالتها. فاختبار لا يُوثق به أسوأ من لا اختبار، لأنه يرسّخ عادة تجاهل الإخفاق.

والإبلاغ عن النتائج ممارسة أخرى لا تُقدَّر حقّ قدرها. فمخرَج يقول «فشل 3 اختبارات» دون سياق يضطرّ المطوّر إلى تقليب السجلات بحثًا عن رسالة الخطأ. أما التقرير الجيد فيُظهر التأكيد الفاشل، والقيم المتوقّعة والفعلية، والملف ورقم السطر، وأي سياق مفيد كاللقطات أو أثر الاستدعاءات. وتقارير HTML في Vitest وPlaywright، أو تعليقات GitHub Actions، تجعل النتائج ظاهرة داخل مسار طلب الدمج نفسه.

الجدل بين الكأس والهرم

قاد الهرم التقليدي — اختبارات الوحدة في القاعدة، والتكامل في الوسط، والطرف إلى الطرف في القمة — استراتيجية الاختبار طوال عقدين. وهو ينقل فكرة بسيطة: كثير من الاختبارات السريعة المعزولة، وأقلّ منها اختبارات تكامل أبطأ، وقليل جدًا من اختبارات الطرف إلى الطرف. ويناسب هذا النموذج خدمات الخلفية حيث تكون وحدة النشر دالة أو صنفًا بحدود واضحة بين المدخل والمخرج.

واقتُرح كأس الاختبار نموذجًا بديلًا يعكس التطوير الأمامي الحديث بشكل أفضل. وهو يعيد تشكيل الهرم معينًا تشكّل فيه اختبارات التكامل أعرض طبقة، مع عدد أقل من اختبارات الوحدة ومن اختبارات الطرف إلى الطرف. والحجّة أن القيمة الكبرى في تطبيقات الواجهة تأتي من اختبار كيفية تعاون المكوّنات — وهو بالضبط ما تتحقّق منه اختبارات التكامل — لا من اختبار دوالّ منعزلة أو رحلات مستخدم كاملة.

ويعكس نموذج الكأس حقيقة عملية: أثمن اختبارات تطبيق React هي تلك التي ترسم مكوّنًا، وتتفاعل معه، وتتحقّق من النتيجة. فهي تُعمِل المكوّن وأبناءه وخطّافاته وإدارة حالته ونداءاته للواجهة معًا. وهي أسرع من اختبارات الطرف إلى الطرف وأقرب إلى الواقع من اختبارات الوحدة. وطقم من اختبارات تكامل مكتوبة جيدًا يمنح ثقة أعلى لكل اختبار مما تمنحه اختبارات الوحدة أو الطرف إلى الطرف في سياق الواجهة.

والحقيقة أن كلا النموذجين تبسيط لواقع أعقد. فالاستراتيجية الصحيحة تتوقّف على معماريتك. فخدمة مصغّرة في الخلفية بمنطق عمل ثقيل تستفيد من الهرم: تغطية وحدة عميقة لمنطق المجال، واختبارات تكامل أقلّ لطبقة الواجهة، وحفنة من اختبارات العقود للخدمات الخارجية. وتطبيق واجهة بتفاعلات معقّدة يستفيد من الكأس: تغطية تكامل واسعة للمكوّنات والصفحات، واختبارات وحدة انتقائية لمنطق الأدوات، واختبارات طرف إلى طرف للمسارات الحاسمة.

الهرم والكأس كلاهما خطأ إن اتّبعته بتزمّت. فالاستراتيجية الصحيحة هي التي تلتقط عللك أنت، وتعمل بسرعة تكفي فريقك أنت، وتنجو من معماريتك أنت. وقد تبدو هرمًا، أو كأسًا، أو شكلًا لم يسمّه أحد بعد. كفّ عن الجدال في الشكل، وانظر إلى ما تلتقطه اختباراتك فعلًا.

والبصيرة المهمّة خلف النموذجين أن لكل مستوى ملفًّا مختلفًا للكلفة والعائد. فاختبار الوحدة يكلّف أجزاء من الألف من الثانية كتابةً وتنفيذًا. واختبار التكامل يكلّف ثوانٍ. واختبار الطرف إلى الطرف يكلّف دقائق. فوزّع ميزانية اختبارك بما يتناسب مع القيمة التي يقدّمها كل مستوى لتطبيقك تحديدًا. فإن كان لوحة معلومات كثيفة البيانات بمنطق عمل معقّد، فاستثمر في الوحدة والتكامل. وإن كان موقع محتوى قليل التفاعل، فاختبارات الطرف إلى الطرف للمسارات الحاسمة والانحدار البصري للتخطيط هي على الأرجح أفضل استثمار.

بناء ثقافة اختبار

تفشل أفضل استراتيجية إن لم يؤمن الفريق بالاختبار. وثقافة الاختبار لا تُفرَض بعتبات تغطية ولا بإلزام بالتطوير المقاد بالاختبار، بل تنشأ حين تصير كتابة الاختبارات الطريق الطبيعي الأقلّ مقاومة. فالمطوّرون يكتبون اختبارات حين تكون سهلة الكتابة، سريعة التشغيل، بيّنة الفائدة. وحين تكون هشّة وبطيئة وتنتج إخفاقات كاذبة، يجدون سبلًا لتفاديها.

ابدأ بجعل تجربة الاختبار ممتازة. استثمر في البنية التحتية: منفّذون سريعون، وقواعد اختبار موثوقة، وكشف للاختبارات المتذبذبة. واكتب أدوات مساعدة تجعل التأكيدات الشائعة موجزة. ووثّق الأنماط في دليل اختبار للفريق، حتى يعرف كل مطوّر كيف يختبر نقطة واجهة جديدة أو مكوّن React أو ترحيل قاعدة بيانات دون أن يبدأ من الصفر. فكلفة بناء بنية اختبار جيدة مرة واحدة أدنى بكثير من الكلفة المتراكمة لفريق يصارع اختبارات رديئة كل يوم.

وينبغي أن تشمل مراجعة الشيفرة مراجعة الاختبارات. فليسأل المراجع: هل يغطّي هذا الاختبار فعلًا السلوك الذي يدّعي تغطيته؟ أكان ليمرّ حتى لو كان التنفيذ خاطئًا؟ أيختبر الشيء الصحيح على المستوى الصحيح؟ أهو مقروء وقابل للصيانة؟ عامل الاختبارات شيفرةً من الدرجة الأولى، خاضعة لمعايير المراجعة نفسها وأدلة الأسلوب نفسها وتوقّعات الجودة نفسها التي تخضع لها شيفرة الإنتاج.

ومقاييس التغطية أداة تشخيص نافعة وهدف رديء. فإن حدّدت هدفًا بتسعين في المئة نلتها تسعين في المئة — لا اختبارات أفضل. سيكتب المطوّرون اختبارات تافهة على دوالّ الجلب والبانيات الفارغة لبلوغ الرقم دون تحسين شبكة الأمان. استعمل تقارير التغطية للعثور على مسارات لم تُختبر، لا لفرض عتبات اعتباطية. فسبعون في المئة مع اختبارات تكامل موضوعة بعناية أثمن من خمسة وتسعين في المئة مع اختبارات وحدة سطحية.

  • يسّر كتابة الاختبارات بأدوات ومصانع ومساعدات تقلّل الشيفرة المكرّرة في كل ملف اختبار.
  • احتفِ بتحسينات الاختبار كما تحتفي بتسليم الخصائص. فاختبار يزيل صنفًا كاملًا من العلل هو خاصية بذاته.
  • دوّر مسؤولية البنية التحتية للاختبار حتى تتوزّع المعرفة على الفريق بدل أن تتركّز في شخص واحد.
  • اعقد جلسة دورية لفرز الاختبارات المتذبذبة. وتتبّع عددها مؤشّرًا على صحّة الفريق، وأعطِ الأولوية لإصلاحها.
  • اكتب بيانًا للفريق في الاختبار يحدّد ما يُختبر عند كل مستوى، وما لا يُختبر، والمقايضات المتّفق عليها.

والغاية القصوى من ثقافة الاختبار ليست رقم تغطية بعينه ولا خطًّا مثاليًا، بل الثقة. الثقة بأن إعادة الهيكلة لن تكسر الإنتاج. والثقة بأن النشر عصر يوم جمعة آمن. والثقة بأن العلّة، متى أُبلغ عنها وأُصلحت، ستبقى مُصلَحة. والاستراتيجية التي تمنح الفريق تلك الثقة استراتيجية جيدة، سواء بدت هرمًا أو كأسًا أو شيئًا بينهما.

جمع الخيوط

بناء استراتيجية اختبار فعّالة ليس اختيار نهج على حساب آخر، بل فهم المقايضات وتطبيق الأداة المناسبة على كل حالة. فاختبارات الوحدة تلتقط أخطاء المنطق في أجزاء من الألف من الثانية. واختبارات التكامل تلتقط علل التفاعل مع اعتماديات حقيقية. واختبارات الطرف إلى الطرف تلتقط الإخفاقات التي يراها المستخدم عبر المكدّس كلّه. والاختبارات القائمة على الخصائص تلتقط حالات حدّية لم تكن تعلم بوجودها. والانحدار البصري يلتقط تغيّرات المظهر غير المقصودة.

والفرق التي تسلّم بثبات ليست أعلاها أرقام تغطية، بل تلك التي فكّرت بعناية فيما يقدّمه كل مستوى، وواءمت استثمارها مع ملفّ مخاطرها، وبنت ثقافة تُكتب فيها الاختبارات لأنها تضيف قيمة لا لأن سياسة تفرضها. ابدأ بمراجعة طقمك الحالي. واسأل عن كل اختبار: أيبرّر وجوده؟ فإن لم يكن الجواب نعم واضحة، فاحذفه أو استبدله. فأفضل طقم اختبارات ليس الأكبر، بل الذي يمنحك أكبر قدر من الثقة في كل دقيقة تشغيل.

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.