اختبارات التكامل على PostgreSQL حقيقية باستخدام Testcontainers: حاوية واحدة لكل تشغيل وقاعدة بيانات لكل ملف اختبار
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أهم النقاط
• Testcontainers مكتبة تشغّل حاويات Docker حقيقية من داخل كود الاختبار ثم تحذفها بعد الانتهاء، فتعمل اختبارات التكامل على نفس الإصدار الرئيسي من PostgreSQL المستخدم في بيئة الإنتاج.
• نشغّل حاوية PostgreSQL واحدة لكل تشغيل للاختبارات داخل globalSetup في Vitest، وننقل بيانات الاتصال إلى عمّال الاختبار عبر provide() وinject().
• نطبّق الترحيلات (migrations) مرة واحدة على قاعدة بيانات قالب، ثم نستنسخها لكل ملف اختبار بالأمر CREATE DATABASE ... TEMPLATE، فننسخ المخطط الجاهز بدلًا من إعادة تشغيل كل ترحيل.
• التراجع عن معاملة (transaction rollback) لكل اختبار ينهار عندما يفتح الكود المختبَر معاملته الخاصة أو يستخدم اتصالًا ثانيًا من الـ pool، لذلك نحصره في اختبارات المستودعات البسيطة.
• نضع بيانات قاعدة الاختبار على tmpfs ونعطّل fsync، لأن إعدادات المتانة تحمي بيانات ستُحذف بعد التشغيل على أي حال.
لماذا نختبر على PostgreSQL حقيقية بدلًا من الـ mocks أو SQLite؟
نختبر على PostgreSQL حقيقية لأن معظم أخطاء طبقة البيانات هي سلوك قاعدة البيانات نفسها، ولا يعيد إنتاجها إلا المحرك الحقيقي. المستودع الوهمي (mock) يعيد فقط ما طلب منه الاختبار إعادته، ولا يستطيع رفع خطأ انتهاك قيد التفرّد (unique constraint)، ولا تطبيق ترحيل، ولا تقييم عبارة ON CONFLICT.
تطبّق SQLite والبدائل التي تعمل في الذاكرة مثل pg-mem لهجة SQL مختلفة أو جزئية. عوامل jsonb، وON CONFLICT ... DO UPDATE، وسياسات row-level security، والإضافات مثل pgvector، ورموز الأخطاء الدقيقة، إما تتصرّف بشكل مختلف أو غير موجودة أصلًا.
Testcontainers مكتبة مفتوحة المصدر لها تطبيقات في Java وGo و.NET وPython وNode.js، وهي تسدّ هذه الفجوة دون الحاجة إلى قاعدة بيانات اختبار مشتركة. في Node.js تتولّى الحزمة testcontainers الحاويات العامة، وتضيف وحدات مثل @testcontainers/postgresql و@testcontainers/redis إعدادات افتراضية خاصة بكل خدمة.
تحصل كل حاوية على منفذ عشوائي على الجهاز المضيف، فلا تتصادم التشغيلات المتوازية على الجهاز نفسه حول المنفذ 5432. وتحذف حاوية جانبية اسمها Ryuk، وهي أداة تنظيف الموارد في Testcontainers، الحاويات عند خروج عملية الاختبار حتى بعد الانهيار. نثبّت وسم الصورة على الإصدار الرئيسي المستخدم في الإنتاج، مثل postgres:17-alpine، أو pgvector/pgvector:pg17 عندما يحتاج المخطط إلى إضافة vector.
كيف نشغّل حاوية PostgreSQL واحدة لتشغيل Vitest بالكامل؟
نشغّل الحاوية داخل ملف globalSetup في Vitest، وهو يعمل مرة واحدة في العملية الرئيسية قبل بدء أي عامل اختبار. تشغيل حاوية لكل ملف اختبار يضاعف تكلفة الإقلاع بعدد الملفات، ومعظم هذه التكلفة هو تهيئة PostgreSQL وليس Docker.
ينشئ ملف الإعداد لدينا PostgreSqlContainer من الصورة postgres:17-alpine مع قاعدة بيانات باسم app_template، ويضع مجلد البيانات على tmpfs، ويمرّر fsync=off وsynchronous_commit=off وfull_page_writes=off كإعدادات للخادم. ثم يطبّق كل ترحيلات Drizzle ORM مرة واحدة على app_template عبر الدالة migrate() من drizzle-orm/node-postgres/migrator، ويغلق ذلك الـ pool، ويقفل القالب بالأمر ALTER DATABASE app_template WITH ALLOW_CONNECTIONS false.
لا يرى عمّال Vitest بشكل موثوق تغييرات process.env التي تحدث داخل globalSetup. في Vitest 3 وما بعده تستقبل دالة الإعداد كائن TestProject تقوم دالته provide() بتسلسل المضيف والمنفذ المربوط واسم المستخدم وكلمة المرور، وتقرأها ملفات الاختبار عبر inject('pg'). يجعل تعريف النوع ProvidedContext الدالة inject() مكتوبة الأنواع بالكامل، والدالة التي تعيدها setup توقف الحاوية بعد آخر ملف اختبار.
نسجّل هذا الملف في إعداد تكامل منفصل فقط، مثل vitest.integration.config.ts، حتى لا تحتاج مجموعة اختبارات الوحدات السريعة إلى Docker أبدًا.
كيف نعزل الاختبارات: التراجع عن المعاملة، أم TRUNCATE، أم قاعدة بيانات مستنسخة؟
نمنح كل ملف اختبار قاعدة بيانات خاصة به مستنسخة من القالب بعد ترحيله، لأن العزل بالاستنساخ يصمد مهما فعل الكود المختبَر بالمعاملات أو الاتصالات. تُقارَن الاستراتيجيات الثلاث الشائعة كما يلي:
• معاملة لكل اختبار يتم التراجع عنها: سريعة وآمنة للتوازي، لكنها تنهار عندما ينفّذ الكود المختبَر BEGIN وCOMMIT بنفسه أو يستعلم عبر اتصال ثانٍ من الـ pool.
• TRUNCATE ... RESTART IDENTITY CASCADE بين الاختبارات: بسيطة، لكن كل الملفات تتشارك قاعدة بيانات واحدة، فلا يمكن تشغيلها بالتوازي، وتنحرف قائمة الجداول عن المخطط مع الوقت.
• قاعدة بيانات لكل ملف اختبار تُنشأ بالأمر CREATE DATABASE ... TEMPLATE app_template: آمنة للتوازي ومستقلة عن الكود المختبَر، ولا تفشل إلا إذا بقي اتصال مفتوح بالقالب.
التراجع عن المعاملة هو الفخ الذي تقع فيه الفرق أولًا. لا تدعم PostgreSQL المعاملات المتداخلة: الأمر BEGIN الثاني داخل معاملة مفتوحة يطلق تحذيرًا فقط، والأمر COMMIT التالي يثبّت المعاملة الخارجية، فلا يجد ROLLBACK الأخير في الاختبار شيئًا ليتراجع عنه. كما أن الخدمة التي تستخدم connection pool ترسل استعلامات عبر اتصالات أخرى لا ترى صفوف الاختبار غير المثبّتة.
تولّد الدالة المساعدة useTestDatabase() لدينا اسمًا فريدًا هو test_ متبوعًا بـ UUID دون شرطات، وتنشئ تلك القاعدة من app_template داخل beforeAll، وتسلّم pg Pool لملف الاختبار، وفي afterAll تغلق الـ pool وتنفّذ DROP DATABASE ... WITH (FORCE) الذي يتطلب PostgreSQL 13 أو أحدث. يتصل pg.Pool بشكل كسول، لذا فإنشاء الـ pool قبل وجود القاعدة آمن.
تتشارك الاختبارات داخل الملف الواحد قاعدة البيانات، لذلك ينشئ كل اختبار بياناته الخاصة، مثل عناوين بريد فريدة، بدلًا من إعادة ضبط الجداول. الاختبار الذي ينشئ مستخدمَين بالبريد نفسه ويتوقع رمز خطأ PostgreSQL رقم 23505، أي unique_violation، يفشل فور اختفاء فهرس التفرّد من الترحيلات. والمستودع الوهمي لا يستطيع إنتاج هذا الخطأ أبدًا.
كيف نُبقي Testcontainers سريعة بما يكفي للتشغيل مع كل تغيير؟
نُبقي Testcontainers سريعة بدفع تكلفة إقلاع الحاوية والترحيلات مرة واحدة لكل تشغيل، وبإيقاف أعمال المتانة التي لا تحتاجها قاعدة بيانات مؤقتة.
tmpfs لمجلد البيانات: تضع withTmpFs() مجلد بيانات PostgreSQL في الذاكرة. يجب أن يطابق المسار قيمة PGDATA في الصورة، وهي /var/lib/postgresql/data في صور postgres:17 الرسمية، لذا نتحقق منه مجددًا قبل الانتقال إلى إصدار رئيسي أحدث.
fsync=off وsynchronous_commit=off وfull_page_writes=off: تتخطى إعدادات خادم PostgreSQL هذه عمليات الكتابة على القرص التي تحمي البيانات المثبّتة من الانهيار. هي غير آمنة في الإنتاج، ولا فائدة منها في حاوية ستُحذف بعد التشغيل.
الاستنساخ من القالب بدلًا من الترحيل لكل ملف: ينسخ CREATE DATABASE ... TEMPLATE القاعدة المرحَّلة على مستوى التخزين، فلا يبطئ الترحيل الخمسون كل ملف اختبار.
في وضع المراقبة المحلي (watch mode) تتيح withReuse() لـ Testcontainers الارتباط بحاوية قيد التشغيل لها الإعدادات نفسها. نستخدمها خارج CI فقط، لأن قاعدة القالب المعاد استخدامها يجب فتحها وإعادة ترحيلها كلما تغيّرت الترحيلات، ولأن CI يجب أن يبدأ دائمًا من حاوية نظيفة.
كيف نشغّل Testcontainers في GitHub Actions؟
نشغّل Testcontainers على مشغّلات ubuntu-latest المستضافة لدى GitHub، وهي تأتي مع Docker Engine، فيعمل أمر الاختبار نفسه في CI دون أي إعداد إضافي. يسحب سير العمل الكود، ويثبّت Node.js 24 عبر actions/setup-node، وينفّذ npm ci، ثم يشغّل vitest بإعداد التكامل.
البديل هو كتلة services: في سير العمل، وهي تشغّل حاوية PostgreSQL بجوار المهمة. نفضّل Testcontainers لأن إصدار قاعدة البيانات وإعدادات الخادم وخطوة الترحيل تعيش في كود الاختبار وتعمل بالطريقة نفسها على الجهاز المحلي وفي CI، بدلًا من تكرارها في ملفات YAML.
لا تتضمن مشغّلات macOS لدى GitHub برنامج Docker، لذا مكان مهام التكامل هو مشغّلات Linux. يدفع التشغيل الأول لكل مهمة تكلفة سحب الصورة، لذا يُبقي وسم صغير ومثبّت مثل postgres:17-alpine هذه التكلفة منخفضة.
الأسئلة الشائعة
س: هل Testcontainers أبطأ من محاكاة قاعدة البيانات بالـ mocks؟
ج: نعم، على مستوى الاختبار الواحد، لأن كل استعلام يقوم برحلة حقيقية إلى PostgreSQL. تشغيل حاوية واحدة لكل تشغيل، والترحيل مرة واحدة، واستنساخ القالب لكل ملف، يحصر التكلفة الثابتة في إقلاع حاوية واحدة لكل تشغيل وعملية استنساخ واحدة لكل ملف اختبار.
س: هل ما زلنا نحتاج اختبارات وحدات تستخدم الـ mocks؟
ج: نعم. منطق الأعمال الصافي أسرع وأوضح في الاختبار دون قاعدة بيانات. نستخدم Testcontainers للكود الذي تعتمد صحته على SQL أو القيود أو الترحيلات أو المعاملات، واختبارات الوحدات العادية لكل ما عدا ذلك.
س: هل يمكن تشغيل Testcontainers دون Docker Desktop؟
ج: نعم. تعمل Testcontainers مع أي بيئة تشغيل متوافقة مع Docker API، بما فيها Docker Engine على Linux وColima وOrbStack وPodman عبر المقبس المتوافق مع Docker. قد تحتاج الإعدادات غير الجذرية (rootless) أو البعيدة إلى ضبط متغير البيئة DOCKER_HOST ليشير إلى المقبس الصحيح.
س: لماذا يفشل CREATE DATABASE ... TEMPLATE برسالة "source database is being accessed by other users"؟
ج: ترفض PostgreSQL استنساخ قاعدة بيانات عليها اتصالات مفتوحة. أغلق كل pool استخدم القالب قبل الاستنساخ، واضبط ALLOW_CONNECTIONS على false في القالب حتى لا يعيد اتصال شارد فتحه.
س: هل يعمل هذا الأسلوب مع NestJS أو Prisma؟
ج: نعم. في NestJS نستبدل مزوّد قاعدة البيانات داخل Test.createTestingModule() عبر overrideProvider() بـ pool يشير إلى القاعدة المستنسخة. ومع Prisma ننفّذ prisma migrate deploy على قاعدة القالب داخل globalSetup، ونمرّر عنوان اتصال القاعدة المستنسخة إلى PrismaClient.
قراءات إضافية
هندسة الواجهات الخلفية
استعلامات N+1 في واجهات Node.js البرمجية: كيف نكتشفها ونجمّعها باستخدام DataLoader ونفرض ميزانية للاستعلامات — ملاحظات ميدانية
استعلام N+1 يختبئ أثناء التطوير المحلي ويضاعف عدد الرحلات إلى قاعدة البيانات في بيئة الإنتاج. نشارك كيف يكتشفه فريقنا بعدّ الاستعلامات لكل طلب وباستخدام pg_stat_statements، ومتى نختار JOIN أو استعلام IN مجمّعًا أو DataLoader، والاختبار في CI الذي يُبقي عدد الاستعلامات ثابتًا مهما زاد حجم الصفحة.
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.