تطبيق صغير: من الصفر إلى اختبار أخضر
درس 6 من 12
6تطبيق صغير: من الصفر إلى اختبار أخضر
نبني واجهة برمجية صغيرة للمهام بلا أي حزمة خارجية، بترتيب يجعل عمل المساعد قابلًا للمراجعة، وفي النهاية نمسك باقتراح «أقصر وأجمل» يحذف المهمة الخطأ بصمت.
ماذا تشاهد في الرسم: تكتب المواصفة، ثم اختبارًا يفشل، ثم الكود الذي يجعله ينجح، فيبقى كل تعديل من المساعد قابلًا للمراجعة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
بناء تطبيق مع مساعد مثل بناء بيت مع مقاول سريع: تطلب غرفة واحدة، تفحصها، ثم الغرفة التالية. لو طلبت البيت كله دفعة واحدة لن تعرف أين الخلل. وفي النهاية تكتشف أن «اختصارًا جميلًا» اقترحه يحذف شيئًا خطأ دون أن يقول.
تبني واجهة مهام صغيرة بمكتبة بايثون القياسية خطوة خطوة: اختبار أولًا ثم تنفيذ، وكل خطوة بفرق صغير قابل للمراجعة. الاختبار الأخضر يثبت السلوك، ومراجعة الاقتراح الأخير تكشف حذفًا بالفهرس الخطأ لم تلتقطه الاختبارات الموجودة.
المتطلبات قبل البداية
الأجزاء ٣–٥
محادثة ووكيل وملف قواعد. هذا الجزء يستخدم الثلاثة معًا على مشروع واحد.
Node حديث
نستخدم http المدمج وfetch المدمج، بلا تثبيت أي حزمة. تأكد بـnode -v.
نتيجة الجزء الرابع
أن «ينجح التشغيل» ليس «يصحّ الكود». سنعتمد على هذا في كل خطوة.
git مهيّأ
git init && git commit قبل أن تبدأ. ستشغّل وكيلًا على مشروع حقيقي؛ هذا هو زرّ التراجع.
المفاهيم الأساسية
العقد، ثم الاختبار، ثم الكود، لا العكس. المساعد ممتاز في كتابة كود يحقّق عقدًا واضحًا، وسيّئ جدًا في تخمين عقد لم تكتبه.
store.js فيه كل القرارات، وserver.js فيه النقل فقط. هذا الفصل ليس أناقة معمارية: هو ما يجعل الاختبار سريعًا بلا شبكة، ويجعل مراجعة عمل المساعد ممكنة لأن كل قرار في مكان واحد.
القيمة كلها في الحدود: نص فارغ، مُعرّف غير موجود، نوع خاطئ، حذف مرتين. المساعد يكتب المسار السعيد بامتياز؛ والحدود هي عملك أنت لأنها تعبّر عن نيّتك.
أنت تملك: العقد، والحدود، ومراجعة الـdiff، وقرار الدمج. المساعد يملك: الطباعة السريعة، والصيغ المتكرّرة، والشرح. لا تبدّل الأدوار.
لا تقل «تمّ» بلا مخرجات حقيقية ملصوقة. كل سطر مخرجات في هذا الجزء شُغِّل فعلًا، وهذا هو المعيار الذي نطلبه منك ومن مساعدك.
كيف يعمل؟
شكل 1، ترتيب البناء: الاختبار قبل الكود، والمراجعة في النهاية لا في البداية
شكل 2، شكل المشروع: السهام المتقطّعة اختبارات، واحد يلمس المنطق مباشرة وواحد يمرّ بالشبكة
store.spec.js يختبر القواعد في أجزاء من الثانية بلا منفذ ولا شبكة، فتشغّله بعد كل تعديل. وhttp.spec.js يختبر التوصيل: الرموز والمسارات وصيغة JSON. خطأ في الأول يعني أن فهمك للمشكلة خاطئ؛ وخطأ في الثاني يعني أن التوصيل خاطئ، والتمييز بينهما يوفّر عليك ساعات.المشروع كاملًا، بمخرجات حقيقية
المشروع: واجهة برمجية صغيرة للمهام. GET /todos تُرجع القائمة، POST /todos تضيف، PATCH /todos/:id تقلب الحالة، DELETE /todos/:id تحذف. بلا حزم، بلا قاعدة بيانات.
id وtitle وdone. العنوان نص غير فارغ بعد الاقتضاب، والمعرّف رقم يبدأ من ١ ولا يتكرّر.» هذا هو الملف الذي ستُرفقه بـ@.store.spec.js يتحقّق من العقد أعلاه، بما فيه الحدود: عنوان فارغ، نوع خاطئ، مُعرّف غير موجود.» الاختبار قبل الكود يجبر المساعد على الالتزام بنيّتك.store.js غير موجود، فالاختبار يفشل بـ«لا يمكن العثور على الوحدة». هذا الأحمر الأول هو دليلك أن الاختبار يعمل.store.js يجعل store.spec.js أخضر، بلا أي حزمة خارجية.»server.js يعرّض العقد عبر HTTP، ولا تضع أي قرار فيه، كل شيء يمرّ بـstore.js.»هذا هو النطاق كاملًا. لاحظ أنه لا يعرف شيئًا عن HTTP:
// store.js - the whole domain, with no HTTP in it
let nextId = 1;
let items = [];
function reset() { nextId = 1; items = []; }
function list() { return items.slice(); }
function add(title) {
if (typeof title !== "string" || title.trim() === "") {
throw new TypeError("title must be a non-empty string");
}
const item = { id: nextId++, title: title.trim(), done: false };
items.push(item);
return item;
}
function toggle(id) {
const item = items.find((t) => t.id === id);
if (!item) return null;
item.done = !item.done;
return item;
}
function remove(id) {
const before = items.length;
items = items.filter((t) => t.id !== id);
return items.length < before;
}
module.exports = { reset, list, add, toggle, remove };والاختبار الذي كُتب قبله (مختصرًا):
// store.spec.js - no server, no network: the rules of the app
const assert = require("assert");
const store = require("./store");
check("a new store is empty", () => assert.deepStrictEqual(store.list(), []));
check("add returns the created item with id 1", () =>
assert.deepStrictEqual(store.add("buy milk"), { id: 1, title: "buy milk", done: false }));
check("titles are trimmed", () =>
assert.strictEqual(store.add(" spaced ").title, "spaced"));
check("an empty title is rejected", () => assert.throws(() => store.add(" "), TypeError));
check("toggle flips done, twice returns it", () => {
const t = store.add("x");
assert.strictEqual(store.toggle(t.id).done, true);
assert.strictEqual(store.toggle(t.id).done, false);
});
check("remove of a missing id returns false", () => assert.strictEqual(store.remove(999), false));
check("list returns a copy, not the live array", () => {
store.add("a"); const copy = store.list(); copy.push({ id: 99 });
assert.strictEqual(store.list().length, 1);
});$ node store.spec.js PASS a new store is empty PASS add returns the created item with id 1 PASS ids increase and do not repeat PASS titles are trimmed PASS an empty title is rejected PASS a non-string title is rejected PASS toggle flips done, twice returns it PASS toggle of a missing id returns null PASS remove deletes exactly one item PASS remove of a missing id returns false PASS list returns a copy, not the live array 11/11 checks passed
ثم طبقة النقل، قرارات صفر، توصيل فقط:
// server.js - HTTP only; every decision lives in store.js
const http = require("http");
const store = require("./store");
const server = http.createServer(async (req, res) => {
const { method } = req;
const path = req.url.split("?")[0];
const idMatch = /^\/todos\/(\d+)$/.exec(path);
if (method === "GET" && path === "/todos") return json(res, 200, store.list());
if (method === "POST" && path === "/todos") {
const body = await readBody(req);
try { return json(res, 201, store.add(body.title)); }
catch (e) { return json(res, 400, { error: e.message }); }
}
if (method === "PATCH" && idMatch) {
const item = store.toggle(Number(idMatch[1]));
return item ? json(res, 200, item) : json(res, 404, { error: "no such todo" });
}
if (method === "DELETE" && idMatch) {
return store.remove(Number(idMatch[1]))
? json(res, 204, null)
: json(res, 404, { error: "no such todo" });
}
return json(res, 404, { error: "unknown route" });
});$ node http.spec.js PASS GET /todos starts empty PASS POST /todos returns 201 and the item PASS POST with an empty title returns 400 PASS PATCH /todos/1 toggles done PASS PATCH a missing id returns 404 PASS DELETE /todos/1 returns 204 and empties the list PASS an unknown route returns 404 7/7 checks passed
list() تُرجع نسخة لا المصفوفة الحيّة (وإلا عدّلها أي أحد من الخارج)، وأن toggle مرتين يرجع للحالة الأولى، وأن حذف مُعرّف غير موجود يُرجع false لا true. الثالث سينقذ المشروع بعد قليل.وهنا جاء الاقتراح الجميل الخطر
طلبنا «اختصر remove». الاقتراح كان أقصر فعلًا وأكثر شيوعًا في الكود المكتوب حول العالم:
// the suggestion: "shorter and more idiomatic"
function remove(id) {
items.splice(items.findIndex((t) => t.id === id), 1);
return true;
}يبدو سليمًا. وهو كذلك، إلا في حالة واحدة. findIndex تُرجع -1 عند عدم الإيجاد، وsplice(-1, 1) تعني «ابدأ من آخر عنصر»:
$ node -e '...add two tasks, then remove a missing id...' before [ 'keep me', 'and me' ] remove(999) true after [ 'keep me' ] <-- "and me" is gone
حذف المهمة الخطأ، وأعاد true كأن العملية نجحت. ولولا سطر واحد في الاختبار لمرّ هذا إلى الإنتاج:
$ node store.spec.js FAIL remove of a missing id returns false -> Expected values to be strictly equal: true !== false 10/11 checks passed $ echo $? 1
اعتقاد شائع × الحقيقة
| الاعتقاد | الحقيقة |
|---|---|
| «الاختبار قبل الكود يبطّئني.» | هو أسرع طريقة لجعل المساعد يكتب ما تريده. بلا اختبار ستشرح نيّتك بالكلام، مرّة بعد مرّة. |
| «المسار السعيد يعمل، إذًا انتهينا.» | كل أخطاء هذا الجزء كانت على الحدود: مُعرّف غير موجود، عنوان فارغ، حذف مكرّر. |
| «الكود الأقصر أفضل.» | splice/findIndex أقصر من filter، ويحذف المهمة الخطأ. القصر صفة لا دليل. |
| «المساعد يعرف الحالات الحدّية.» | يعرف الشائع منها فقط. حدود مشروعك تعبّر عن نيّتك، وهي لا تُستنتج من الكود. |
| «فصل المنطق عن HTTP ترف معماري.» | هو ما جعل ١١ تحقّقًا تعمل في أجزاء من الثانية بلا منفذ، وهذا ما يجعلك تشغّلها فعلًا. |
جرّب بنفسك
لماذا أعاد remove(999) القيمة true في النسخة المعطوبة؟
ثلاث مشكلات في سطرين: نتيجة findIndex لم تُفحص، و-1 تعني «من النهاية»، وقيمة الإرجاع ثابتة لا تعبّر عن النجاح. الاختبار الذي يتحقّق من قيمة الإرجاع أمسك بها.
أي ترتيب يجعل عمل المساعد قابلًا للمراجعة؟
العقد يحدّد النيّة، والاختبار يحوّلها إلى مقياس، والكود يحقّقه، والتشغيل يثبته، والمراجعة تحميك. الثالث خطأ شائع: الاختبار الأخضر يثبت ما فحصته فقط، ولا يثبت أنك فحصت الشيء الصحيح.
بطاقات مراجعة سريعة
store.js عن server.js؟findIndex عند عدم الإيجاد تُرجع؟-1، وsplice(-1,1) تحذف آخر عنصر.filter أسلم هنا من splice؟تحقّق من فهمك
1. بجملة واحدة: ما الفرق بين store.spec.js وhttp.spec.js ولماذا نحتاج الاثنين؟
الأول يختبر القواعد بلا منفذ ولا شبكة فيعمل في أجزاء من الثانية وتشغّله بعد كل تعديل، والثاني يختبر التوصيل: الرموز والمسارات وصيغة JSON. والفائدة العملية أن موضع الفشل يخبرك بنوع الخطأ قبل أن تبحث عنه.
2. اشرح بالتفصيل كيف يحذف splice/findIndex المهمة الخطأ.
findIndex لا تجد المُعرّف فتُرجع -1. تُمرّر هذه القيمة مباشرة إلى splice، وفي جافاسكربت الفهرس السالب يُحسب من نهاية المصفوفة، فيصير المعنى «احذف عنصرًا واحدًا من آخرها». النتيجة: المهمة الأخيرة تُحذف، والدالة تُرجع true المثبّتة فيبدو أن كل شيء تمّ. ثلاث مشكلات في سطر واحد: قيمة غير مفحوصة، ودلالة فهرس غير متوقّعة، وقيمة إرجاع كاذبة.
3. (اكتشف الخطأ) اختباراتك ١١/١١ خضراء، وفي الإنتاج تظهر مهام بعنوان فارغ. ما الأرجح؟
أن طبقة النقل تتجاوز النطاق: كأن server.js تبني الكائن بنفسها بدل أن تنادي store.add. الاختبار الأخضر يثبت أن القواعد صحيحة، لا أن كل المسارات تمرّ بها. العلاج: تحقّق في http.spec.js أن الطلب الفاسد يُرجع 400، وهذا بالضبط ما يفعله اختبار HTTP أعلاه.
4. وكيل أضاف حزمة لتوليد المعرّفات. ما موقفك؟
أسأل: ما المشكلة التي تحلّها ولم يحلّها عدّاد من سطر واحد؟ كل حزمة تضيف سطح هجوم وتحديثات وترخيصًا ووزنًا. في مشروع بلا حزم، إضافة أول حزمة قرار لا تفصيلة، والقاعدة المكتوبة في AGENTS.md («لا حزم جديدة بلا سؤال») موجودة تحديدًا لهذه اللحظة.
5. (قرار) زميل يقول: «المساعد كتب التطبيق في عشر دقائق، لماذا نضيّع وقتًا في الاختبارات؟» ما ردّك؟
أن الاختبارات هي ما جعل العشر دقائق قابلة للتصديق. في هذا الجزء أمسك سطر اختبار واحد بخطأ كان سيحذف بيانات مستخدم في الإنتاج، والاقتراح الذي سبّبه كان أقصر وأجمل من كودنا، أي أنه كان سيمرّ في مراجعة بصرية. الاختبار ليس ضريبة على السرعة؛ هو الشيء الوحيد الذي يجعل السرعة آمنة. وبلا اختبارات لم نوفّر الوقت، بل أجّلناه إلى لحظة أسوأ.