Node.js: الاختبار وتصحيح الأخطاء

آخر تحديث: 2026-08-26

كتب بوب واجهة برمجة تطبيقات RESTful لمنصة تجارة إلكترونية، وفي كل مرة يضيف فيها ميزة جديدة، كان شيء آخر يتعطل. كان الاختبار اليدوي باستخدام Postman يستغرق 30 دقيقة لكل عملية نشر، ومع ذلك كان لا يزال يفوت الحالات الحدية. فأدخل Jest لاختبارات الوحدة وSupertest لاختبارات تكامل واجهة برمجة التطبيقات — والآن يقوم npm test بتشغيل 50 اختبارًا في 3 ثوانٍ، وكل عملية نشر مدعومة بفحص CI آلي. كما أضاف winston للتسجيل المنظم، بحيث عندما يتسلل خطأ ما، تخبره السجلات بالضبط بما حدث من خطأ.

سوف تتعلم:


1. نظرة عامة على الاختبار

(1) لماذا نكتب الاختبارات

بدون اختبارات مع الاختبارات
تحقق يدوي، 30 دقيقة في كل مرة آلي، 3 ثوانٍ
تغيير شيء واحد يكسر كل شيء تغيير شيء واحد، تخبرك الاختبارات بما تعطل
قلق قبل النشر إشارات خضراء، انشر بثقة
خوف من إعادة الهيكلة إعادة الهيكلة مع شبكة أمان الاختبارات

▶ مثال: (2) هرم الاختبارات

100%
graph TD
    A["Unit Tests<br/>Many / Fast / Cheap"] --> B["Integration Tests<br/>Moderate / Slower"]
    B --> C["E2E Tests<br/>Few / Slow / Expensive"]
    style A fill:#4CAF50,color:#fff
    style B fill:#FF9800,color:#fff
    style C fill:#F44336,color:#fff

(3) مقارنة أدوات الاختبار في Node.js

الأداة النوع الميزات الأنسب لـ
Jest وحدة + تكامل تكوين صفري، mock/تغطية مدمجة، اختبار اللقطة الاستخدام العام
Mocha وحدة + تكامل مرن، إضافات غنية، يحتاج chai/sinon الاحتياجات المخصصة
Vitest وحدة نظام Vite البيئي، ESM أصلي، سريع جدًا مشاريع Vite
node:test وحدة مدمج في Node.js، بدون تبعيات المشاريع البسيطة
Supertest اختبار HTTP محاكاة طلبات HTTP، اختبار مسارات Express اختبارات تكامل API
Playwright E2E أتمتة المتصفح، دعم متعدد المتصفحات E2E للواجهة الأمامية

يستخدم هذا الدرس مزيج Jest + Supertest، وهو حل الاختبار الأكثر انتشارًا في مجتمع Node.js.



2. اختبارات الوحدة باستخدام Jest

▶ مثال: (1) التثبيت والإعداد

BASH
npm install --save-dev jest

أضف سكربت الاختبار إلى package.json:

JSON
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

يعمل Jest بدون تكوين افتراضيًا، حيث يكتشف تلقائيًا ملفات *.test.js أو *.spec.js.

▶ مثال: (2) الصيغة الأساسية: describe / it / expect

JAVASCRIPT
// math.js — الوحدة المطلوب اختبارها
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

module.exports = { add, subtract, multiply, divide };
▶ جرّب الكود
JAVASCRIPT
// math.test.js
const { add, subtract, multiply, divide } = require('./math');

