استخدام أداة تشغيل الاختبارات في Node.js
تتميز Node.js بأداة تشغيل اختبارات مدمجة مرنة وقوية. سيوضح لك هذا الدليل كيفية إعدادها واستخدامها.
example/
├ …
├ src/
├ app/…
└ sw/…
└ test/
├ globals/
├ …
├ IndexedDb.js
└ ServiceWorkerGlobalScope.js
├ setup.mjs
├ setup.units.mjs
└ setup.ui.mjs
npm init -y
npm install --save-dev concurrently
{
"name": "example",
"scripts": {
"test": "concurrently --kill-others-on-fail --prefix none npm:test:*",
"test:sw": "node --import ./test/setup.sw.mjs --test './src/sw/**/*.spec.*'",
"test:units": "node --import ./test/setup.units.mjs --test './src/app/**/*.spec.*'",
"test:ui": "node --import ./test/setup.ui.mjs --test './src/app/**/*.test.*'"
}
}
ملاحظة
تتطلب العبارات النمطية Node v21+، ويجب أن تكون العبارات النمطية نفسها محاطة بعلامات اقتباس (بدون ذلك، ستحصل على سلوك مختلف عما هو متوقع، حيث قد يبدو في البداية أنه يعمل ولكنه ليس كذلك).
هناك بعض الأشياء التي تريدها دائمًا، لذا ضعها في ملف إعداد أساسي مثل التالي. سيتم استيراد هذا الملف بواسطة إعدادات أخرى أكثر تفصيلاً.
الإعداد العام
import { register } from 'node:module';
register('some-typescript-loader');
// يتم دعم TypeScript من هنا فصاعدًا
// لكن ملفات test/setup.*.mjs الأخرى يجب أن تكون نصًا عاديًا بلغة JavaScript!
ثم لكل إعداد، قم بإنشاء ملف setup
مخصص (مع التأكد من استيراد ملف setup.mjs
الأساسي ضمن كل ملف). هناك عدد من الأسباب لعزل الإعدادات، ولكن السبب الأكثر وضوحًا هو YAGNI + الأداء: الكثير مما قد تقوم بإعداده هو محاكيات/بدائل خاصة بالبيئة، والتي يمكن أن تكون مكلفة للغاية وستؤدي إلى إبطاء تشغيل الاختبارات. تريد تجنب هذه التكاليف (المال الحرفي الذي تدفعه إلى CI، والوقت الذي تنتظره حتى ينتهي الاختبار، وما إلى ذلك) عندما لا تحتاج إليها.
تم أخذ كل مثال أدناه من مشاريع حقيقية؛ قد لا تكون مناسبة/قابلة للتطبيق على مشروعك، لكن كل منها يُظهر مفاهيم عامة قابلة للتطبيق على نطاق واسع.
اختبارات ServiceWorker
يحتوي ServiceWorkerGlobalScope
على واجهات برمجة تطبيقات محددة للغاية غير موجودة في بيئات أخرى، وبعض واجهات برمجة التطبيقات الخاصة به تبدو مشابهة للآخرين (مثل fetch
) ولكن لها سلوك مُعزز. لا ترغب في أن تتسرب هذه إلى اختبارات غير ذات صلة.
import { beforeEach } from 'node:test';
import { ServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';
import './setup.mjs'; // 💡
beforeEach(globalSWBeforeEach);
function globalSWBeforeEach() {
globalThis.self = new ServiceWorkerGlobalScope();
}
import assert from 'node:assert/strict';
import { describe, mock, it } from 'node:test';
import { onActivate } from './onActivate.js';
describe('ServiceWorker::onActivate()', () => {
const globalSelf = globalThis.self;
const claim = mock.fn(async function mock__claim() {});
const matchAll = mock.fn(async function mock__matchAll() {});
class ActivateEvent extends Event {
constructor(...args) {
super('activate', ...args);
}
}
before(() => {
globalThis.self = {
clients: { claim, matchAll },
};
});
after(() => {
global.self = globalSelf;
});
it('should claim all clients', async () => {
await onActivate(new ActivateEvent());
assert.equal(claim.mock.callCount(), 1);
assert.equal(matchAll.mock.callCount(), 1);
});
});
اختبارات اللقطة
اشتهرت هذه الاختبارات بفضل Jest؛ والآن، تقوم العديد من المكتبات بتنفيذ مثل هذه الوظائف، بما في ذلك Node.js اعتبارًا من الإصدار 22.3.0. هناك العديد من حالات الاستخدام مثل التحقق من مخرجات عرض المكونات و تكوين البنية التحتية كرمز. المفهوم هو نفسه بغض النظر عن حالة الاستخدام.
لا يوجد تكوين محدد مطلوب باستثناء تمكين الميزة عبر --experimental-test-snapshots
. ولكن لإظهار التكوين الاختياري، ربما تضيف شيئًا مثل ما يلي إلى أحد ملفات تكوين الاختبار الحالية لديك.
بشكل افتراضي، يقوم node بإنشاء اسم ملف غير متوافق مع الكشف عن تمييز بناء الجملة: .js.snapshot
. الملف المُنشأ هو في الواقع ملف CJS، لذلك فإن اسم الملف الأنسب سينتهي بـ .snapshot.cjs
(أو بشكل أكثر إيجازًا .snap.cjs
كما هو موضح أدناه)؛ سيتعامل هذا أيضًا بشكل أفضل في مشاريع ESM.
import { basename, dirname, extname, join } from 'node:path';
import { snapshot } from 'node:test';
snapshot.setResolveSnapshotPath(generateSnapshotPath);
/**
* @param {string} testFilePath '/tmp/foo.test.js'
* @returns {string} '/tmp/foo.test.snap.cjs'
*/
function generateSnapshotPath(testFilePath) {
const ext = extname(testFilePath);
const filename = basename(testFilePath, ext);
const base = dirname(testFilePath);
return join(base, `${filename}.snap.cjs`);
}
يوضح المثال أدناه اختبار اللقطة مع مكتبة الاختبار لمكونات واجهة المستخدم؛ لاحظ الطريقتين المختلفين للوصول إلى assert.snapshot
:
import { describe, it } from 'node:test';
import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // أي إطار عمل (مثل svelte)
import { SomeComponent } from './SomeComponent.jsx';
describe('<SomeComponent>', () => {
// للأشخاص الذين يفضلون بناء جملة "السهم الدهني"، ربما يكون ما يلي أفضل للتناسق
it('should render defaults when no props are provided', (t) => {
const component = render(<SomeComponent />).container.firstChild;
t.assert.snapshot(prettyDOM(component));
});
it('should consume `foo` when provided', function() {
const component = render(<SomeComponent foo="bar" />).container.firstChild;
this.assert.snapshot(prettyDOM(component));
// `this` يعمل فقط عندما يتم استخدام `function` (وليس "السهم الدهني").
});
});
WARNING
تأتي assert.snapshot
من سياق الاختبار (t أو this)، وليس node:assert
. هذا ضروري لأن سياق الاختبار لديه حق الوصول إلى نطاق مستحيل بالنسبة إلى node:assert
(سيتعين عليك توفيره يدويًا في كل مرة يتم استخدام assert.snapshot
، مثل snapshot (this, value)
، وهو أمر ممل إلى حد ما).
اختبارات الوحدة
اختبارات الوحدة هي أبسط الاختبارات، ولا تتطلب عمومًا أي شيء خاص نسبيًا. من المرجح أن تكون الغالبية العظمى من اختباراتك اختبارات وحدة، لذلك من المهم الحفاظ على هذا الإعداد بسيطًا لأن انخفاضًا صغيرًا في أداء الإعداد سيتضخم ويتسلسل.
import { register } from 'node:module';
import './setup.mjs'; // 💡
register('some-plaintext-loader');
// يمكن الآن استيراد ملفات نص عادي مثل graphql:
// import GET_ME from 'get-me.gql'; GET_ME = '
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { Cat } from './Cat.js';
import { Fish } from './Fish.js';
import { Plastic } from './Plastic.js';
describe('Cat', () => {
it('should eat fish', () => {
const cat = new Cat();
const fish = new Fish();
assert.doesNotThrow(() => cat.eat(fish));
});
it('should NOT eat plastic', () => {
const cat = new Cat();
const plastic = new Plastic();
assert.throws(() => cat.eat(plastic));
});
});
اختبارات واجهة المستخدم
تتطلب اختبارات واجهة المستخدم عمومًا DOM، وربما واجهات برمجة تطبيقات أخرى خاصة بالمتصفح (مثل IndexedDb
المستخدمة أدناه). تميل هذه الاختبارات إلى أن تكون معقدة للغاية ومكلفة في الإعداد.
إذا كنت تستخدم واجهة برمجة تطبيقات مثل IndexedDb
ولكنها معزولة جدًا، فقد لا يكون النموذج المُحاكى العام أدناه هو الخيار المناسب. بدلاً من ذلك، ربما قم بنقل هذا beforeEach
إلى الاختبار المحدد حيث سيتم الوصول إلى IndexedDb
. لاحظ أنه إذا تم الوصول على نطاق واسع إلى الوحدة التي تصل إلى IndexedDb
(أو أي شيء آخر)، فقم إما بمحاكاة تلك الوحدة (ربما الخيار الأفضل)، أو احتفظ بها هنا.
import { register } from 'node:module';
// ⚠️ تأكد من إنشاء مثيل واحد فقط من JSDom؛ ستؤدي عمليات إنشاء متعددة إلى العديد من 🤬
import jsdom from 'global-jsdom';
import './setup.units.mjs'; // 💡
import { IndexedDb } from './globals/IndexedDb.js';
register('some-css-modules-loader');
jsdom(undefined, {
url: 'https://test.example.com', // ⚠️ من المرجح أن يؤدي عدم تحديد هذا إلى العديد من 🤬
});
// مثال لكيفية تزيين متغير عام.
// لا يتعامل `history` الخاص بـ JSDOM مع التنقل؛ يتعامل ما يلي مع معظم الحالات.
const pushState = globalThis.history.pushState.bind(globalThis.history);
globalThis.history.pushState = function mock_pushState(data, unused, url) {
pushState(data, unused, url);
globalThis.location.assign(url);
};
beforeEach(globalUIBeforeEach);
function globalUIBeforeEach() {
globalThis.indexedDb = new IndexedDb();
}
يمكنك أن يكون لديك مستويان مختلفان من اختبارات واجهة المستخدم: مستوى يشبه الوحدة (حيث يتم محاكاة العناصر الخارجية والاعتماديات) ومستوى أكثر شمولاً (حيث يتم محاكاة العناصر الخارجية فقط مثل IndexedDb ولكن بقية السلسلة حقيقية). الخيار الأول هو الخيار الأنقى عمومًا، ويتم تأجيل الأخير عمومًا إلى اختبار قابلية استخدام أوتوماتيكي شامل تمامًا عبر شيء مثل Playwright أو Puppeteer. فيما يلي مثال على الأول.
import { before, describe, mock, it } from 'node:test';
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; // أي إطار عمل (مثال svelte)
// ⚠️ لاحظ أن SomeOtherComponent ليس استيرادًا ثابتًا؛
// هذا ضروري لتسهيل محاكاة استيراداته الخاصة.
describe('<SomeOtherComponent>', () => {
let SomeOtherComponent;
let calcSomeValue;
before(async () => {
// ⚠️ تسلسل الأمور: يجب إعداد المحاكاة قبل استيراد مستهلكها.
// يتطلب تعيين `--experimental-test-module-mocks`.
calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });
({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
});
describe('when calcSomeValue fails', () => {
// هذا لا ترغب في التعامل معه باستخدام لقطة لأن ذلك سيكون هشًا:
// عندما تتم إجراء تحديثات غير ذات أهمية لرسالة الخطأ،
// سيفشل اختبار اللقطة بشكل خاطئ
// (وسيحتاج إلى تحديث اللقطة بدون قيمة حقيقية).
it('should fail gracefully by displaying a pretty error', () => {
calcSomeValue.mockImplementation(function mock__calcSomeValue() { return null });
render(<SomeOtherComponent>);
const errorMessage = screen.queryByText('unable');
assert.ok(errorMessage);
});
});
});