الاختبارات الآلية بـ node:test — Node.js و Express
الاختبارات الآلية بـ node:test
كل مرة تعدّل كوداً، تجرّبه يدوياً؟ مع 30 مساراً و10 حالات لكل منها، يصبح ذلك مستحيلاً — فتصل الأخطاء للمستخدمين. الاختبارات الآلية تجرّب كل شيء في ثوانٍ بعد كل تعديل.
مشغّل مدمج — بلا مكتبات
منذ Node 20، node:test مستقر ومدمج:
const { test, describe, it } = require("node:test");
const assert = require("node:assert/strict");
function slugify(text) {
return text.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
}
describe("slugify", () => {
it("يحوّل المسافات إلى شرطات", () => {
assert.equal(slugify("Hello World"), "hello-world");
});
it("يحافظ على الحروف العربية", () => {
assert.equal(slugify(" مرحبا يا عالم "), "مرحبا-يا-عالم");
});
it("يزيل الرموز من الأطراف", () => {
assert.equal(slugify("!!Node.js!!"), "node-js");
});
});node --test # يشغّل كل ملفات *.test.js و test/*.js
node --test --watch # يعيد التشغيل عند الحفظ
node --test --experimental-test-coverageأضف إلى package.json: "test": "node --test" ثم npm test.
assert: أهم الدوال
| الدالة | للتحقق من |
|---|---|
| assert.equal(a, b) | تساوٍ صارم (مع /strict) |
| assert.deepEqual(obj1, obj2) | كائنات ومصفوفات بالمحتوى |
| assert.ok(value) | قيمة صحيحة (truthy) |
| assert.throws(fn, /رسالة/) | دالة ترمي خطأ |
| await assert.rejects(promise, ErrorClass) | Promise يُرفض |
| assert.match(text, /نمط/) | نص يطابق نمطاً |
غير المتزامن
اجعل الاختبار async وانتظر — المشغّل ينتظر Promise:
const { test } = require("node:test");
const assert = require("node:assert/strict");
async function fetchUser(id) {
if (id <= 0) throw new RangeError("معرّف غير صالح");
return { id, name: "سارة" };
}
test("يعيد المستخدم", async () => {
assert.deepEqual(await fetchUser(1), { id: 1, name: "سارة" });
});
test("يرفض المعرّف السالب", async () => {
await assert.rejects(fetchUser(-1), RangeError);
});الدوال الوهمية (Mocks) والاعتماديات
اختبار يرسل بريداً حقيقياً أو يكتب في قاعدة الإنتاج ليس اختباراً بل كارثة. صمم الكود ليستقبل اعتمادياته (حقن الاعتماديات)، فتمرر نسخاً وهمية في الاختبار:
const { test, mock } = require("node:test");
const assert = require("node:assert/strict");
function makeSignup({ usersRepo, mailer }) {
return async (email) => {
if (await usersRepo.exists(email)) throw new Error("مستخدم موجود");
await usersRepo.create(email);
await mailer.send(email, "مرحباً بك");
};
}
test("التسجيل يرسل بريد ترحيب مرة واحدة", async () => {
const usersRepo = { exists: async () => false, create: mock.fn(async () => {}) };
const mailer = { send: mock.fn(async () => {}) };
await makeSignup({ usersRepo, mailer })("sara@example.com");
assert.equal(mailer.send.mock.callCount(), 1);
assert.deepEqual(mailer.send.mock.calls[0].arguments, ["sara@example.com", "مرحباً بك"]);
});ما الذي يُختبر؟
| النوع | ماذا | السرعة والعدد |
|---|---|---|
| وحدة (unit) | دالة أو خدمة معزولة عن الشبكة وقاعدة البيانات | سريعة جداً، كثيرة |
| تكامل (integration) | API مع قاعدة بيانات اختبار حقيقية | أبطأ، أقل |
| شامل (E2E) | التطبيق كاملاً كما يستخدمه المستخدم | الأبطأ، قليلة للمسارات الحرجة |
اختبر السلوك لا التفاصيل الداخلية: "التسجيل ببريد موجود يرفض" لا "الدالة استدعت المتغير x". واختبر حالات الحافة والفشل — هناك تختبئ الأخطاء: مدخلات فارغة، قيم سالبة، صلاحيات، تكرار.
التغطية: مؤشر لا هدف
تغطية 100% لا تعني خلو الكود من الأخطاء؛ تعني أن كل سطر نُفِّذ مرة. استخدمها لاكتشاف الأجزاء غير المختبرة إطلاقاً، لا كرقم للتباهي.
في كل مشروع
- شغّل الاختبارات في CI (GitHub Actions) مع كل Pull Request.
- عند إصلاح خطأ: اكتب أولاً اختباراً يفشل بسببه، ثم أصلحه — فلا يعود.
تمرين
اكتب اختبارات لدالة parsePagination(query) من درس الترقيم: القيم الافتراضية، القص عند limit كبير، page سالب، نصوص غير رقمية. ثم دالة transfer بمستودع وهمي: تحويل ناجح، رصيد غير كافٍ، مبلغ سالب.
// cart.test.js — شغّله بـ: node --test (أو node cart.test.js)
const { describe, it, beforeEach, mock } = require("node:test");
const assert = require("node:assert/strict");
// ===== الكود المُختبَر: سلة مشتريات تستقبل اعتمادياتها =====
function createCart({ pricing, clock = () => new Date() }) {
const items = new Map();
return {
add(sku, qty = 1) {
if (!Number.isInteger(qty) || qty < 1) throw new RangeError("كمية غير صالحة");
items.set(sku, (items.get(sku) ?? 0) + qty);
},
remove(sku) {
items.delete(sku);
},
async total(coupon) {
let sum = 0;
for (const [sku, qty] of items) sum += (await pricing.priceOf(sku)) * qty;
if (coupon === "RAMADAN" && clock().getMonth() === 2) sum *= 0.9; // خصم في مارس فقط
return Math.round(sum * 100) / 100;
},
count: () => [...items.values()].reduce((a, b) => a + b, 0),
};
}
// ===== الاختبارات =====
describe("سلة المشتريات", () => {
let pricing;
beforeEach(() => {
pricing = { priceOf: mock.fn(async (sku) => ({ PEN: 1.5, BAG: 25 })[sku] ?? 0) };
});
it("تجمع كميات نفس المنتج", () => {
const cart = createCart({ pricing });
cart.add("PEN", 2);
cart.add("PEN", 3);
assert.equal(cart.count(), 5);
});
it("ترفض الكميات غير الصالحة", () => {
const cart = createCart({ pricing });
assert.throws(() => cart.add("PEN", 0), RangeError);
assert.throws(() => cart.add("PEN", 1.5), /كمية غير صالحة/);
});
it("تحسب المجموع من خدمة الأسعار", async () => {
const cart = createCart({ pricing });
cart.add("PEN", 4);
cart.add("BAG");
assert.equal(await cart.total(), 31);
assert.equal(pricing.priceOf.mock.callCount(), 2);
});
it("تطبق الكوبون في الشهر الصحيح فقط (ساعة وهمية)", async () => {
const march = createCart({ pricing, clock: () => new Date("2026-03-10") });
const july = createCart({ pricing, clock: () => new Date("2026-07-10") });
march.add("BAG", 2);
july.add("BAG", 2);
assert.equal(await march.total("RAMADAN"), 45);
assert.equal(await july.total("RAMADAN"), 50);
});
it("الحذف يزيل المنتج كلياً", async () => {
const cart = createCart({ pricing });
cart.add("PEN", 2);
cart.remove("PEN");
assert.equal(await cart.total(), 0);
});
});اختبر فهمك