describe('Math utilities', () => {
  test('add should sum two numbers', () => {
    expect(add(2, 3)).toBe(5);
    expect(add(-1, 1)).toBe(0);
  });

  test('subtract should return the difference', () => {
    expect(subtract(5, 3)).toBe(2);
  });

  test('multiply should return the product', () => {
    expect(multiply(3, 4)).toBe(12);
  });

  test('divide should throw on zero divisor', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

تشغيل:

BASH
npm test

Output:

TEXT 📖 للعرض فقط
 PASS  ./math.test.js
  Math utilities
    ✓ add should sum two numbers (2 ms)
    ✓ subtract should return the difference
    ✓ multiply should return the product
    ✓ divide should throw on zero divisor (1 ms)

Tests:       4 passed, 4 total
Time:        0.5s

(3) المطابِقات الشائعة

المطابِق المعنى مثال
toBe المساواة الصارمة (===) expect(1 + 1).toBe(2)
toEqual المساواة العميقة expect(obj).toEqual({ a: 1 })
toBeTruthy / toBeFalsy فحص منطقي expect(flag).toBeTruthy()
toBeNull / toBeUndefined null / undefined expect(val).toBeNull()
toThrow يرمي استثناءً expect(fn).toThrow()
toContain يحتوي المصفوفة على expect([1, 2, 3]).toContain(2)
toMatch مطابقة تعبير نمطي expect('hello').toMatch(/ell/)
toHaveLength فحص الطول expect('abc').toHaveLength(3)
resolves / rejects نتيجة الوعد (Promise) expect(promise).resolves.toBe(5)
toHaveBeenCalled تم استدعاء الـ mock expect(fn).toHaveBeenCalled()

▶ مثال: (4) الاختبار غير المتزامن

JAVASCRIPT
// async-functions.js
function fetchUser(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id, name: `User_${id}` });
    }, 100);
  });
}

async function getUserEmail(id) {
  const user = await fetchUser(id);
  return `${user.name.toLowerCase()}@example.com`;
}

module.exports = { fetchUser, getUserEmail };
▶ جرّب الكود
JAVASCRIPT
// async-functions.test.js
const { fetchUser, getUserEmail } = require('./async-functions');

describe('Async function tests', () => {
  test('fetchUser returns user object', async () => {
    const user = await fetchUser(1);
    expect(user).toEqual({ id: 1, name: 'User_1' });
  });

  test('getUserEmail returns formatted email', async () => {
    const email = await getUserEmail(42);
    expect(email).toBe('user_42@example.com');
  });

  test('fetchUser resolves within 200ms', async () => {
    await expect(fetchUser(1)).resolves.toHaveProperty('id', 1);
  });
});

(5) المحاكاة (Mocking)

تعزل الـ mocks التبعيات الخارجية (قواعد البيانات، طلبات HTTP، نظام الملفات) بحيث تركز الاختبارات على منطق الوحدة الحالية فقط.

JAVASCRIPT
// email-service.js
class EmailService {
  send(to, subject, body) {
    // استدعاء SMTP فعلي — بطيء جدًا لاختبارات الوحدة
    console.log(`Sending email to ${to}: ${subject}`);
    return true;
  }
}

class UserService {
  constructor(emailService) {
    this.emailService = emailService;
  }

  register(name, email) {
    // منطق الأعمال: التحقق + إرسال بريد الترحيب
    if (!email.includes('@')) throw new Error('Invalid email');
    this.emailService.send(email, 'Welcome', `Hello ${name}!`);
    return { name, email, status: 'registered' };
  }
}

module.exports = { EmailService, UserService };
JAVASCRIPT
// email-service.test.js
const { EmailService, UserService } = require('./email-service');

describe('UserService', () => {
  let mockEmailService;
  let userService;

  beforeEach(() => {
    mockEmailService = {
      send: jest.fn().mockReturnValue(true),
    };
    userService = new UserService(mockEmailService);
  });

  test('register should call emailService.send', () => {
    const result = userService.register('Alice', 'alice@example.com');

    expect(result).toEqual({
      name: 'Alice',
      email: 'alice@example.com',
      status: 'registered',
    });

    expect(mockEmailService.send).toHaveBeenCalledWith(
      'alice@example.com',
      'Welcome',
      'Hello Alice!'
    );
  });

  test('register should throw on invalid email', () => {
    expect(() => userService.register('Bob', 'invalid')).toThrow('Invalid email');
    expect(mockEmailService.send).not.toHaveBeenCalled();
  });

  test('register should send exactly one email', () => {
    userService.register('Charlie', 'charlie@example.com');
    expect(mockEmailService.send).toHaveBeenCalledTimes(1);
  });
});

▶ مثال: التأكيدات الأساسية مع Jest

JAVASCRIPT
test('basic matchers', () => {
  expect(2 + 2).toBe(4);
  expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
  expect([1, 2, 3]).toContain(2);
  expect('hello world').toMatch(/world/);
  expect(() => { throw new Error('fail'); }).toThrow('fail');
});
▶ جرّب الكود

