دليل اختبارات Vitest: من الصفر إلى الثقة في الإنتاج
اختبارات الوحدة، واختبارات التكامل، واختبارات المكوّنات مع Testing Library، واستراتيجيات المحاكاة، وإعداد التغطية، وفلسفة الاختبار التي تنتج برمجيات أفضل فعلاً.
معظم الفرق تكتب الاختبارات بنفس الطريقة التي يمارس بها معظم الناس الرياضة: يعلمون أنه ينبغي عليهم ذلك، ويشعرون بالذنب عندما لا يفعلون، وعندما يفعلون أخيراً يبالغون في اليوم الأول ويتوقفون بحلول الجمعة. ينتهي المشروع بمجموعة متناثرة من اختبارات اللقطات السطحية التي تنكسر في كل مرة يغيّر فيها شخص ما كلاس CSS، وبضع اختبارات تكامل لا يثق بها أحد، وشارة تغطية في ملف README تكذب.
لقد كنت على كلا الجانبين. شحنتُ مشاريع بدون أي اختبارات وتعرّقت في كل عملية نشر. وكنت أيضاً في فرق طاردت تغطية 100% وقضت وقتاً في صيانة الاختبارات أكثر من كتابة الميزات. لا الأولى تنفع ولا الثانية. ما ينفع هو كتابة الاختبارات الصحيحة، في الأماكن الصحيحة، بالأدوات الصحيحة.
غيّر Vitest طريقة تفكيري في الاختبار في JavaScript. ليس لأنه اخترع مفاهيم جديدة — فالأساسيات لم تتغير منذ أن كتب عنها Kent Beck قبل عقود. لكن لأنه أزال ما يكفي من الاحتكاك لدرجة أن كتابة الاختبارات توقفت عن كونها عبئاً وأصبحت جزءاً من حلقة التطوير. عندما يكون مشغّل الاختبارات بنفس سرعة خادم التطوير ويستخدم نفس الإعدادات، تتبخر الأعذار.
هذا المقال يحتوي على كل ما أعرفه عن الاختبار مع Vitest، من الإعداد الأولي إلى الفلسفة التي تجعل كل ذلك يستحق العناء.
لماذا Vitest بدلاً من Jest#
إذا كنت قد استخدمت Jest، فأنت تعرف معظم واجهة Vitest البرمجية بالفعل. وهذا بالتصميم — فـ Vitest متوافق مع Jest على مستوى الواجهة البرمجية. describe، it، expect، beforeEach، vi.fn() — كلها تعمل. فلماذا الانتقال؟
دعم ESM الأصلي#
بُني Jest لـ CommonJS. يمكنه التعامل مع ESM، لكنه يتطلب إعدادات، وأعلام تجريبية، وأحياناً دعاء. إذا كنت تستخدم صيغة import/export (وهي كل شيء حديث)، فمن المحتمل أنك صارعت خط أنابيب التحويل في Jest.
يعمل Vitest على Vite. و Vite يفهم ESM أصلياً. لا توجد خطوة تحويل لشفرتك المصدرية — إنها تعمل وحسب. هذا يهم أكثر مما يبدو. نصف مشاكل Jest التي قمت بتصحيحها على مر السنين تعود إلى حل الوحدات: SyntaxError: Cannot use import statement outside a module، أو محاكاة لا تعمل لأن الوحدة كانت مخزنة مؤقتاً بتنسيق مختلف.
نفس إعدادات خادم التطوير#
إذا كان مشروعك يستخدم Vite (وإذا كنت تبني تطبيق React أو Vue أو Svelte في 2026، فهو على الأرجح كذلك)، يقرأ Vitest ملف vite.config.ts تلقائياً. أسماء المسارات المستعارة والإضافات ومتغيرات البيئة تعمل في الاختبارات دون أي إعداد إضافي. مع Jest، تحتفظ بإعداد موازٍ يجب أن يظل متزامناً مع إعداد المجمّع. في كل مرة تضيف مسار مستعار في vite.config.ts، عليك أن تتذكر إضافة moduleNameMapper المقابل في jest.config.ts. إنه أمر صغير، لكن الأمور الصغيرة تتراكم.
السرعة#
Vitest سريع. سريع بشكل ذي معنى. ليس "يوفّر لك ثانيتين" سريع — بل "يغيّر طريقة عملك" سريع. يستخدم مخطط وحدات Vite لفهم أي اختبارات تتأثر بتغيير ملف ولا يشغّل سوى تلك. وضع المراقبة يستخدم نفس بنية HMR التي تجعل خادم تطوير Vite يبدو فورياً.
في مشروع يحتوي على أكثر من 400 اختبار، أدى التحول من Jest إلى Vitest إلى تقليل حلقة التغذية الراجعة في وضع المراقبة من ~4 ثوانٍ إلى أقل من 500 مللي ثانية. هذا هو الفرق بين "سأنتظر حتى ينجح الاختبار" و"سألقي نظرة على الطرفية وأصابعي لا تزال على لوحة المفاتيح."
قياس الأداء مدمج#
يتضمن Vitest bench() جاهزة للاستخدام لاختبارات الأداء. لا حاجة لمكتبة منفصلة:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});شغّلها بأمر vitest bench. ليست الحدث الرئيسي، لكن من الجيد وجود اختبارات الأداء في نفس سلسلة الأدوات دون تثبيت benchmark.js وتوصيل مشغّل منفصل.
الإعداد#
التثبيت#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomالإعدادات#
أنشئ ملف vitest.config.ts في جذر مشروعك:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});أو، إذا كان لديك بالفعل ملف vite.config.ts، يمكنك توسيعه:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});قرار globals: true#
عندما تكون globals قيمتها true، لا تحتاج لاستيراد describe، it، expect، beforeEach، إلخ — فهي متاحة في كل مكان، تماماً كما في Jest. عندما تكون false، تستوردها صراحةً:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});أنا أستخدم globals: true لأنها تقلل الضوضاء البصرية وتتطابق مع ما يتوقعه معظم المطورين. إذا كنت في فريق يقدّر الاستيراد الصريح، اجعلها false — لا توجد إجابة خاطئة هنا.
إذا كنت تستخدم globals: true، أضف أنواع Vitest إلى tsconfig.json ليتعرف عليها TypeScript:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}البيئة: jsdom مقابل happy-dom مقابل node#
يتيح لك Vitest اختيار تطبيق DOM لكل اختبار أو على المستوى العام:
node— بدون DOM. للمنطق البحت والأدوات المساعدة ومسارات API وأي شيء لا يتعامل مع المتصفح.jsdom— المعيار. تطبيق DOM كامل. أثقل لكن أكثر اكتمالاً.happy-dom— أخف وأسرع من jsdom لكن أقل اكتمالاً. بعض الحالات الحدّية (مثلRange،Selection، أوIntersectionObserver) قد لا تعمل.
أستخدم jsdom كإعداد افتراضي عام وأتجاوزه لكل ملف عندما أحتاج node:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});ملفات الإعداد#
يعمل ملف الإعداد قبل كل ملف اختبار. هذا هو المكان الذي تُعدّ فيه مطابقات Testing Library والمحاكاة العامة:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});استيراد @testing-library/jest-dom/vitest يمنحك مطابقات مثل toBeInTheDocument()، toHaveClass()، toBeVisible()، وغيرها الكثير. هذه تجعل التأكيدات على عناصر DOM مقروءة ومعبّرة.
كتابة اختبارات جيدة#
نمط AAA#
كل اختبار يتبع نفس البنية: Arrange (ترتيب)، Act (تنفيذ)، Assert (تأكيد). حتى عندما لا تكتب تعليقات صريحة، ينبغي أن تكون البنية مرئية:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});عندما أرى اختباراً يخلط الترتيب والتنفيذ والتأكيد في سلسلة واحدة من استدعاءات الدوال، أعلم أنه سيكون صعب الفهم عندما يفشل. حافظ على الفصل المرئي بين المراحل الثلاث حتى لو لم تضف التعليقات.
تسمية الاختبارات#
هناك مدرستان: it('should calculate total with tax') و it('calculates total with tax'). بادئة "should" مطوّلة دون إضافة معلومات. عندما يفشل الاختبار، سترى:
FAIL ✕ calculates total with tax
هذه جملة كاملة بالفعل. إضافة "should" مجرد ضوضاء. أفضّل الصيغة المباشرة: it('renders loading state')، it('rejects invalid email')، it('returns empty array when no matches found').
لكتل describe، استخدم اسم الوحدة المُختبرة:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});اقرأها بصوت عالٍ: "calculateTotal تجمع أسعار العناصر." "calculateTotal تطبّق معدل الضريبة." إذا عملت الجملة، فالتسمية تعمل.
تأكيد واحد لكل اختبار مقابل التجميع العملي#
القاعدة النقية تقول تأكيد واحد لكل اختبار. القاعدة العملية تقول: مفهوم واحد لكل اختبار. هذان شيئان مختلفان.
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});الاختبار الأول يحتوي على ثلاث تأكيدات لكنها جميعاً تتحقق من شيء واحد: تنسيق اسم العرض. إذا فشل أي تأكيد، تعرف بالضبط ما المعطّل. الاختبار الثاني هو ثلاثة اختبارات منفصلة محشورة معاً. إذا فشل التأكيد الثاني، لا تعرف هل الإنشاء أم التحديث هو المعطّل، والتأكيد الثالث لا يعمل أبداً.
أوصاف الاختبارات كتوثيق#
مجموعات الاختبار الجيدة تعمل كتوثيق حيّ. شخص غير مألوف بالكود يجب أن يتمكن من قراءة أوصاف الاختبارات وفهم سلوك الميزة:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});عندما تعمل مجموعة الاختبارات هذه، يُقرأ الناتج كمواصفات. هذا هو الهدف.
المحاكاة (Mocking)#
المحاكاة هي الأداة الأقوى والأخطر في أدوات الاختبار لديك. عند استخدامها بشكل جيد، تعزل الوحدة المُختبرة وتجعل الاختبارات سريعة وحتمية. عند استخدامها بشكل سيء، تُنشئ اختبارات تنجح بغض النظر عما يفعله الكود.
vi.fn() — إنشاء دوال المحاكاة#
أبسط محاكاة هي دالة تسجّل استدعاءاتها:
const mockCallback = vi.fn();
// Call it
mockCallback("hello", 42);
mockCallback("world");
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");يمكنك إعطاؤها قيمة إرجاع:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});أو جعلها تُرجع قيماً مختلفة في الاستدعاءات المتتالية:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — مراقبة الدوال الحقيقية#
عندما تريد مراقبة دالة دون استبدال سلوكها:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn يحتفظ بالتطبيق الأصلي افتراضياً. يمكنك تجاوزه بـ .mockImplementation() عند الحاجة لكن استعد الأصلي بعد ذلك بـ .mockRestore().
vi.mock() — محاكاة على مستوى الوحدة#
هذه هي الأهم. vi.mock() تستبدل وحدة كاملة:
// Mock the entire module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});يرفع Vitest استدعاءات vi.mock() إلى أعلى الملف تلقائياً. هذا يعني أن المحاكاة تكون في مكانها قبل تشغيل أي استيراد. لا تحتاج للقلق بشأن ترتيب الاستيراد.
المحاكاة التلقائية#
إذا أردت فقط استبدال كل تصدير بـ vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});بدون دالة المصنع، يقوم Vitest بمحاكاة تلقائية لجميع التصديرات. كل دالة مصدّرة تصبح vi.fn() تُرجع undefined. هذا مفيد للوحدات التي تريد إسكاتها (مثل التحليلات أو التسجيل) دون تحديد كل دالة.
المسح مقابل إعادة التعيين مقابل الاستعادة#
هذا يُربك الجميع في مرحلة ما:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally againفي الممارسة العملية، استخدم vi.clearAllMocks() في beforeEach لإعادة تعيين سجل الاستدعاءات بين الاختبارات. استخدم vi.restoreAllMocks() إذا كنت تستخدم spyOn وتريد استعادة الأصليات:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});خطر الإفراط في المحاكاة#
هذه أهم نصيحة يمكنني تقديمها بشأن المحاكاة: كل محاكاة هي كذبة ترويها لاختبارك. عندما تحاكي اعتمادية، فأنت تقول "أثق أن هذا الشيء يعمل بشكل صحيح، لذا سأستبدله بنسخة مبسّطة." إذا كان افتراضك خاطئاً، ينجح الاختبار لكن الميزة معطّلة.
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});هذا الاختبار يتحقق من أن processInput تستدعي validate و format. لكن ماذا لو استدعتهما بترتيب خاطئ؟ ماذا لو تجاهلت قيم إرجاعهما؟ ماذا لو كان من المفترض أن يمنع التحقق خطوة التنسيق من العمل؟ الاختبار لا يعرف. لقد حاكيت كل السلوك المثير للاهتمام.
القاعدة العامة: حاكِ عند الحدود، لا في الوسط. حاكِ طلبات الشبكة، والوصول إلى نظام الملفات، والخدمات الخارجية. لا تحاكِ دوال الأدوات المساعدة الخاصة بك إلا إذا كان هناك سبب مقنع (مثل أنها مكلفة التشغيل أو لها آثار جانبية).
اختبار مكوّنات React#
الأساسيات مع Testing Library#
تفرض Testing Library فلسفة: اختبر المكوّنات بالطريقة التي يتفاعل بها المستخدمون معها. لا فحص للحالة الداخلية، لا تفتيش لنسخ المكوّنات، لا عرض سطحي. تعرض مكوّناً وتتفاعل معه عبر DOM، تماماً كما يفعل المستخدم.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});الاستعلامات: getBy مقابل queryBy مقابل findBy#
هنا يرتبك المبتدئون. هناك ثلاثة أنواع من الاستعلامات وكل نوع له حالة استخدام محددة:
getBy* — يُرجع العنصر أو يطرح خطأ إذا لم يُعثر عليه. استخدمه عندما تتوقع وجود العنصر:
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });queryBy* — يُرجع العنصر أو null إذا لم يُعثر عليه. استخدمه عندما تؤكد أن شيئاً ما غير موجود:
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — يُرجع Promise. استخدمه للعناصر التي تظهر بشكل غير متزامن:
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();استعلامات إمكانية الوصول أولاً#
تقدّم Testing Library هذه الاستعلامات بترتيب أولوية مقصود:
getByRole— أفضل استعلام. يستخدم أدوار ARIA. إذا لم يكن مكوّنك قابلاً للعثور عليه بالدور، فقد يكون لديه مشكلة إمكانية وصول.getByLabelText— لعناصر النماذج. إذا لم يكن لحقل الإدخال تسمية، أصلح ذلك أولاً.getByPlaceholderText— مقبول لكن أضعف. العناصر النائبة تختفي عندما يكتب المستخدم.getByText— للعناصر غير التفاعلية. يبحث بالمحتوى النصي المرئي.getByTestId— الملاذ الأخير. استخدمه عندما لا يعمل أي استعلام دلالي.
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
// Over this
screen.getByPlaceholderText("Enter your email");
// And definitely over this
screen.getByTestId("email-input");الترتيب ليس عشوائياً. إنه يطابق كيفية تنقل التقنيات المساعدة في الصفحة. إذا تمكنت من العثور على عنصر بدوره واسمه المتاح، يمكن لقارئات الشاشة ذلك أيضاً. إذا لم تجده إلا بمعرّف الاختبار، فقد يكون لديك فجوة في إمكانية الوصول.
أحداث المستخدم#
لا تستخدم fireEvent. استخدم @testing-library/user-event. الفرق مهم:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type fires keydown, keypress, input, keyup for EACH character
// fireEvent.change just sets the value — skipping realistic event flow
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent يحاكي سلسلة الأحداث الكاملة التي يطلقها متصفح حقيقي. fireEvent.change هو حدث اصطناعي واحد. إذا كان مكوّنك يستمع إلى onKeyDown أو يستخدم onInput بدلاً من onChange، فإن fireEvent.change لن يُشغّل تلك المعالجات لكن userEvent.type سيفعل.
استدعِ دائماً userEvent.setup() في البداية واستخدم نسخة user المُرجعة. هذا يضمن ترتيب الأحداث وتتبع الحالة بشكل صحيح.
اختبار تفاعلات المكوّنات#
اختبار مكوّن واقعي يبدو هكذا:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});لاحظ: لا فحص للحالة الداخلية، لا component.setState()، لا التحقق من تفاصيل التطبيق. نعرض، نتفاعل، ونؤكد على ما يراه المستخدم. إذا أعاد المكوّن هيكلة إدارة حالته الداخلية من useState إلى useReducer، ستظل هذه الاختبارات تنجح. هذا هو المقصود.
اختبار الكود غير المتزامن#
waitFor#
عندما يتحدّث مكوّن بشكل غير متزامن، waitFor يستقصي حتى ينجح التأكيد:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for content to appear
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator should be gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor يعيد محاولة الاستدعاء كل 50 مللي ثانية (افتراضياً) حتى ينجح أو تنتهي المهلة (1000 مللي ثانية افتراضياً). يمكنك تخصيص كليهما:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);المؤقتات الوهمية#
عند اختبار كود يستخدم setTimeout، setInterval، أو Date، تتيح لك المؤقتات الوهمية التحكم في الزمن:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset the timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});مهم: استدعِ دائماً vi.useRealTimers() في afterEach. المؤقتات الوهمية التي تتسرب بين الاختبارات تسبب أكثر الأعطال إرباكاً التي ستواجهها في حياتك.
الاختبار بالمؤقتات الوهمية والعرض غير المتزامن#
الجمع بين المؤقتات الوهمية واختبار مكوّنات React يتطلب حذراً. جدولة React الداخلية تستخدم مؤقتات حقيقية، لذا تحتاج غالباً إلى تقديم المؤقتات وتفريغ تحديثات React معاً:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Advance timers inside act() to flush React updates
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});محاكاة API مع MSW#
لاختبار جلب البيانات، يعترض Mock Service Worker (MSW) طلبات الشبكة على مستوى الشبكة. هذا يعني أن كود fetch/axios في مكوّنك يعمل تماماً كما سيعمل في الإنتاج — MSW فقط يستبدل استجابة الشبكة:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Override the default handler for this one test
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW أفضل من محاكاة fetch أو axios مباشرةً لأن:
- كود جلب البيانات الفعلي في مكوّنك يعمل — أنت تختبر التكامل الحقيقي.
- يمكنك اختبار معالجة الأخطاء بتجاوز المعالجات لكل اختبار.
- نفس المعالجات تعمل في كلٍّ من الاختبارات ووضع التطوير في المتصفح (Storybook، على سبيل المثال).
اختبارات التكامل#
اختبارات الوحدة مقابل اختبارات التكامل#
اختبار الوحدة يعزل دالة واحدة أو مكوّناً واحداً ويحاكي كل شيء آخر. اختبار التكامل يترك عدة وحدات تعمل معاً ولا يحاكي إلا الحدود الخارجية (الشبكة، نظام الملفات، قواعد البيانات).
الحقيقة هي: معظم الأخطاء التي رأيتها في الإنتاج تحدث عند الحدود بين الوحدات، لا داخلها. دالة تعمل بشكل مثالي بمعزل لكنها تفشل لأن المُستدعي يمرر البيانات بتنسيق مختلف قليلاً. مكوّن يُعرض بشكل جيد مع بيانات محاكاة لكنه ينكسر عندما تحتوي استجابة API الفعلية على مستوى تضمين إضافي.
اختبارات التكامل تلتقط هذه الأخطاء. إنها أبطأ من اختبارات الوحدة وأصعب في التصحيح عندما تفشل، لكنها تعطي ثقة أكبر لكل اختبار.
اختبار عدة مكوّنات معاً#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});في هذا الاختبار، ShoppingCart و CartProvider ومكوّناتهما الداخلية (صفوف العناصر، محددات الكمية، عرض الإجماليات) تعمل جميعها معاً بكود حقيقي. المحاكاة الوحيدة هي استدعاء API، لأننا لا نريد إجراء طلبات شبكة حقيقية في الاختبارات.
متى تستخدم اختبارات التكامل مقابل اختبارات الوحدة#
استخدم اختبارات الوحدة عندما:
- المنطق معقد وله حالات حدّية كثيرة (محلل تواريخ، آلة حالة، حساب).
- تحتاج تغذية راجعة سريعة عن سلوك دالة محددة.
- الوحدة معزولة نسبياً ولا تعتمد بشكل كبير على وحدات أخرى.
استخدم اختبارات التكامل عندما:
- عدة مكوّنات تحتاج للعمل معاً بشكل صحيح (نموذج مع تحقق وإرسال).
- البيانات تتدفق عبر عدة طبقات (سياق ← مكوّن ← مكوّن فرعي).
- تختبر سير عمل مستخدم، لا قيمة إرجاع دالة.
في الممارسة العملية، مجموعة اختبارات صحية تركّز على اختبارات التكامل للميزات وتحتوي على اختبارات وحدة للأدوات المساعدة المعقدة. المكوّنات نفسها تُختبر من خلال اختبارات التكامل — لا تحتاج اختبار وحدة منفصل لكل مكوّن صغير إذا كان اختبار التكامل يمرّنه.
التغطية#
تشغيل التغطية#
vitest run --coverageستحتاج مزود تغطية. يدعم Vitest اثنين:
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbulأعدّه في إعدادات Vitest:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul مقابل V8#
تغطية V8 تستخدم أدوات القياس المدمجة في محرك V8. إنها أسرع لأنه لا توجد خطوة تحويل كود. لكنها قد تكون أقل دقة في بعض الحالات الحدّية، خاصةً حول حدود وحدات ES.
Istanbul يضيف عدّادات لشفرتك المصدرية قبل تشغيل الاختبارات. إنه أبطأ لكنه أكثر نضجاً ويعطي تغطية فروع أدق. إذا كنت تفرض حدود تغطية في CI، قد تكون دقة Istanbul مهمة.
أستخدم V8 للتطوير المحلي (تغذية راجعة أسرع) و Istanbul في CI (إنفاذ أدق). يمكنك إعداد مزودين مختلفين لكل بيئة إذا لزم الأمر.
ماذا تعني التغطية فعلاً#
التغطية تخبرك أي أسطر من الكود تم تنفيذها أثناء الاختبارات. هذا كل شيء. لا تخبرك ما إذا كانت تلك الأسطر قد اختُبرت بشكل صحيح. تأمّل هذا:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// No assertion!
});هذا الاختبار يعطي تغطية 100% لدالة divide. لكنه أيضاً لا يختبر شيئاً على الإطلاق. الاختبار سينجح لو أرجعت divide القيمة null، أو طرحت خطأ، أو أطلقت صواريخ.
التغطية مؤشر سلبي مفيد: تغطية منخفضة تعني حتماً وجود مسارات غير مختبرة. لكن تغطية عالية لا تعني أن كودك مختبر جيداً. تعني فقط أن كل سطر عمل أثناء اختبار ما.
الأسطر مقابل الفروع#
تغطية الأسطر هي المقياس الأكثر شيوعاً لكن تغطية الفروع أكثر قيمة:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}اختبار بـ getDiscount({ isPremium: true, yearsActive: 10 }) يمر على كل سطر (تغطية أسطر 100%) لكنه يختبر فرعين فقط من ثلاثة. مسار isPremium: false ومسار yearsActive <= 5 غير مختبرين.
تغطية الفروع تلتقط هذا. تتعقب كل مسار ممكن عبر المنطق الشرطي. إذا كنت ستفرض حداً للتغطية، استخدم تغطية الفروع.
تجاهل الكود المُولَّد#
بعض الكود لا ينبغي احتسابه في التغطية. الملفات المولّدة، تعريفات الأنواع، الإعدادات — هذه تُضخّم مقاييسك دون إضافة قيمة:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}يمكنك أيضاً تجاهل أسطر أو كتل محددة في شفرتك المصدرية:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }استخدم هذا بشكل مقتصد. إذا وجدت نفسك تتجاهل أجزاء كبيرة من الكود، فإما أن تلك الأجزاء تحتاج اختبارات أو أنها لا ينبغي أن تكون في تقرير التغطية أصلاً.
اختبار Next.js#
محاكاة next/navigation#
مكوّنات Next.js التي تستخدم useRouter، usePathname، أو useSearchParams تحتاج محاكاة:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));للاختبارات التي تحتاج التحقق من استدعاء التنقل:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});محاكاة next-intl#
للمكوّنات المتعددة اللغات التي تستخدم next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));هذا هو الأسلوب الأبسط — الترجمات تُرجع المفتاح نفسه، لذا t("hero.title") يُرجع "hero.title". في التأكيدات، تتحقق من مفتاح الترجمة بدلاً من النص المترجم الفعلي. هذا يجعل الاختبارات مستقلة عن اللغة.
إذا كنت بحاجة لترجمات فعلية في اختبار محدد:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));اختبار معالجات المسارات#
معالجات مسارات Next.js هي دوال عادية تأخذ Request وتُرجع Response. اختبارها بسيط:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});اختبار Middleware#
Middleware في Next.js يعمل على الحافة ويعالج كل طلب. اختبره كدالة:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});اختبار Server Actions#
Server Actions هي دوال غير متزامنة تعمل على الخادم. بما أنها مجرد دوال، يمكنك اختبارها مباشرةً — لكن قد تحتاج لمحاكاة الاعتماديات الخاصة بالخادم:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Missing required fields
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});أنماط عملية#
دالة العرض المخصصة#
معظم المشاريع تحتاج نفس المزودات ملفوفة حول كل مكوّن. أنشئ عرضاً مخصصاً:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };الآن كل ملف اختبار يستورد من أدواتك المخصصة بدلاً من @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header and all its children have access to ThemeProvider and CartProvider
});اختبار الخطافات المخصصة#
يعمل Vitest مع renderHook من @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // doesn't go below min
});
});اختبار حدود الأخطاء#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Suppress console.error for expected errors
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});اختبار اللقطات (بحذر)#
اختبارات اللقطات لها سمعة سيئة لأن الناس يستخدمونها كبديل عن التأكيدات الحقيقية. لقطة لمخرجات HTML كاملة لمكوّن هي عبء صيانة — تنكسر مع كل تغيير في كلاس CSS ولا أحد يراجع الفرق بعناية.
لكن اللقطات المستهدفة يمكن أن تكون مفيدة:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Don't do this
});اللقطات المضمّنة (toMatchInlineSnapshot) أفضل من لقطات الملفات لأن القيمة المتوقعة مرئية مباشرةً في الاختبار. يمكنك رؤية ما تُرجعه الدالة بنظرة واحدة دون فتح ملف .snap منفصل.
فلسفة الاختبار#
اختبر السلوك، لا التطبيق#
هذا المبدأ مهم لدرجة أنه يستحق قسماً مخصصاً. تأمّل اختبارين لنفس الميزة:
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});الاختبار الأول ينكسر إذا انتقلت من useState إلى useReducer، رغم أن المكوّن يعمل بنفس الطريقة تماماً. الاختبار الثاني ينكسر فقط إذا تغيّر سلوك المكوّن فعلاً. لا يهمّه كيف يُدار العداد داخلياً — فقط أن النقر على "+" يجعل الرقم يزداد.
اختبار الحسم بسيط: هل يمكنك إعادة هيكلة التطبيق دون تغيير الاختبار؟ إذا نعم، فأنت تختبر السلوك. إذا لا، فأنت تختبر التطبيق.
كأس الاختبار#
اقترح Kent C. Dodds "كأس الاختبار" كبديل لهرم الاختبار التقليدي:
╭─────────╮
│ E2E │ قليلة — مكلفة، بطيئة، ثقة عالية
├─────────┤
│ │
│ Integr. │ الأكثر — نسبة ثقة-إلى-تكلفة جيدة
│ │
├─────────┤
│ Unit │ بعض — سريعة، مركّزة، تكلفة منخفضة
├─────────┤
│ Static │ دائماً — TypeScript، ESLint
╰─────────╯
الهرم التقليدي يضع اختبارات الوحدة في القاع (كثير منها) واختبارات التكامل في الوسط (أقل). الكأس يعكس هذا: اختبارات التكامل هي النقطة المثالية. إليك السبب:
- التحليل الثابت (TypeScript، ESLint) يلتقط الأخطاء المطبعية والأنواع الخاطئة والأخطاء المنطقية البسيطة مجاناً. لا تحتاج حتى لتشغيل أي شيء.
- اختبارات الوحدة ممتازة للمنطق البحت المعقد لكنها لا تخبرك إذا كانت القطع تعمل معاً.
- اختبارات التكامل تتحقق من أن المكوّنات والخطافات والسياقات تعمل معاً. تعطي أكبر ثقة لكل اختبار مكتوب.
- اختبارات من-الطرف-إلى-الطرف تتحقق من النظام بأكمله لكنها بطيئة وغير مستقرة ومكلفة الصيانة. تحتاج بضعاً منها للمسارات الحرجة لكن ليس المئات.
أتبع هذا التوزيع عملياً: TypeScript يلتقط معظم أخطاء الأنواع، أكتب اختبارات وحدة للأدوات المساعدة والخوارزميات المعقدة، واختبارات تكامل للميزات وسير عمل المستخدم، وحفنة من اختبارات E2E للمسار الحرج (التسجيل، الشراء، سير العمل الأساسي).
ما يمنح الثقة مقابل ما يُضيع الوقت#
الاختبارات موجودة لتمنحك الثقة للنشر. ليس الثقة بأن كل سطر كود يعمل — بل الثقة بأن التطبيق يعمل للمستخدمين. هذان شيئان مختلفان.
ثقة عالية، قيمة عالية:
- اختبار تكامل لتدفق الدفع — يغطي تحقق النموذج، واستدعاءات API، وتحديثات الحالة، وواجهة النجاح/الخطأ.
- اختبار وحدة لدالة حساب الأسعار مع حالات حدّية — الفاصلة العائمة، التقريب، الخصومات، القيم الصفرية/السالبة.
- اختبار أن المسارات المحمية تعيد توجيه المستخدمين غير المصادق عليهم.
ثقة منخفضة، مضيّعات وقت:
- اختبار لقطة لصفحة تسويقية ثابتة — ينكسر كل مرة يتغير فيها النص، لا يلتقط شيئاً ذا معنى.
- اختبار وحدة يتحقق أن مكوّناً يمرر خاصية لمكوّن فرعي — تختبر React نفسها، لا كودك.
- اختبار أن
useStateيُستدعى — تختبر الإطار، لا السلوك. - تغطية 100% لملف إعدادات — إنه بيانات ثابتة، TypeScript يتحقق من شكله أصلاً.
السؤال الذي يجب طرحه قبل كتابة اختبار: "إذا لم يكن هذا الاختبار موجوداً، أي خلل يمكن أن يتسلل للإنتاج؟" إذا كانت الإجابة "لا شيء لن يلتقطه TypeScript" أو "لا شيء سيلاحظه أحد"، فالاختبار على الأرجح لا يستحق الكتابة.
الاختبار كتغذية راجعة للتصميم#
الكود صعب الاختبار عادةً ما يكون كوداً سيء التصميم. إذا احتجت لمحاكاة خمسة أشياء لاختبار دالة واحدة، فتلك الدالة لديها اعتماديات كثيرة. إذا لم تتمكن من عرض مكوّن دون إعداد مزودات سياق معقدة، فالمكوّن مقترن بشكل مبالغ فيه ببيئته.
الاختبارات هي مستخدم لكودك. إذا كانت اختباراتك تعاني في استخدام واجهتك البرمجية، سيعاني المطورون الآخرون أيضاً. عندما تجد نفسك تصارع إعداد الاختبار، خذها كإشارة لإعادة هيكلة الكود المُختبر، لا لإضافة المزيد من المحاكاة.
// Hard to test — function does too much
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Easier to test — separated concerns
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}النسخة الثانية هي دالة نقية. يمكنك اختبار كل تركيبة من نتائج المخزون والدفع دون محاكاة قاعدة بيانات أو مزود دفع أو خدمة بريد إلكتروني. منطق التنسيق (جلب البيانات، إرسال رسائل البريد) يعيش في طبقة رقيقة تُختبر على مستوى التكامل.
هذه هي القيمة الحقيقية للاختبار: ليس التقاط الأخطاء بعد كتابتها، بل منع التصاميم السيئة قبل تثبيتها. انضباط كتابة الاختبارات يدفعك نحو دوال أصغر، وواجهات أوضح، وبنية أكثر نمطية. الاختبارات هي الأثر الجانبي. تحسين التصميم هو الحدث الرئيسي.
اكتب اختبارات تثق بها#
أسوأ ما يمكن أن يحدث لمجموعة اختبارات ليس أن بها فجوات. بل أن الناس يتوقفون عن الوثوق بها. مجموعة اختبارات ببضعة اختبارات غير مستقرة تفشل عشوائياً في CI تُعلّم الفريق تجاهل البناءات الحمراء. بمجرد حدوث ذلك، تصبح مجموعة الاختبارات أسوأ من عديمة الفائدة — فهي تقدّم أماناً زائفاً.
إذا فشل اختبار بشكل متقطع، أصلحه أو احذفه. إذا كان اختبار بطيئاً، سرّعه أو انقله لمجموعة اختبارات بطيئة منفصلة. إذا كان اختبار ينكسر مع كل تغيير غير ذي صلة، أعد كتابته ليختبر السلوك بدلاً من التطبيق.
الهدف هو مجموعة اختبارات حيث كل فشل يعني شيئاً حقيقياً معطّل. عندما يثق المطورون بالاختبارات، يشغّلونها قبل كل commit. وعندما لا يثقون بها، يتجاوزونها بـ --no-verify وينشرون وأصابعهم متشابكة بالدعاء.
ابنِ مجموعة اختبارات تراهن عليها بعطلة نهاية الأسبوع. لا شيء أقل من ذلك يستحق الصيانة.