اختبار تطبيقات Node.js
الاختبار جزء أساسي من بناء تطبيقات Node.js موثوقة وقابلة للصيانة. في هذا الدرس، سنستكشف أطر الاختبار والأدوات الأكثر شيوعاً في نظام Node.js البيئي، بما في ذلك Mocha وChai وSupertest وSinon. ستتعلم كيفية كتابة اختبارات الوحدة واختبارات التكامل واختبارات API للتأكد من أن الكود يعمل كما هو متوقع.
لماذا يهم الاختبار
يوفر الاختبار فوائد مهمة عديدة:
- الثقة في التغييرات: تساعدك الاختبارات على إعادة الهيكلة وإضافة ميزات دون كسر الوظائف الموجودة
- التوثيق: تعمل الاختبارات كوثائق حية لكيفية عمل الكود
- منع الأخطاء: اكتشاف الأخطاء مبكراً قبل وصولها إلى الإنتاج
- تصميم أفضل: كتابة كود قابل للاختبار غالباً ما تؤدي إلى بنية أفضل
- تطوير أسرع: الاختبارات الآلية أسرع من الاختبار اليدوي
ملاحظة: تطور نظام الاختبار في Node.js بشكل كبير. بينما لا تزال Mocha وChai شائعة، يتضمن Node.js الآن مشغل اختبار مدمج (منذ الإصدار 18)، وبدائل مثل Jest وVitest منتشرة أيضاً.
إعداد بيئة الاختبار
أولاً، لنقم بتثبيت تبعيات الاختبار اللازمة:
npm install --save-dev mocha chai supertest sinon
قم بتحديث package.json لإضافة سكربتات الاختبار:
{
"scripts": {
"test": "mocha --exit",
"test:watch": "mocha --watch --exit",
"test:coverage": "nyc mocha --exit"
}
}
فهم Mocha
Mocha هو إطار اختبار مرن يوفر البنية لتنظيم وتشغيل الاختبارات. يستخدم بناء جملة describe/it:
// test/math.test.js
const assert = require('assert');
describe('Math Operations', function() {
describe('Addition', function() {
it('should add two positive numbers', function() {
assert.strictEqual(2 + 2, 4);
});
it('should add negative numbers', function() {
assert.strictEqual(-5 + 3, -2);
});
it('should handle zero', function() {
assert.strictEqual(0 + 0, 0);
});
});
describe('Multiplication', function() {
it('should multiply two numbers', function() {
assert.strictEqual(3 * 4, 12);
});
});
});
قم بتشغيل الاختبارات باستخدام:
npm test
استخدام Chai لتأكيدات أفضل
توفر Chai تأكيدات أكثر قابلية للقراءة والتعبير من وحدة assert المدمجة في Node. تقدم ثلاثة أنماط من التأكيدات: expect وshould وassert.
// test/user.test.js
const { expect } = require('chai');
describe('User Validation', function() {
it('should validate email format', function() {
const email = 'user@example.com';
expect(email).to.be.a('string');
expect(email).to.include('@');
expect(email).to.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});
it('should validate password length', function() {
const password = 'securePassword123';
expect(password).to.have.lengthOf.at.least(8);
expect(password).to.not.be.empty;
});
it('should validate user object structure', function() {
const user = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
roles: ['user', 'admin']
};
expect(user).to.be.an('object');
expect(user).to.have.property('id');
expect(user).to.have.property('email').that.is.a('string');
expect(user.roles).to.be.an('array').that.includes('admin');
});
});
نصيحة: بناء جملة expect في Chai هو الأكثر شيوعاً لأنه قابل للقراءة ولا يعدل النماذج المدمجة (على عكس نمط should). استخدم expect لمعظم سيناريوهات الاختبار.
اختبار الكود غير المتزامن
Node.js غير متزامن بطبيعته، لذا فإن اختبار الكود غير المتزامن أمر أساسي. تدعم Mocha callbacks والوعود وasync/await:
const { expect } = require('chai');
// الاختبار باستخدام callbacks
describe('Async Operations', function() {
it('should handle callbacks', function(done) {
setTimeout(() => {
expect(true).to.be.true;
done(); // إشارة إلى اكتمال الاختبار
}, 100);
});
// الاختبار باستخدام الوعود
it('should handle promises', function() {
return Promise.resolve(42)
.then(result => {
expect(result).to.equal(42);
});
});
// الاختبار باستخدام async/await (موصى به)
it('should handle async/await', async function() {
const result = await Promise.resolve(42);
expect(result).to.equal(42);
});
// اختبار رفض الوعود
it('should handle promise rejection', async function() {
try {
await Promise.reject(new Error('Failed'));
expect.fail('Should have thrown');
} catch (error) {
expect(error.message).to.equal('Failed');
}
});
});
تحذير: عند اختبار callbacks، قم دائماً باستدعاء done(). إذا نسيت، سينتهي وقت الاختبار. بالنسبة للوعود وasync/await، تتعامل Mocha مع الاكتمال تلقائياً.
اختبار Express APIs باستخدام Supertest
Supertest مصمم خصيصاً لاختبار خوادم HTTP. يجعل اختبار API نظيفاً وبسيطاً:
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
res.status(201).json({ id: 3, name, email });
});
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
if (id === 1) {
return res.json({ id: 1, name: 'Alice', email: 'alice@example.com' });
}
res.status(404).json({ error: 'User not found' });
});
module.exports = app;
// test/api.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
describe('User API', function() {
describe('GET /api/users', function() {
it('should return all users', async function() {
const response = await request(app)
.get('/api/users')
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).to.be.an('array');
expect(response.body).to.have.lengthOf(2);
expect(response.body[0]).to.have.property('name', 'Alice');
});
});
describe('POST /api/users', function() {
it('should create a new user', async function() {
const newUser = {
name: 'Charlie',
email: 'charlie@example.com'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201)
.expect('Content-Type', /json/);
expect(response.body).to.have.property('id');
expect(response.body).to.have.property('name', 'Charlie');
expect(response.body).to.have.property('email', 'charlie@example.com');
});
it('should reject invalid data', async function() {
const response = await request(app)
.post('/api/users')
.send({ name: 'Charlie' }) // البريد الإلكتروني مفقود
.expect(400);
expect(response.body).to.have.property('error');
});
});
describe('GET /api/users/:id', function() {
it('should return a specific user', async function() {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).to.have.property('id', 1);
expect(response.body).to.have.property('name', 'Alice');
});
it('should return 404 for non-existent user', async function() {
await request(app)
.get('/api/users/999')
.expect(404);
});
});
});
المحاكاة باستخدام Sinon
توفر Sinon أدوات لإنشاء بدائل الاختبار: الجواسيس والبدائل والمحاكيات. تساعدك هذه على اختبار الكود بشكل منفصل دون تبعيات.
// services/emailService.js
const sendEmail = async (to, subject, body) => {
// في الواقع، سيتصل هذا بخدمة البريد الإلكتروني
console.log(`Sending email to ${to}`);
return { success: true, messageId: '12345' };
};
module.exports = { sendEmail };
// services/userService.js
const emailService = require('./emailService');
const createUser = async (userData) => {
// حفظ المستخدم في قاعدة البيانات (مبسط)
const user = { id: 1, ...userData };
// إرسال بريد إلكتروني ترحيبي
await emailService.sendEmail(
userData.email,
'Welcome!',
`Hello ${userData.name}, welcome to our platform!`
);
return user;
};
module.exports = { createUser };
// test/userService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const userService = require('../services/userService');
const emailService = require('../services/emailService');
describe('User Service', function() {
describe('createUser', function() {
let emailStub;
beforeEach(function() {
// إنشاء بديل لـ sendEmail قبل كل اختبار
emailStub = sinon.stub(emailService, 'sendEmail');
emailStub.resolves({ success: true, messageId: 'abc123' });
});
afterEach(function() {
// استعادة الوظيفة الأصلية بعد كل اختبار
emailStub.restore();
});
it('should create a user and send welcome email', async function() {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};
const user = await userService.createUser(userData);
expect(user).to.have.property('id');
expect(user).to.have.property('name', 'John Doe');
// التحقق من إرسال البريد الإلكتروني
expect(emailStub.calledOnce).to.be.true;
expect(emailStub.calledWith(
'john@example.com',
'Welcome!',
sinon.match.string
)).to.be.true;
});
it('should handle email sending failures', async function() {
emailStub.rejects(new Error('Email service unavailable'));
const userData = {
name: 'Jane Smith',
email: 'jane@example.com'
};
try {
await userService.createUser(userData);
expect.fail('Should have thrown');
} catch (error) {
expect(error.message).to.equal('Email service unavailable');
}
});
});
});
استخدام الجواسيس لمراقبة استدعاءات الدوال
تتيح لك الجواسيس تتبع استدعاءات الدوال دون تغيير سلوكها:
const { expect } = require('chai');
const sinon = require('sinon');
describe('Callback Spy Example', function() {
it('should track callback invocations', function() {
const callback = sinon.spy();
const processData = (data, cb) => {
data.forEach(item => cb(item));
};
processData([1, 2, 3], callback);
expect(callback.callCount).to.equal(3);
expect(callback.firstCall.args[0]).to.equal(1);
expect(callback.secondCall.args[0]).to.equal(2);
expect(callback.thirdCall.args[0]).to.equal(3);
});
});
خطافات الاختبار: before، after، beforeEach، afterEach
توفر Mocha خطافات لإعداد وتفكيك شروط الاختبار:
const { expect } = require('chai');
describe('Test Hooks Example', function() {
let database;
// يعمل مرة واحدة قبل جميع الاختبارات في هذا الكتلة
before(function() {
console.log('Connecting to database...');
database = { connected: true };
});
// يعمل مرة واحدة بعد جميع الاختبارات في هذا الكتلة
after(function() {
console.log('Closing database connection...');
database = null;
});
// يعمل قبل كل اختبار في هذا الكتلة
beforeEach(function() {
console.log('Setting up test data...');
database.testData = { users: [] };
});
// يعمل بعد كل اختبار في هذا الكتلة
afterEach(function() {
console.log('Cleaning up test data...');
delete database.testData;
});
it('should have clean test data', function() {
expect(database.testData.users).to.be.empty;
database.testData.users.push({ id: 1 });
});
it('should have fresh test data again', function() {
// تم تنظيف testData وإعادة إنشائها
expect(database.testData.users).to.be.empty;
});
});
اختبار Middleware
يمكن اختبار Express middleware بشكل منفصل:
// middleware/auth.js
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
if (token !== 'Bearer valid-token') {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = { id: 1, name: 'Test User' };
next();
};
module.exports = authMiddleware;
// test/middleware/auth.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const authMiddleware = require('../../middleware/auth');
describe('Auth Middleware', function() {
let req, res, next;
beforeEach(function() {
req = {
headers: {}
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub()
};
next = sinon.spy();
});
it('should reject requests without token', function() {
authMiddleware(req, res, next);
expect(res.status.calledWith(401)).to.be.true;
expect(res.json.calledWith({ error: 'No token provided' })).to.be.true;
expect(next.called).to.be.false;
});
it('should reject invalid tokens', function() {
req.headers.authorization = 'Bearer invalid-token';
authMiddleware(req, res, next);
expect(res.status.calledWith(403)).to.be.true;
expect(next.called).to.be.false;
});
it('should allow valid tokens', function() {
req.headers.authorization = 'Bearer valid-token';
authMiddleware(req, res, next);
expect(req.user).to.deep.equal({ id: 1, name: 'Test User' });
expect(next.calledOnce).to.be.true;
expect(res.status.called).to.be.false;
});
});
تغطية الكود باستخدام NYC
قم بتثبيت NYC (واجهة سطر الأوامر لـ Istanbul) لقياس تغطية الكود:
npm install --save-dev nyc
أضف تكوين التغطية إلى package.json:
{
"nyc": {
"reporter": ["text", "html", "lcov"],
"exclude": ["test/**", "node_modules/**"],
"all": true
}
}
قم بتشغيل الاختبارات مع التغطية:
npm run test:coverage
سينشئ NYC تقريراً يعرض أي أسطر من الكود مغطاة بالاختبارات.
نصيحة: اهدف إلى ما لا يقل عن 80٪ تغطية للكود، لكن لا تهتم بنسبة 100٪. ركز على اختبار منطق الأعمال الحرجة والحالات الحدية بدلاً من مطاردة أرقام التغطية.
أفضل الممارسات للاختبار
- اختبار السلوك، وليس التنفيذ: اختبر ما يفعله الكود، وليس كيف يفعله
- تأكيد واحد لكل اختبار: حافظ على الاختبارات مركزة على اهتمام واحد
- أسماء اختبار وصفية: استخدم أوصاف اختبار واضحة وذات مغزى
- ترتيب-تنفيذ-تأكيد: هيكل الاختبارات مع مراحل الإعداد والتنفيذ والتحقق
- اختبارات مستقلة: يجب أن يعمل كل اختبار بشكل مستقل دون الاعتماد على اختبارات أخرى
- اختبارات سريعة: حافظ على الاختبارات سريعة من خلال محاكاة التبعيات الخارجية
- DRY (لا تكرر نفسك): استخدم الخطافات والدوال المساعدة لتقليل التكرار
تمرين: بناء مجموعة اختبار كاملة
أنشئ API بسيط للمدونة واكتب اختبارات شاملة:
- أنشئ نموذج Post مع حقول title وcontent وauthor وcreatedAt
- ابنِ مسارات Express لعمليات CRUD (GET all وGET by ID وPOST وPUT وDELETE)
- نفذ middleware للتحقق من صحة المدخلات
- اكتب اختبارات وحدة لمنطق التحقق
- اكتب اختبارات تكامل لجميع نقاط نهاية API باستخدام Supertest
- قم بمحاكاة عمليات قاعدة البيانات باستخدام بدائل Sinon
- حقق تغطية كود لا تقل عن 85٪
- أضف اختباراً للترقيم في نقطة نهاية GET all posts
إضافي: قم بإعداد التكامل المستمر مع GitHub Actions لتشغيل الاختبارات تلقائياً على كل دفع.