يعرض مطابِقات Jest الأكثر استخدامًا: toBe للأنواع الأولية، وtoEqual للكائنات، وtoContain للمصفوفات، وtoMatch للسلاسل، وtoThrow لاختبار الأخطاء.




3. اختبارات تكامل واجهة برمجة التطبيقات باستخدام Supertest

▶ مثال: (1) التثبيت والمبدأ

BASH
npm install --save-dev supertest

يشغّل Supertest تطبيق Express في الذاكرة، ويرسل طلبات HTTP، ويتحقق من الاستجابات — دون الحاجة إلى الاستماع على منفذ حقيقي.

100%
flowchart LR
    A["Test File"] -->|"request(app)"| B["Express App<br/>(in-memory)"]
    B -->|"response"| A
    A -->|"assert"| C["Jest expect"]

▶ مثال: (2) اختبار واجهة برمجة تطبيقات Express

JAVASCRIPT
// app.js — تطبيق Express (تصدير بدون listen)
const express = require('express');
const app = express();

app.use(express.json());

let products = [
  { id: 1, name: 'Laptop', price: 999.99 },
  { id: 2, name: 'Phone', price: 499.99 },
];

app.get('/api/products', (req, res) => {
  res.json(products);
});

app.get('/api/products/:id', (req, res) => {
  const product = products.find(p => p.id === parseInt(req.params.id));
  if (!product) return res.status(404).json({ error: 'Product not found' });
  res.json(product);
});

app.post('/api/products', (req, res) => {
  const { name, price } = req.body;
  if (!name || price == null) {
    return res.status(400).json({ error: 'Name and price are required' });
  }
  const newProduct = { id: products.length + 1, name, price };
  products.push(newProduct);
  res.status(201).json(newProduct);
});

module.exports = app;
▶ جرّب الكود

ملاحظة: يصدّر app.js كائن app فقط، ولا يستدعي app.listen() داخل الوحدة. يتم وضع listen في server.js، بحيث يستطيع Supertest اختبار كائن app مباشرة.

JAVASCRIPT
// app.test.js
const request = require('supertest');
const app = require('./app');

describe('Products API', () => {
  test('GET /api/products should return product list', async () => {
    const response = await request(app).get('/api/products');

    expect(response.status).toBe(200);
    expect(response.body).toHaveLength(2);
    expect(response.body[0]).toHaveProperty('name', 'Laptop');
  });

  test('GET /api/products/:id should return a single product', async () => {
    const response = await request(app).get('/api/products/1');

    expect(response.status).toBe(200);
    expect(response.body.id).toBe(1);
  });

  test('GET /api/products/:id should return 404 for missing product', async () => {
    const response = await request(app).get('/api/products/999');

    expect(response.status).toBe(404);
    expect(response.body).toHaveProperty('error', 'Product not found');
  });

  test('POST /api/products should create a new product', async () => {
    const response = await request(app)
      .post('/api/products')
      .send({ name: 'Tablet', price: 299.99 });

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('name', 'Tablet');
    expect(response.body).toHaveProperty('id', 3);
  });

  test('POST /api/products should return 400 for missing fields', async () => {
    const response = await request(app)
      .post('/api/products')
      .send({ name: 'Incomplete Product' });

    expect(response.status).toBe(400);
    expect(response.body).toHaveProperty('error');
  });
});

▶ مثال: (3) اختبار واجهة برمجة تطبيقات محمية

JAVASCRIPT
// protected-routes.test.js
const request = require('supertest');
const app = require('./app');

