تطوير البرمجيات الموجه بالاختبار (TDD): تطبيق عملي على الدوال ومكونات React
تطوير البرمجيات الموجه بالاختبار (TDD): تطبيق عملي على الدوال ومكونات React
يُعد هذا المقال جزءًا من دراساتي المتعمقة حول كيفية بناء برمجيات مستدامة ومتسقة. سنتناول هنا المنهجية الفكرية وراء تطوير البرمجيات الموجه بالاختبار (Test-Driven Development – TDD)، وكيفية تطبيق هذه المعرفة على الدوال البسيطة، وإمكانية الوصول إلى الويب (web accessibility)، ومكونات React، مع التركيز بشكل أساسي على مكتبتي Jest و React Testing Library.
تُشكل الاختبارات الآلية جزءًا لا يتجزأ من عملية تطوير البرمجيات. فهي لا تمنحنا، كمطورين، الثقة لنشر الكود فحسب، بل تزيد أيضًا من يقيننا بأن البرنامج سيعمل بكفاءة واستمرارية. بدأت مسيرتي المهنية في تطوير البرمجيات ضمن مجتمع Ruby، حيث كنت أكتب الاختبارات منذ اليوم الأول لتعلمي اللغة. لطالما كان مجتمع Ruby (و Rails) قويًا في مجال أتمتة الاختبارات، وقد ساعد ذلك في تشكيل عقليتي حول كيفية كتابة برمجيات جيدة.
باستخدام Ruby و Rails، قمت بالكثير من الأعمال في الواجهة الخلفية (backend)، مثل مهام الخلفية (background jobs)، ونمذجة هياكل البيانات (data structure modeling)، وبناء واجهات برمجة التطبيقات (API building)، وما إلى ذلك. في هذا النطاق، يكون المستخدم دائمًا واحدًا: المطور. فإذا كنت تبني واجهة برمجة تطبيقات (API)، سيكون المستخدم هو المطور الذي يستهلك هذه الواجهة. وإذا كنت تبني النماذج (models)، فسيكون المستخدم هو المطور الذي سيستخدم هذا النموذج.
الآن، وبعد عام مكثف من بناء تطبيقات الويب التقدمية (PWAs) باستخدام React و Redux بشكل أساسي، أصبحت أقوم بالكثير من أعمال الواجهة الأمامية (frontend) أيضًا. في البداية، راودتني بعض الأفكار:
- تطوير البرمجيات الموجه بالاختبار (TDD) مستحيل عند بناء واجهات المستخدم (UI). كيف أعرف ما إذا كان العنصر
divأوspan؟ - يمكن أن يكون الاختبار "معقدًا". هل يجب أن أستخدم
shallowأمmount؟ - هل يجب اختبار كل شيء؟ هل يجب التأكد من أن كل عنصر
divفي مكانه الصحيح؟
لذا، بدأت في إعادة التفكير في ممارسات الاختبار هذه وكيفية جعلها أكثر إنتاجية. اكتشفت أن TDD ممكن تمامًا. إذا كنت أتساءل عما إذا كان يجب أن أتوقع عنصر div أو span، فمن المحتمل أنني أختبر الشيء الخطأ. تذكر: يجب أن تمنحنا الاختبارات الثقة في نشر الكود، وليس بالضرورة تغطية كل جزء أو تفاصيل التنفيذ.
سنتعمق في هذا الموضوع لاحقًا! أرغب في بناء اختبارات:
- تضمن عمل البرنامج بشكل مناسب.
- تمنح الثقة لنشر الكود في بيئة الإنتاج.
- تجعلنا نفكر في تصميم البرمجيات.
واختبارات تجعل البرمجيات:
- سهلة الصيانة.
- سهلة إعادة الهيكلة (refactor).
فهم تطوير البرمجيات الموجه بالاختبار (TDD)
لا ينبغي أن يكون TDD معقدًا. إنه ببساطة عملية تتكون من ثلاث خطوات:
- اكتب اختبارًا (Make a test): ابدأ بكتابة اختبار بسيط يغطي كيفية توقعك لعمل البرنامج. في هذه المرحلة، سيفشل الاختبار لأنه لا يوجد كود لتشغيله. (الحالة الحمراء – Red).
- اجعل الاختبار ينجح (Make it run): قم بتنفيذ الحد الأدنى من الكود (فئة، دالة، سكريبت، إلخ) لجعل الاختبار يمر. الهدف هو جعل الاختبارات خضراء بأسرع وقت ممكن. (الحالة الخضراء – Green).
- حسِّن الكود (Make it right): الآن بعد أن أصبح البرنامج يعمل، حان الوقت لتحسينه. قم بإعادة هيكلة الكود (refactor) لجعله أنظف وأكثر كفاءة وقابلية للقراءة، مع التأكد من أن جميع الاختبارات لا تزال تمر. (الحالة الزرقاء/الخضراء – Refactor).
نحن نحل مشكلة "العمل" أولاً، ثم نجعل الكود نظيفًا. الأمر بسيط جدًا، ويجب أن يكون كذلك. لم أقل إنه سهل، لكنه بسيط ومباشر، مجرد ثلاث خطوات. في كل مرة تمارس فيها هذه العملية – كتابة الاختبارات أولاً، ثم الكود، ثم إعادة الهيكلة – ستشعر بثقة أكبر. إحدى التقنيات الجيدة عند كتابة اختباراتك أولاً هي التفكير في حالات الاستخدام ومحاكاة كيفية استخدامها (كدالة، مكون، أو بواسطة مستخدم حقيقي).
تطبيق TDD على الدوال البسيطة
دعنا نطبق مفهوم TDD هذا على الدوال البسيطة. منذ فترة، كنت أعمل على تنفيذ ميزة مسودة (draft feature) لتدفق تسجيل العقارات. كان جزء من الميزة هو إظهار نافذة منبثقة (modal) إذا كان لدى المستخدم عقار غير مكتمل. الدالة التي سنقوم بتنفيذها هي التي تجيب عما إذا كان لدى المستخدم مسودة عقار واحدة على الأقل. لذا، الخطوة الأولى: كتابة الاختبار!
دعنا نفكر في حالات استخدام هذه الدالة. إنها دائمًا تستجيب بقيمة منطقية (boolean): true أو false.
- ليس لديه مسودة عقار غير محفوظة:
false - لديه مسودة عقار واحدة على الأقل غير محفوظة:
true
دعنا نكتب الاختبارات التي تمثل هذا السلوك:
describe('hasRealEstateDraft', () => {
describe('with real estate drafts', () => {
it('returns true', () => {
const realEstateDrafts = [
{ address: 'São Paulo', status: 'UNSAVED' }
];
expect(hasRealEstateDraft(realEstateDrafts)).toBeTruthy();
});
});
describe('with not drafts', () => {
it('returns false', () => {
expect(hasRealEstateDraft([])).toBeFalsy();
});
});
});
لقد كتبنا الاختبارات. ولكن عند تشغيلها، ستظهر باللون الأحمر: اختباران معطلان لأننا لم ننفذ الدالة بعد. الخطوة الثانية: اجعلها تعمل!
في هذه الحالة، الأمر بسيط جدًا. نحتاج إلى استقبال كائن المصفوفة هذا وإرجاع ما إذا كان يحتوي على مسودة عقار واحدة على الأقل أم لا.
const hasRealEstateDraft = (realEstateDrafts) => realEstateDrafts.length > 0;
رائع! دالة بسيطة. اختبارات بسيطة. يمكننا الانتقال إلى الخطوة الثالثة: اجعلها صحيحة! ولكن في هذه الحالة، دالتنا بسيطة حقًا وقد قمنا بالفعل بجعلها صحيحة.
ولكن الآن نحتاج إلى دالة للحصول على مسودات العقارات وتمريرها إلى الدالة hasRealEstateDraft. ما هي حالات الاستخدام التي يمكننا التفكير فيها؟
- قائمة فارغة من العقارات.
- عقارات محفوظة فقط.
- عقارات غير محفوظة فقط.
- مختلطة: عقارات محفوظة وغير محفوظة.
دعنا نكتب الاختبارات لتمثيل ذلك:
describe('getRealEstateDrafts', () => {
describe('with an empty list', () => {
it('returns an empty list', () => {
const realEstates = [];
expect(getRealEstateDrafts(realEstates)).toMatchObject([]);
});
});
describe('with only unsaved real estates', () => {
it('returns the drafts', () => {
const realEstates = [
{ address: 'São Paulo', status: 'UNSAVED' },
{ address: 'Tokyo', status: 'UNSAVED' }
];
expect(getRealEstateDrafts(realEstates)).toMatchObject(realEstates);
});
});
describe('with only saved real estates', () => {
it('returns an empty list', () => {
const realEstates = [
{ address: 'São Paulo', status: 'SAVED' },
{ address: 'Tokyo', status: 'SAVED' }
];
expect(getRealEstateDrafts(realEstates)).toMatchObject([]);
});
});
describe('with saved and unsaved real estates', () => {
it('returns the drafts', () => {
const realEstates = [
{ address: 'São Paulo', status: 'SAVED' },
{ address: 'Tokyo', status: 'UNSAVED' }
];
expect(getRealEstateDrafts(realEstates)).toMatchObject([{ address: 'Tokyo', status: 'UNSAVED' }]);
});
});
});
رائع! نشغل الاختبارات. إنها لا تعمل… بعد! الآن لننفذ الدالة.
const getRealEstatesDrafts = (realEstates) => {
const unsavedRealEstates = realEstates.filter(
(realEstate) => realEstate.status === 'UNSAVED'
);
return unsavedRealEstates;
};
نقوم ببساطة بالتصفية حسب حالة العقار (status) وإرجاعها. ممتاز، الاختبارات تمر، والشريط أخضر! والبرنامج يعمل، ولكن يمكننا تحسينه: الخطوة الثالثة!
ماذا عن استخراج الدالة المجهولة (anonymous function) داخل دالة filter وجعل 'UNSAVED' ممثلة بتعداد (enum)؟
const STATUS = {
UNSAVED: 'UNSAVED',
SAVED: 'SAVED',
};
const byUnsaved = (realEstate) => realEstate.status === STATUS.UNSAVED;
const getRealEstatesDrafts = (realEstates) => realEstates.filter(byUnsaved);
لا تزال الاختبارات تمر، ولدينا حل أفضل. شيء واحد يجب أخذه في الاعتبار هنا: لقد عزلت مصدر البيانات عن المنطق. ماذا يعني ذلك؟ نحصل على البيانات من التخزين المحلي (localStorage) (مصدر البيانات)، لكننا نختبر فقط الدوال المسؤولة عن المنطق للحصول على المسودات ومعرفة ما إذا كان هناك مسودة واحدة على الأقل. الدوال التي تحتوي على المنطق، نضمن أنها تعمل وأنها كود نظيف. إذا حصلنا على localStorage داخل دوالنا، يصبح من الصعب اختبارها. لذا نفصل المسؤولية ونجعل الاختبارات سهلة الكتابة. الدوال النقية (Pure functions) أسهل في الصيانة وأبسط في كتابة الاختبارات.
اختبار مكونات React
الآن دعنا نتحدث عن مكونات React. بالعودة إلى المقدمة، تحدثنا عن كتابة اختبارات تختبر تفاصيل التنفيذ. والآن سنرى كيف يمكننا تحسين ذلك، وجعله أكثر استدامة، والحصول على ثقة أكبر.
قبل بضعة أيام، كنت أخطط لبناء معلومات الإعداد الأولي (onboarding information) الجديدة لمالك العقار. وهي في الأساس مجموعة من الصفحات بنفس التصميم، ولكنها تغير الأيقونة والعنوان والوصف للصفحات.

