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

قبل ثلاث سنوات، كانت الأعذار لعدم كتابة الاختبارات معقولة على الأقل. كانت أطر الاختبار بطيئة. كان إعداد الموكس (Mocks) مملاً. استغرقت كتابة الاختبارات وقتًا أطول من كتابة الكود. لم يكن العائد على الاستثمار واضحًا لشركة ناشئة مكونة من 3 أشخاص تسعى جاهدة لتحقيق ملاءمة المنتج للسوق. لم أوافق على تلك الأعذار، لكنني فهمتها.
في عام 2026، اختفت تلك الأعذار. يقوم Vitest بتشغيل مجموعة اختبار كاملة في الوقت الذي كان يستغرقه Jest للتشغيل. تقوم أدوات الذكاء الاصطناعي بإنشاء ملفات اختبار شاملة من توقيع دالة. يكتشف TypeScript فئات كاملة من الأخطاء في وقت الترجمة، مما يضيق نطاق ما يحتاج إلى الاختبار بالفعل. يقوم Docker Compose بتشغيل قواعد بيانات حقيقية لاختبارات التكامل في ثوانٍ. لم تكن الفجوة بين "لا توجد اختبارات" و "مختبر جيدًا" أصغر من أي وقت مضى.
مشهد الاختبار الحديث
لقد توحد نظام الاختبار البيئي حول عدد قليل من الأدوات الممتازة، واختفى التجزؤ الذي كان يجعل إعداد الاختبار مشروعًا بحثيًا إلى حد كبير.
Vitest مقابل Jest: تم اتخاذ القرار
لأي مشروع جديد، Vitest هو الخيار الافتراضي. ليس لأن Jest سيء — Jest إطار عمل قوي ومجرب — ولكن لأن Vitest أفضل بشكل ملحوظ في كل بُعد يهم التطوير الحديث.
السرعة: يستخدم Vitest esbuild للتحويل ويشغل الاختبارات في سلاسل عاملة (worker threads) مع دعم ESM الأصلي. في مشاريعي، تعمل نفس مجموعة الاختبار أسرع 3-5 مرات تحت Vitest مقارنة بـ Jest. مجموعة استغرقت 45 ثانية في Jest تكتمل في 12 ثانية في Vitest.
التكوين: يعيد Vitest استخدام تكوين Vite الخاص بك. إذا كنت تستخدم Vite بالفعل لعملية البناء (وفي عام 2026، معظم المشاريع كذلك)، فلا توجد أي إعدادات إضافية. قارن ذلك بـ moduleNameMapper و transform و transformIgnorePatterns في Jest، والمشكلات الحتمية المتعلقة بسبب عدم عمل بعض حزم ESM.
التوافقية: يطبق Vitest واجهة برمجة تطبيقات متوافقة مع Jest. describe، it، expect، beforeEach، afterEach، vi.fn()، vi.mock() — كلها تعمل كما هو متوقع. الهجرة من Jest إلى Vitest هي في الغالب عملية بحث واستبدال لـ jest.fn() بـ vi.fn()."
// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
متى تلتزم بـ Jest: إذا كان لديك مجموعة اختبار Jest كبيرة موجودة (أكثر من 500 اختبار) ولا توجد نقاط ضعف فورية، فإن الهجرة ليست ملحة. يستمر Jest في تلقي التحديثات ويعمل بشكل جيد. ولكن بالنسبة للمشاريع الجديدة أو المشاريع التي تحتوي على أقل من 100 اختبار، فإن Vitest هو الخيار الواضح.
هرم الاختبار في الممارسة
يظل هرم الاختبار التقليدي (العديد من اختبارات الوحدات، عدد أقل من اختبارات التكامل، عدد قليل من اختبارات E2E) صحيحًا من حيث المبدأ، لكن الحدود قد تغيرت.
اختبارات الوحدات تتحقق من الوظائف الفردية، وطرق المساعدة (utility methods)، ومنطق العمل النقي. يجب أن تكون سريعة (أقل من 10 مللي ثانية لكل منها)، ولا تحتوي على تبعيات خارجية، وتختبر السلوك بدلاً من التنفيذ.
اختبارات التكامل تتحقق من أن المكونات تعمل معًا — مسارات API مع استعلامات قاعدة البيانات، وطرق الخدمة مع استدعاءات API الخارجية، ومكونات React مع إدارة حالتها. هذه أبطأ (100-500 مللي ثانية لكل منها) ولكنها تكتشف الأخطاء التي تفوتها اختبارات الوحدات.
اختبارات E2E تتحقق من تدفقات عمل المستخدم الكاملة عبر التطبيق الفعلي. هذه هي الأبطأ (5-30 ثانية لكل منها) والأكثر هشاشة، لكنها تكتشف الأخطاء التي لا تكتشفها أي اختبارات أخرى. لقد جعلت أدوات مثل Playwright اختبارات E2E أكثر موثوقية بشكل كبير من عصر Selenium.
النسبة التي أستهدفها: 70% وحدات، 20% تكامل، 10% E2E. الأرقام الدقيقة أقل أهمية من وجود تمثيل في كل مستوى.
ما يجب اختباره وما يجب تخطيه
ليس كل الكود يحتاج إلى نفس الصرامة في الاختبار. معرفة أين يجب استثمار جهد الاختبار لا يقل أهمية عن معرفة كيفية كتابة الاختبارات.
اختبر دائمًا
منطق العمل وقواعد المجال. إذا كان الكود يطبق قاعدة عمل — حساب الأسعار، فحص الأذونات، التحقق من صحة البيانات، انتقال آلة الحالة — فإنه يحتاج إلى اختبارات. هذه هي القواعد التي، إذا كانت خاطئة، تكلف المال أو تخلق ثغرات أمنية.
// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (!discount) return subtotal;
if (discount.type === 'percentage') {
return subtotal * (1 - discount.value / 100);
}
if (discount.type === 'fixed') {
return Math.max(0, subtotal - discount.value);
}
return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';
describe('calculateOrderTotal', () => {
const items: OrderItem[] = [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
];
it('calculates subtotal without discount', () => {
expect(calculateOrderTotal(items)).toBe(30.97);
});
it('applies percentage discount', () => {
const discount = { type: 'percentage' as const, value: 10 };
expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
});
it('applies fixed discount', () => {
const discount = { type: 'fixed' as const, value: 5 };
expect(calculateOrderTotal(items, discount)).toBe(25.97);
});
it('does not go below zero with fixed discount', () => {
const discount = { type: 'fixed' as const, value: 50 };
expect(calculateOrderTotal(items, discount)).toBe(0);
});
it('handles empty items array', () => {
expect(calculateOrderTotal([])).toBe(0);
});
});
وظائف تحويل البيانات. أي دالة تحول البيانات من شكل إلى آخر — أدوات ربط استجابات API، أدوات تسلسل بيانات النموذج، محللات CSV — تحتاج إلى اختبارات بمدخلات تمثيلية وحالات حافة.
مسارات معالجة الأخطاء. ماذا يحدث عندما تعيد واجهة برمجة التطبيقات (API) رمز 500؟ عندما يكون الإدخال فارغًا (null)؟ عندما لا يكون الملف موجودًا؟ مسارات الأخطاء هي حيث تختبئ الأخطاء لأن المطورين يختبرون المسار السعيد يدويًا ويفترضون أن مسار الخطأ يعمل.
وظائف المساعدة (Utility functions). منسقات السلاسل النصية، مساعدات التاريخ، أدوات مساعدة المصفوفات، وظائف التحقق من الصحة. تُستخدم هذه في كل مكان، والخطأ في دالة مساعدة يتضاعف عبر قاعدة الكود.
تخطى أو اختبر بخفة
أغلفة الإطار المباشرة. إذا كانت دالتك عبارة عن غلاف رفيع حول طريقة إطار عمل تم اختبارها جيدًا، فإن اختبارها يختبر الإطار، وليس الكود الخاص بك. مكون React الذي يعرض <h1>{title}</h1> لا يحتاج إلى اختبار يتحقق من عرض h1.
ملفات التكوين. التكوين الثابت، الثوابت، تعريفات الأنواع — هذه لا تحتاج إلى اختبارات. يقوم TypeScript بالفعل بالتحقق منها في وقت الترجمة.
ربط تكامل مكتبة الطرف الثالث. الكود الذي يستدعي stripe.charges.create() بمعاملات من نموذج بياناتك لا يحتاج إلى اختبار وحدة يقوم بمحاكاة Stripe والتحقق من أنك استدعيته. إنه يحتاج إلى اختبار تكامل يتحقق من تدفق الشحن الشامل.
استراتيجيات المحاكاة (Mocking)
المحاكاة هي حيث تنتقل الاختبارات من "التحقق من السلوك" إلى "اختبار تفاصيل التنفيذ". الهدف هو محاكاة أقل قدر ممكن مع الحفاظ على الاختبارات سريعة وحتمية.
نهج حقن التبعية
بدلاً من محاكاة الوحدات (modules)، قم بتمرير التبعيات كمعاملات. هذا يجعل الاختبار طبيعيًا ويتجنب سحر vi.mock() الذي يربط الاختبارات بهيكل الوحدة.
// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
return {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
await emailClient.send({
to: user.email,
template: 'welcome',
data: { name: user.name },
});
return user;
},
};
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';
describe('createUserService', () => {
it('creates user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
const db = {
users: { create: vi.fn().mockResolvedValue(mockUser) },
};
const emailClient = {
send: vi.fn().mockResolvedValue(undefined),
};
const service = createUserService(db as any, emailClient as any);
const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });
expect(result).toEqual(mockUser);
expect(emailClient.send).toHaveBeenCalledWith({
to: 'jane@example.com',
template: 'welcome',
data: { name: 'Jane' },
});
});
});
لا توجد استدعاءات vi.mock()، ولا سلاسل مسار الوحدة، ولا إعداد يعتمد على الترتيب. الاختبار صريح بشأن ما هو حقيقي وما هو مزيف.
متى تستخدم vi.mock()
تعد المحاكاة على مستوى الوحدة مناسبة عندما لا يمكنك التحكم في حقن التبعية — عادةً عند اختبار مكونات React التي تستورد الوحدات مباشرةً أو عند اختبار الكود الذي يستخدم متغيرات عامة خاصة بالبيئة.
// When you genuinely need module mocking
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));
MSW لمحاكاة API
بالنسبة للاختبارات التي تقوم بطلبات HTTP، يقوم Mock Service Worker (MSW) باعتراض طلبات الشبكة على مستوى عامل الخدمة (service worker). هذا أفضل من محاكاة fetch أو axios لأنه يختبر كود عميل HTTP الفعلي الخاص بك.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
توليد الاختبارات بمساعدة الذكاء الاصطناعي
لقد غيرت أدوات الذكاء الاصطناعي حقًا اقتصاديات كتابة الاختبارات. إن توليد الهيكل الأولي للاختبار — الكود المتكرر (boilerplate)، حالات المسار السعيد الأساسية، حالات الحافة القياسية — هو بالضبط نوع العمل المتكرر القائم على الأنماط الذي تتعامل معه نماذج اللغة الكبيرة (LLMs) بشكل جيد.
ما يفعله الذكاء الاصطناعي جيدًا
- توليد ملفات الاختبار من توقيعات الدوال وتعريفات الأنواع
- اقتراح حالات حافة قد لا تفكر فيها (مصفوفات فارغة، قيم فارغة (null)، أرقام حدودية)
- كتابة الكود المتكرر للإعداد/التهيئة (setup/teardown boilerplate)
- إنشاء كائنات وهمية (mock objects) تتطابق مع أشكال الواجهات
- توليد حالات اختبار معلمية (parameterized test cases) للدوال ذات مجموعات إدخال متعددة
ما يفعله الذكاء الاصطناعي بشكل سيء
- فهم سياق العمل ("يجب أن يفشل هذا لأن المستخدمين لا يمكنهم الطلب بعد منتصف الليل" هي معرفة خاصة بالمجال)
- اختبار تفاعلات الحالة المعقدة عبر استدعاءات دوال متعددة
- كتابة اختبارات تكامل ذات معنى تمارس حدود النظام الحقيقية
- تحديد ما يجب اختباره وما لا يجب اختباره
- كتابة اختبارات تتحقق من السلوك بدلاً من التنفيذ
سير العمل العملي
سير عملي مع الاختبار بمساعدة الذكاء الاصطناعي:
- اكتب الدالة أو الوحدة.
- اطلب من الذكاء الاصطناعي توليد ملف اختبار بحالات أساسية.
- راجع واصلح الاختبارات المولدة — غالبًا ما تختبر الاختبارات المولدة بواسطة الذكاء الاصطناعي تفاصيل التنفيذ بدلاً من السلوك.
- أضف حالات خاصة بالمجال فاتها الذكاء الاصطناعي.
- قم بتشغيل الاختبارات وتحقق من أنها تكتشف الأخطاء بالفعل عن طريق كسر الكود مؤقتًا.
يقوم الذكاء الاصطناعي بالخطوة 2 (التي كانت الجزء الممل)، وأنا أركز على الخطوات 3-5 (التي تتطلب حكمًا). هذا يقلل من وقت كتابة الاختبار بنسبة تتراوح تقريبًا بين 50-60% لكود المساعدة والخدمة القياسي.
اختبار مكونات React
لقد تطور اختبار المكونات بشكل كبير. لقد غير التحول من العرض الضحل (shallow rendering) لـ Enzyme إلى فلسفة اختبار React Testing Library التي تركز على المستخدم طريقة تفكيري في اختبارات المكونات.
ما يجب اختباره في المكونات
- هل يتم عرض المكون بشكل صحيح مع خصائص (props) مختلفة؟
- هل تؤدي تفاعلات المستخدم (النقر، الكتابة، التحديد) إلى السلوك المتوقع؟
- هل يتعامل المكون مع حالات التحميل والخطأ والفارغة؟
- هل يعمل العرض الشرطي (conditional rendering) بشكل صحيح؟
ما لا يجب اختباره في المكونات
- قيم الحالة الداخلية (اختبر ما يراه المستخدم، وليس ما يتتبعه React)
- تفاصيل التنفيذ (أي دالة تم استدعاؤها، كم مرة حدث إعادة عرض)
- التصميم (styling) (استخدم اختبار الانحدار البصري لذلك)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';
describe('OrderSummary', () => {
const defaultProps = {
items: [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
],
onCheckout: vi.fn(),
};
it('displays order items with prices', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Burger')).toBeInTheDocument();
expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
expect(screen.getByText('Fries')).toBeInTheDocument();
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('displays total', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
});
it('calls onCheckout when checkout button is clicked', async () => {
const user = userEvent.setup();
render(<OrderSummary {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /checkout/i }));
expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
});
it('shows empty state when no items', () => {
render(<OrderSummary {...defaultProps} items={[]} />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
});
it('disables checkout button during loading', () => {
render(<OrderSummary {...defaultProps} isLoading={true} />);
expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
});
});
ملاحظة: لا يوجد getByTestId إلا إذا كان ضروريًا، لا يوجد فحص للحالة الداخلية، لا يوجد تحقق من فئات CSS. تُقرأ الاختبارات كوصف لما يراه المستخدم ويفعله.
اختبار مسارات API
اختبارات مسارات API هي اختبارات تكامل تتحقق من أن معالج HTTP الخاص بك يعمل بشكل صحيح مع البرمجيات الوسيطة (middleware) والتحقق من الصحة وتنسيق الاستجابة.
// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';
describe('POST /api/orders', () => {
let authToken: string;
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
// Get auth token for test user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'testpassword' });
authToken = loginResponse.body.token;
});
afterAll(async () => {
await db.destroy();
});
it('creates an order with valid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'prod-1', quantity: 2 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
]),
});
});
it('returns 401 without auth token', async () => {
const response = await request(app)
.post('/api/orders')
.send({ items: [{ productId: 'prod-1', quantity: 2 }] });
expect(response.status).toBe(401);
});
it('returns 400 with invalid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [] });
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('returns 400 when product does not exist', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'nonexistent', quantity: 1 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(400);
expect(response.body.message).toContain('not found');
});
});
اختبار عمليات قاعدة البيانات
يتطلب اختبار عمليات قاعدة البيانات قاعدة بيانات حقيقية. محاكاة استعلامات SQL تختبر المحاكاة، وليس الاستعلام. استخدم قاعدة بيانات اختبار يتم إعادة إنشائها لكل تشغيل اختبار.
// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
beforeEach(async () => {
// Clean tables in dependency order
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.user.deleteMany();
});
describe('Order repository', () => {
it('creates order with items and calculates total', async () => {
const user = await prisma.user.create({
data: { email: 'test@example.com', name: 'Test User' },
});
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productName: 'Burger', price: 12.99, quantity: 2 },
{ productName: 'Fries', price: 4.99, quantity: 1 },
],
},
total: 30.97,
},
include: { items: true },
});
expect(order.items).toHaveLength(2);
expect(order.total).toBe(30.97);
expect(order.userId).toBe(user.id);
});
it('enforces unique email constraint', async () => {
await prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 1' },
});
await expect(
prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 2' },
})
).rejects.toThrow();
});
});
إعداد قاعدة بيانات الاختبار في CI
# In GitHub Actions
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
مع Prisma، قم بتشغيل npx prisma migrate deploy قبل الاختبارات لتطبيق المخطط. مع SQL الخام، قم بتشغيل نصوص الترحيل (migration scripts). تبدأ قاعدة بيانات الاختبار جديدة لكل تشغيل CI.
مقاييس التغطية التي تهم
تغطية الكود هي إشارة مفيدة، لكنها هدف سيء.
فخ التغطية
يؤدي التحسين لتحقيق تغطية 100% إلى اختبارات موجودة لضرب الأسطر غير المغطاة بدلاً من التحقق من السلوك. لقد رأيت اختبارات كهذه:
// This test exists purely for coverage
it('should have a default export', () => {
expect(module).toBeDefined();
});
هذا الاختبار لا يتحقق من أي شيء ذي معنى. إنه يضيف إلى رقم التغطية بينما يضيف صفر ثقة.
أهداف تغطية ذات معنى
بدلاً من عتبة تغطية شاملة، أستخدم أهدافًا مختلفة لفئات الكود المختلفة:
| الفئة | الهدف | الأساس المنطقي |
|---|---|---|
| منطق العمل / المجال | 90%+ | الأخطاء هنا تكلف المال |
| مسارات API / المتحكمات | 80%+ | حدود التكامل |
| وظائف المساعدة | 95%+ | مستخدمة على نطاق واسع، تأثير عالٍ |
| مكونات واجهة المستخدم | 70%+ | الأخطاء الب |