describe('Protected API routes', () => {
  let token;

  beforeAll(async () => {
    // تسجيل الدخول للحصول على رمز JWT
    const response = await request(app)
      .post('/api/auth/login')
      .send({ username: 'alice', password: 'secret123' });
    token = response.body.token;
  });

  test('should access protected route with valid token', async () => {
    const response = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`);

    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('username', 'alice');
  });

  test('should reject access without token', async () => {
    const response = await request(app).get('/api/profile');

    expect(response.status).toBe(401);
  });

  test('should reject access with invalid token', async () => {
    const response = await request(app)
      .get('/api/profile')
      .set('Authorization', 'Bearer invalid_token_here');

    expect(response.status).toBe(401);
  });
});
▶ جرّب الكود

▶ مثال: اختبار فحص سلامة واجهة برمجة التطبيقات

JAVASCRIPT
const request = require('supertest');
const express = require('express');

const app = express();
app.get('/health', (req, res) => res.json({ status: 'ok' }));

describe('GET /health', () => {
  it('returns 200 with status ok', async () => {
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('ok');
  });
});
▶ جرّب الكود

اختبار فحص سلامة بسيط لواجهة برمجة التطبيقات باستخدام Supertest — ينشئ تطبيق Express أساسيًا، ويرسل طلب GET، ويتحقق من نص استجابة JSON ورمز الحالة.




4. تصحيح الأخطاء في Node.js

(1) node inspect + Chrome DevTools

يحتوي Node.js على مصحح أخطاء مدمج — لا حاجة إلى أدوات إضافية.

BASH
node inspect app.js

ثم افتح chrome://inspect في Chrome وانقر فوق "Open dedicated DevTools for Node".

عمليات تصحيح الأخطاء الشائعة:

الإجراء اختصار DevTools سطر الأوامر
متابعة F8 c
التخطي F10 n
الدخول F11 s
الخروج Shift+F11 o
تعيين نقطة توقف النقر على رقم السطر setBreakpoint()

▶ مثال: (2) تعيين نقاط التوقف في الكود

JAVASCRIPT
// debug-demo.js
function calculateDiscount(price, memberLevel) {
  debugger; // يتوقف التنفيذ هنا في المفتش
  let discount = 0;
  if (memberLevel === 'gold') discount = 0.2;
  else if (memberLevel === 'silver') discount = 0.1;
  const finalPrice = price * (1 - discount);
  return finalPrice;
}

console.log(calculateDiscount(100, 'gold'));
console.log(calculateDiscount(100, 'silver'));
console.log(calculateDiscount(100, 'unknown'));
▶ جرّب الكود
BASH
node inspect debug-demo.js

▶ مثال: (3) نصائح لتصحيح الأخطاء باستخدام console

JAVASCRIPT
// طرق تصحيح الأخطاء باستخدام console
const users = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' },
  { id: 3, name: 'Charlie', role: 'admin' },
];

// عرض جدولي
console.table(users);

// قياس وقت التنفيذ
console.time('database-query');
// ... منطق الاستعلام ...
console.timeEnd('database-query');

// تتبع المكدس
console.trace('Where is this called from?');

// مخرجات مجمعة
console.group('User Details');
console.log('Name: Alice');
console.log('Role: admin');
console.groupEnd();
▶ جرّب الكود

5. التسجيل المنظم باستخدام winston

(1) لماذا نستخدم winston بدلاً من console.log

console.log winston
لا توجد مستويات سجل يدعم debug/info/warn/error
لا يوجد إخراج إلى ملف يدعم Console + File + HTTP وغيرها من الـ transports
لا توجد تهيئة يدعم JSON / الطابع الزمني / تنسيقات مخصصة
لا توجد دورة للسجلات يقترن مع winston-daily-rotate-file للدورة التلقائية
لا يُنصح به للإنتاج خيار بمستوى الإنتاج

▶ مثال: (2) التثبيت والإعداد الأساسي

BASH
npm install winston
npm install winston-daily-rotate-file
JAVASCRIPT
// logger.js
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'my-app' },
  transports: [
    // سجلات الأخطاء — ملف منفصل
    new DailyRotateFile({
      filename: 'logs/error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      maxSize: '20m',
      maxFiles: '14d',
    }),
    // جميع السجلات — ملف مدمج
    new DailyRotateFile({
      filename: 'logs/combined-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '20m',
      maxFiles: '14d',
    }),
  ],
});

// التطوير: السجل أيضًا إلى وحدة التحكم مع مخرجات ملونة
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    ),
  }));
}

module.exports = logger;

▶ مثال: (3) الاستخدام في تطبيق Express

JAVASCRIPT
// app-with-logger.js
const express = require('express');
const logger = require('./logger');

const app = express();
app.use(express.json());

// برنامج وسيط لتسجيل الطلبات
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    logger.info('HTTP request', {
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: `${duration}ms`,
    });
  });
  next();
});

app.get('/api/health', (req, res) => {
  logger.debug('Health check requested');
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/api/users/:id', (req, res) => {
  const userId = req.params.id;
  logger.info('User lookup', { userId });

  if (isNaN(userId)) {
    logger.warn('Invalid user ID format', { userId });
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  // محاكاة البحث عن مستخدم
  const user = { id: parseInt(userId), name: 'Alice' };
  logger.info('User found', { userId: user.id, name: user.name });
  res.json(user);
});

app.use((err, req, res, next) => {
  logger.error('Unhandled error', { error: err.message, stack: err.stack });
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
▶ جرّب الكود

مثال على إخراج ملف السجل:

JSON
{"level":"info","message":"HTTP request","service":"my-app","timestamp":"2026-07-13 10:30:00","method":"GET","url":"/api/users/1","status":200,"duration":"15ms"}
{"level":"warn","message":"Invalid user ID format","service":"my-app","timestamp":"2026-07-13 10:30:05","userId":"abc"}
{"level":"error","message":"Unhandled error","service":"my-app","timestamp":"2026-07-13 10:31:00","error":"Cannot read property 'name' of undefined","stack":"TypeError: Cannot read property..."}

▶ مثال: التسجيل المنظم باستخدام winston

JAVASCRIPT
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});

logger.info('Server started', { port: 3000, env: 'development' });
logger.warn('Memory usage high', { used: '850MB' });
logger.error('Database connection failed', { db: 'main', retry: 3 });
▶ جرّب الكود

يعرض التسجيل المنظم بتنسيق JSON باستخدام winston — مستويات سجل مختلفة (info وwarn وerror) وبيانات وصفية سياقية مرفقة بكل إدخال سجل.




6. تغطية الاختبارات وTDD

(1) تغطية الاختبارات

يتضمن Jest أداة Istanbul لإعداد تقارير التغطية:

BASH
npm run test:coverage

Output:

TEXT 📖 للعرض فقط
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |    90.5 |    85.71 |     100 |    90.5 |
 math.js  |     100 |      100 |     100 |     100 |
 app.js   |   88.24 |    83.33 |     100 |   88.24 | 45-48
----------|---------|----------|---------|---------|-------------------

شرح أبعاد التغطية:

البعد المعنى الهدف المقترح
Statements نسبة التعليمات المنفذة ≥ 80%
Branches نسبة الفروع الشرطية المنفذة ≥ 75%
Functions نسبة الدوال المستدعاة ≥ 85%
Lines نسبة أسطر الكود المنفذة ≥ 80%

إن السعي وراء تغطية 100% له عوائد متناقصة؛ فالتغطية بنسبة 80% تكتشف معظم الأخطاء.

▶ مثال: (2) سير عمل TDD (Red-Green-Refactor)

100%
flowchart LR
    A["🔴 Red<br/>Write failing test"] --> B["🟢 Green<br/>Write minimal code to pass"]
    B --> C["🔵 Refactor<br/>Improve code while tests pass"]
    C --> A
  1. Red: اكتب اختبارًا أولاً (سيفشل لأن الميزة غير موجودة بعد)
  2. Green: اكتب الحد الأدنى من الكود لنجاح الاختبار
  3. Refactor: حسّن الكود مع استمرار نجاح الاختبارات

▶ مثال: دوال أدوات السلاسل النصية باستخدام TDD

JAVASCRIPT
// string-utils.test.js — اكتب الاختبار أولاً (Red)
const { capitalize, truncate, slugify } = require('./string-utils');

describe('StringUtils', () => {
  describe('capitalize', () => {
    test('should capitalize first letter', () => {
      expect(capitalize('hello')).toBe('Hello');
    });

    test('should handle empty string', () => {
      expect(capitalize('')).toBe('');
    });

    test('should handle already capitalized', () => {
      expect(capitalize('Hello')).toBe('Hello');
    });
  });

  describe('truncate', () => {
    test('should truncate long strings', () => {
      expect(truncate('Hello World', 5)).toBe('Hello...');
    });

    test('should not truncate short strings', () => {
      expect(truncate('Hi', 10)).toBe('Hi');
    });
  });

  describe('slugify', () => {
    test('should convert to URL slug', () => {
      expect(slugify('Hello World')).toBe('hello-world');
    });

    test('should handle special characters', () => {
      expect(slugify('C# & .NET!')).toBe('c-and-net');
    });
  });
});
▶ جرّب الكود
JAVASCRIPT
// string-utils.js — اكتب التنفيذ بعد الاختبار (Green)
function capitalize(str) {
  if (!str) return '';
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function truncate(str, maxLength) {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength) + '...';
}

function slugify(str) {
  return str
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

module.exports = { capitalize, truncate, slugify };


7. مثال شامل: كتابة مجموعة اختبارات كاملة لواجهة برمجة تطبيقات لإدارة المهام

▶ مثال: مجموعة اختبارات واجهة برمجة تطبيقات المهام

الخطوة 1 — كود التطبيق

JAVASCRIPT 📖 للعرض فقط
// tasks-app.js
const express = require('express');
const app = express();
app.use(express.json());

let tasks = [];
let nextId = 1;

function resetTasks() {
  tasks = [];
  nextId = 1;
}

app.get('/api/tasks', (req, res) => {
  const { status, priority } = req.query;
  let filtered = tasks;
  if (status) filtered = filtered.filter(t => t.status === status);
  if (priority) filtered = filtered.filter(t => t.priority === priority);
  res.json(filtered);
});

app.get('/api/tasks/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) return res.status(404).json({ error: 'Task not found' });
  res.json(task);
});

app.post('/api/tasks', (req, res) => {
  const { title, priority = 'medium' } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  const task = { id: nextId++, title, priority, status: 'pending' };
  tasks.push(task);
  res.status(201).json(task);
});

app.patch('/api/tasks/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) return res.status(404).json({ error: 'Task not found' });
  const { title, status, priority } = req.body;
  if (title) task.title = title;
  if (status) task.status = status;
  if (priority) task.priority = priority;
  res.json(task);
});

app.delete('/api/tasks/:id', (req, res) => {
  const index = tasks.findIndex(t => t.id === parseInt(req.params.id));
  if (index === -1) return res.status(404).json({ error: 'Task not found' });
  const deleted = tasks.splice(index, 1);
  res.json(deleted[0]);
});

module.exports = { app, resetTasks };
44 سطر من الكود المنطقي (تجاوز الحد 40, للعرض فقط)

الخطوة 2 — مجموعة الاختبارات الكاملة

JAVASCRIPT
// tasks-app.test.js
const request = require('supertest');
const { app, resetTasks } = require('./tasks-app');

beforeEach(() => {
  resetTasks();
});

describe('Tasks API — CRUD operations', () => {
  test('should create a task', async () => {
    const response = await request(app)
      .post('/api/tasks')
      .send({ title: 'Write tests', priority: 'high' });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: 1,
      title: 'Write tests',
      priority: 'high',
      status: 'pending',
    });
  });

  test('should reject task without title', async () => {
    const response = await request(app)
      .post('/api/tasks')
      .send({ priority: 'high' });

    expect(response.status).toBe(400);
    expect(response.body.error).toBe('Title is required');
  });

  test('should list all tasks', async () => {
    await request(app).post('/api/tasks').send({ title: 'Task 1' });
    await request(app).post('/api/tasks').send({ title: 'Task 2' });

    const response = await request(app).get('/api/tasks');
    expect(response.status).toBe(200);
    expect(response.body).toHaveLength(2);
  });

  test('should filter tasks by status', async () => {
    await request(app).post('/api/tasks').send({ title: 'Pending task' });

    const createRes = await request(app).post('/api/tasks').send({ title: 'Done task' });
    await request(app).patch(`/api/tasks/${createRes.body.id}`).send({ status: 'done' });

    const response = await request(app).get('/api/tasks?status=done');
    expect(response.body).toHaveLength(1);
    expect(response.body[0].status).toBe('done');
  });

  test('should update a task', async () => {
    const createRes = await request(app).post('/api/tasks').send({ title: 'Old title' });

    const response = await request(app)
      .patch(`/api/tasks/${createRes.body.id}`)
      .send({ title: 'New title', status: 'done' });

    expect(response.status).toBe(200);
    expect(response.body.title).toBe('New title');
    expect(response.body.status).toBe('done');
  });

  test('should delete a task', async () => {
    const createRes = await request(app).post('/api/tasks').send({ title: 'To delete' });

    const response = await request(app).delete(`/api/tasks/${createRes.body.id}`);
    expect(response.status).toBe(200);

    const listRes = await request(app).get('/api/tasks');
    expect(listRes.body).toHaveLength(0);
  });

  test('should return 404 for non-existent task', async () => {
    const response = await request(app).get('/api/tasks/999');
    expect(response.status).toBe(404);
  });
});

تشغيل:

BASH
npm test

Output:

TEXT 📖 للعرض فقط
 PASS  ./tasks-app.test.js
  Tasks API — CRUD operations
    ✓ should create a task (15 ms)
    ✓ should reject task without title (3 ms)
    ✓ should list all tasks (4 ms)
    ✓ should filter tasks by status (8 ms)
    ✓ should update a task (5 ms)
    ✓ should delete a task (5 ms)
    ✓ should return 404 for non-existent task (2 ms)

Tests:       7 passed, 7 total
Time:        0.8s

❓ أسئلة شائعة

س هل أختار Jest أم Mocha؟
ج Jest ذو تكوين صفري مع mock/تغطية مدمجين، وهو رائع لمعظم المشاريع؛ أما Mocha فهو مرن لكنه يحتاج إلى chai/sinon/istanbul، وهو الأنسب للمشاريع التي تتطلب تخصيصًا عميقًا. يُنصح باستخدام Jest للمشاريع الجديدة.
س ما الفرق بين اختبارات الوحدة واختبارات التكامل؟
ج تعزل اختبارات الوحدة الدوال/الوحدات الفردية وتختبرها باستخدام mocks لاستبدال التبعيات الخارجية — وهي سريعة. بينما تتحقق اختبارات التكامل من التعاون بين وحدات متعددة (مثل API + قاعدة البيانات) باستخدام مكونات حقيقية — وهي أبطأ ولكنها أقرب إلى السيناريوهات الحقيقية.
س هل تحتاج الاختبارات إلى اتصال حقيقي بقاعدة البيانات؟
ج يجب ألا تستخدم اختبارات الوحدة قاعدة بيانات حقيقية أبدًا — استخدم mocks. يمكن لاختبارات التكامل استخدام قاعدة بيانات في الذاكرة (مثل SQLite :memory:) أو نسخة قاعدة بيانات مخصصة للاختبار. وتستخدم اختبارات E2E قاعدة بيانات بيئة الاختبار.
س ما هي مستويات السجل في winston؟
ج من الأدنى إلى الأعلى: error → warn → info → http → verbose → debug → silly. عند تعيين المستوى إلى info، يتم إخراج مستوى info وما فوقه فقط.
س كيف يمكن تصحيح أخطاء اختبارات Jest نفسها؟
ج شغّل node --inspect-brk node_modules/.bin/jest --runInBand ثم صحح الأخطاء في Chrome DevTools. يضمن --runInBand تشغيل Jest على خيط واحد حتى تعمل نقاط التوقف.
س هل يحتاج Supertest إلى تشغيل خادم حقيقي؟
ج لا. ينشئ Supertest خادمًا في الذاكرة عبر http.createServer(app) — لا يتم شغل أي منفذ، ويُغلق تلقائيًا عند انتهاء الاختبارات.

📖 ملخص


📝 تمارين

  1. اكتب اختبارات وحدة باستخدام Jest للدالة التالية: function isPalindrome(str) { return str === str.split('').reverse().join(''); }، بحيث تغطي المتناظرات العادية، وغير المتناظرة، والسلاسل الفارغة، وحالات الأحرف المختلطة.
  2. أنشئ واجهة برمجة تطبيقات Express (عمليات CRUD للمسار /api/books)، واكتب 5 اختبارات تكامل على الأقل باستخدام Supertest (إنشاء، قراءة، تحديث، حذف، 404).
  3. أضف برنامجًا وسيطًا للتسجيل باستخدام winston إلى تطبيق Express قائم، بحيث يسجل method/url/status/duration لكل طلب، مع دورة يومية للسجلات.
  4. استخدم TDD لتطوير دالة formatCurrency(amount, currency): اكتب أولاً اختبارًا يفشل (مثل formatCurrency(1234.5, 'USD')'$1,234.50')، ثم اكتب التنفيذ.
  5. شغّل jest --coverage، وابحث عن الملفات ذات التغطية الأقل من 80%، وأضف حالات اختبار لتحقيق هدف التغطية.

Web-Tutorial.com

فريق Web-Tutorial التقني

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

100%