أردت بناء مكون واحد فقط: Content وتمرير المعلومات اللازمة لعرض الأيقونة والعنوان والوصف الصحيحين. سأقوم بتمرير businessContext و step كـ props، وسيقوم بعرض المحتوى الصحيح لصفحة الإعداد الأولي.
لا نريد أن نعرف ما إذا كنا سنعرض علامة div أو paragraph. يجب أن يضمن اختبارنا أنه بالنسبة لسياق عمل وخطوة معينين، سيتم عرض المحتوى الصحيح. لذلك توصلت إلى حالات الاستخدام هذه:
- الخطوة الأولى من سياق عمل الإيجار.
- الخطوة الأخيرة من سياق عمل الإيجار.
- الخطوة الأولى من سياق عمل البيع.
- الخطوة الأخيرة من سياق عمل البيع.
دعنا نرى الاختبارات:
describe('Content', () => {
describe('in the rental context', () => {
const defaultProps = { businessContext: BUSINESS_CONTEXT.RENTAL };
it('renders the title and description for the first step', () => {
const step = 0;
const { getByText } = render(
);
expect(getByText('the first step title')).toBeInTheDocument();
expect(getByText('the first step description')).toBeInTheDocument();
});
it('renders the title and description for the forth step', () => {
const step = 3;
const { getByText } = render(
);
expect(getByText('the last step title')).toBeInTheDocument();
expect(getByText('the last step description')).toBeInTheDocument();
});
});
describe('in the sales context', () => {
const defaultProps = { businessContext: BUSINESS_CONTEXT.SALE };
it('renders the title and description for the first step', () => {
const step = 0;
const { getByText } = render(
);
expect(getByText('the first step title')).toBeInTheDocument();
expect(getByText('the first step description')).toBeInTheDocument();
});
it('renders the title and description for the last step', () => {
const step = 6;
const { getByText } = render(
);
expect(getByText('the last step title')).toBeInTheDocument();
expect(getByText('the last step description')).toBeInTheDocument();
});
});
});
لدينا كتلة describe واحدة لكل سياق عمل وكتلة it لكل خطوة. لقد أنشأت أيضًا اختبارًا لإمكانية الوصول (accessibility test) لضمان أن المكون الذي نبنيه يمكن الوصول إليه.
it('has not accessibility violations', async () => {
const props = {
businessContext: BUSINESS_CONTEXT.SALE,
step: 0,
};
const { container } = render(
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
الآن نحتاج إلى جعلها تعمل! بشكل أساسي، جزء واجهة المستخدم لهذا المكون هو مجرد الأيقونة والعنوان والوصف. شيء من هذا القبيل:
{title}
{description}
نحتاج فقط إلى بناء المنطق للحصول على جميع هذه البيانات الصحيحة. بما أن لدي businessContext و step في هذا المكون، أردت فقط أن أفعل شيئًا مثل:
content[businessContext][step]
وهو يحصل على المحتوى الصحيح. لذلك قمت ببناء هيكل بيانات للعمل بهذه الطريقة.
const onboardingStepsContent = {
alugar: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
vender: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
};
إنه مجرد كائن تكون فيه المفاتيح الأولى هي بيانات سياق العمل، ولكل سياق عمل، يحتوي على مفاتيح تمثل كل خطوة من خطوات الإعداد الأولي. وسيكون مكوننا:
const Content = ({ businessContext, step }) => {
const onboardingStepsContent = {
alugar: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
vender: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
};
const { Icon, title, description } = onboardingStepsContent[businessContext][step];
return (
{title}
{description}
);
};
إنه يعمل! الآن دعنا نجعله أفضل. أردت جعل عملية الحصول على المحتوى أكثر مرونة. ماذا لو تلقى خطوة غير موجودة على سبيل المثال؟
هذه هي حالات الاستخدام:
- الخطوة الأولى من سياق عمل الإيجار.
- الخطوة الأخيرة من سياق عمل الإيجار.
- الخطوة الأولى من سياق عمل البيع.
- الخطوة الأخيرة من سياق عمل البيع.
- خطوة غير موجودة في سياق عمل الإيجار.
- خطوة غير موجودة في سياق عمل البيع.
دعنا نرى الاختبارات:
describe('getOnboardingStepContent', () => {
describe('when it receives existent businessContext and step', () => {
it('returns the correct content for the step in "alugar" businessContext', () => {
const businessContext = 'alugar';
const step = 0;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject({
Icon: Home,
title: 'first step title',
description: 'first step description',
});
});
it('returns the correct content for the step in "vender" businessContext', () => {
const businessContext = 'vender';
const step = 5;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject({
Icon: ContractSign,
title: 'last step title',
description: 'last step description',
});
});
});
describe('when it receives inexistent step for a given businessContext', () => {
it('returns the first step of "alugar" businessContext', () => {
const businessContext = 'alugar';
const step = 7;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject({
Icon: Home,
title: 'first step title',
description: 'first step description',
});
});
it('returns the first step of "vender" businessContext', () => {
const businessContext = 'vender';
const step = 10;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject({
Icon: Home,
title: 'first step title',
description: 'first step description',
});
});
});
});
رائع! الآن دعنا نبني دالتنا getOnboardingStepContent للتعامل مع هذا المنطق.
const getOnboardingStepContent = ({ businessContext, step }) => {
const content = onboardingStepsContent[businessContext][step];
return content ? content : onboardingStepsContent[businessContext][0];
};
نحاول الحصول على المحتوى. إذا كان موجودًا، نرجعه. إذا لم يكن موجودًا، نرجع الخطوة الأولى من الإعداد الأولي. أنيق!
ولكن يمكننا تحسينه. ماذا عن استخدام عامل التشغيل ||؟ لا حاجة للتعيين إلى متغير، ولا حاجة لاستخدام عامل ثلاثي (ternary operator).
const getOnboardingStepContent = ({ businessContext, step }) =>
onboardingStepsContent[businessContext][step] || onboardingStepsContent[businessContext][0];
إذا وجد المحتوى، فإنه يرجعه. إذا لم يجده، فإنه يرجع الخطوة الأولى من سياق العمل المحدد. الآن مكوننا هو مجرد واجهة مستخدم (UI) بحتة.
const Content = ({ businessContext, step }) => {
const { Icon, title, description } = getOnboardingStepContent({ businessContext, step });
return (
{title}
{description}
);
};
الخلاصة التقنية
إن التفكير بعمق في الاختبارات التي نكتبها ليس مجرد ممارسة جيدة، بل هو ضرورة حتمية في عالم تطوير البرمجيات المعاصر. الاختبارات الفعالة، خاصة عند تطبيق منهجية TDD، تمنح المطورين ثقة لا تتزعزع في الكود الذي يقومون بشحنه، مما يقلل من المخاطر ويسرع وتيرة التسليم. كما أنها تدفعنا نحو تصميم معماري أفضل للبرمجيات، حيث يصبح الكود أكثر وضوحًا وقابلية للصيانة وإعادة الهيكلة. إن فصل منطق الأعمال عن مكونات واجهة المستخدم، كما رأينا في أمثلة React، يعزز من نقاء الدوال ويجعل عملية الاختبار أسهل وأكثر موثوقية. في نهاية المطاف، الاستثمار في كتابة اختبارات جيدة هو استثمار في جودة المنتج واستدامته على المدى الطويل، ويساهم في بناء ثقافة تطوير برمجيات قوية وواثقة.