full_stack_101 اختبار الواجهة الخلفية لتطبيقات Node.js عبر مكتبة Jest


ابراهيم الخضور

سنبدأ الآن بكتابة الاختبارات الخاصة بالواجهة الخلفية. وطالما أن منطق شيفرة الواجهة الخلفية ليس بهذا التعقيد، فلن يكون هناك معنى لكتابة اختبارت لأجزاء الشيفرة. لكن الشيء الوحيد الذي يمكن أن نجري عليه اختبار الأجزاء، هو التابع toJSON الذي يستخدم لتنسيق الملاحظات.

قد يكون مفيدًا في بعض الأحيان، أن نختبر الواجهة الخلفية بأن نحاكي قاعدة البيانات بدلًا من استخدام القاعدة الأصلية، ويمكن الاستعانة بالمكتبة mongo-mock في ذلك.

وطالما أن تطبيقنا لا يزال سهلًا نوعًا ما، سنختبر التطبيق بالكامل عبر واجهته البرمجية REST، وبالتالي سنضمن اختباره مع قاعدة بياناته. يسمى هذا النوع من الاختبارات الذي نجريه على عدة مكونات كمجموعة متكاملة اسم اختبار التكامل (integration testing)

بيئة الاختبار

أشرنا في أحد الفصول السابقة إلى أن خادم الواجهة الخلفية سيكون في وضع الإنتاج production mode عندما يعمل على Heroku. وتقتضي تقاليد Node تعريف وضع التطبيق عند تنفيذه ضمن متحول البيئة NODE_ENV.

نستعمل في تطبيقنا متحولات البيئة المعرفة ضمن الملف env. فقط إن لم يكن التطبيق في وضع الإنتاج. وقد جرت العادة على تعريف أوضاع التطبيق بشكل منفصل في حالتي الاختبار والإنتاج.

عدلنا سكربت package.json لكي يأخذ متحول البيئة NODE_ENV القيمة test عندما نجري الاختبارات:

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",
    "dev": "NODE_ENV=development nodemon index.js",
    "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
    "deploy": "git push heroku master",
    "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
    "logs:prod": "heroku logs --tail",
    "lint": "eslint .",
    "test": "NODE_ENV=test jest --verbose --runInBand"  },
  // ...
}

كما أضفنا الخيار runInBand إلى سكربت npm، حيث يمنع هذا الخيار مكتبة Jest من تنفيذ الاختبارات على التوازي. وسنناقش أهمية هذا الخيار عندما نبدأ الاختبارات مستخدمين قاعدة البيانات.

حددنا وضع التطبيق في سكربت npm run dev ليكون في وضع التطوير development، بحيث يمكن استخدام المكتبة nodemon. بينما حددنا وضع التطبيق ليكون في وضع الإنتاج production في سكربت npm start والذي يحدد وضع التطبيق عند التشغيل الافتراضي.

سنجد أن هناك مشكلة صغيرة في تعريفنا لوضع التطبيق بهذا الشكل، فلن يعمل التطبيق على Windows. ولحل هذه المشكلة علينا تثبيت حزمة cross-env بتنفيذ الأمر التالي:

npm install cross-env

يمكننا إنجاز العمل بشكل متوافق مع منصات التشغيل المختلفة باستخدام المكتبة cross-env، حيث سنعدل في سكربت npm ضمن الملف package.json ليصبح على الشكل:

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "dev": "cross-env NODE_ENV=development nodemon index.js",
    // ...
    "test": "cross-env NODE_ENV=test jest --verbose --runInBand",
  },
  // ...
}

وهكذا سنصبح قادرين على تشغيل التطبيق بأوضاع مختلفة. حيث نستطيع مثلًا أن نستخدم قاعدة بيانات منفصلة لأغراض الاختبارات عندما نجريها.

على الرغم من إمكانية إنشاء قاعدة بيانات للاختبارات على MongoDB Atlas، إلا أن ذلك لن يكون حلًا جيدًا، لأن العديد من المطورين سيعملون على نفس التطبيق وبالتالي قد تظهر المشاكل. فمن المفترض ألا نجري عدة اختبارات في الوقت ذاته مستخدمين قاعدة البيانات ذاتها. ومن الأفضل إنجاز الاختبارات باستخدام قاعدة بيانات مثبتة على جهاز المطور وتعمل عليه. وبالتالي سيكون الخيار الأنسب استخدام قاعدة بيانات منفصلة لكل اختبار نجريه، وسيكون هذا الأمر بسيطًا نوعًا ما عندما نستخدم إحدى الحاويتين running Mongo in-memory أو Docker. لن نعقد الأمور أكثر بل سنستمر باستخدام قاعدة البيانات على MongoDB Atlas.

لنجري بعض التعديلات على الوحدة التي تعرف إعدادات التهيئة لتطبيقنا:

require('dotenv').config()

let PORT = process.env.PORT
let MONGODB_URI = process.env.MONGODB_URI

if (process.env.NODE_ENV === 'test') {  MONGODB_URI = process.env.TEST_MONGODB_URI}
module.exports = {
  MONGODB_URI,
  PORT
}

يضم الملف env. متحولات منفصلة لتعريف عناوين قواعد البيانات لأغراض الاختبار والتطوير:

MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true
PORT=3001

TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true

تشابه الوحدة config التي أضفناها إلى التطبيق الحزمة node-config قليلًا. لكن إضافة إعداداتنا الخاصة إلى التطبيق له مبرراته طالما أن التطبيق بسيط وأنه مصمم خصيصًا لتعلم نقاط هامة.

وهكذا نكون قد أجرينا كل التعديلات المطلوبة على تطبيقنا. يمكنك إيجاد شيفرة التطبيق الحالي بالكامل في الفرع part4-2 ضمن المخزن المخصص على GitHub.

استخدام المكتبة supertest

سنستخدم الحزمة supertest التي ستساعدنا على إجراء الاختبارات على واجهة تطبيقنا البرمجية، حيث سنثبت الحزمة كاعتمادية تطوير كالتالي:

npm install --save-dev supertest

لنكتب أول اختباراتنا في الملف note_api.test.js الذي سننشئه في المجلد tests:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')

const api = supertest(app)

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

afterAll(() => {
  mongoose.connection.close()
})

تُدرج شيفرة الاختبار تطبيق Express من الوحدة app.js وتغلفها داخل الدالة supertest لنحصل على كائن جديد يدعى superagent. يُسند هذا الكائن إلى المتغير api حيث يستخدمه التطبيق لإرسال طلبات HTTP إلى الواجهة الخلفية.

يرسل تطبيقنا طلب HTTP-GET إلى عنوان الموقع api/notes ويتحقق من استجابة الخادم عندما يعيد رمز الحالة (200). كما يتحقق الاختبار من أن قيمة ترويسة "نوع-المحتوى" هي application/json، وبالتالي ستكون البيانات بالتنسيق الصحيح. ويضم التطبيق أيضًا بعض التفاصيل الأخرى التي سنناقشها بعد قليل.

تُسبق الدالة السهمية التي تعرف الاختبار بالتعليمة async، وكذلك يُسبق التابع الذي يستدعي الكائن api بالتعليمة await. سنعود إلى هذا الموضوع بعد أن ننفذ عدة اختبارات أولًا. لا تُعر الموضوع اهتمامًا حاليًا، وعليك فقط أن تتأكد بأن الاختبار يجري بنجاح. فالعبارتين awiat/async في واقع الأمر مرتبطتان بعدم تزامن طلبات HTTP إلى الواجهة البرمجية حيث تستخدم Async/await لكتابة شيفرة غير متزامنة في التنفيذ، لكنها توحي بالتزامن.

يجب إنهاء الاتصال الذي تستخدمه Mongoose بقاعدة البيانات بمجرد إنتهاء الاختبارات (هناك اختبار واحد حاليًا). تُنجز هذه المهمة باستخدام التابع afterAll:

afterAll(() => {
  mongoose.connection.close()
})

من الممكن أن تواجه التحذير التالي على الطرفية عندما تنفذ الاختبارات:

test_warn_01.png

إن حدث هذا، علينا اتباع التعليمات وإضافة الملف jest.config.js عند جذر المشروع وبداخله الشيفرة التالية:

module.exports = {
  testEnvironment: 'node'
}

يجدر بك الانتباه إلى تفصيل صغير ومهم، فلقد وضعنا تطبيق Express ضمن الملف app.js في بداية هذا القسم، وبقي للملف index.js وظيفة تشغيل التطبيق على المنفذ المحدد باستخدام كائن http المدمج في Node:

const app = require('./app') // the actual Express app
const http = require('http')
const config = require('./utils/config')
const logger = require('./utils/logger')

const server = http.createServer(app)

server.listen(config.PORT, () => {
  logger.info(`Server running on port ${config.PORT}`)
})

تستخدم الاختبارات التي نجريها تطبيق express المعرّف في الملف app.js:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...

سنجد في توثيق supertest مايلي:

اقتباس

إن لم يكن الخادم منصتًا إلى الاتصالات، فسيكون مرتبطًا بمنفذ بك وبالتالي لا حاجة لتعقب المنافذ.

بمعنًى آخر: ستهتم المكتبة بتشغيل التطبيق الذي تختبره على المنفذ الذي يستخدمه داخليًا.

لنكتب عدة اختبارات جديدة:

اقتباس

*if the server is not already listening for connections then it is bound to an ephemeral port for you so there is no need to keep track of ports.

test('there are two notes', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(2)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  expect(response.body[0].content).toBe('HTML is easy')
})

يخزن كلا الاختبارين الاستجابة ضمن المتغير response، وعلى خلاف الاختبار السابق الذي يستخدم توابع المكتبة supertest للتحقق من رمز الحالة الذي يعيده الخادم، سيتحقق الاختباران من البيانات المخزنة ضمن الخاصية response.body. حيث سيتحققا من تنسيق ومحتوى البيانات في الاستجابة باستخدام التابع expect العائد للمكتبة Jest.

ستظهر لك الآن فائدة استخدام await/async. فعادة ما كنا نستخدم دوال الاستدعاء للوصول إلى البيانات التي تعيدها الوعود، لكن بهذه الطريقة ستغدو الأمور مريحةً أكثر:

const res = await api.get('/api/notes')
//http سيكون تنفيذ الشيفرة هنا بعد اكتمال طلب 
//res تُحفظ نتيجة تنفيذ الطلب في المتغير 
expect(res.body).toHaveLength(2)

ستحجب الأداة الوسيطية المسؤولة عن إظهار معلومات طلب HTTP، المعلومات التي سيظهرها الاختبار، لذا سنعدل الأداة الوسطية logger بحيث لا تطبع أية معلومات على الطرفية أثناء الاختبار:

const info = (...params) => {
  if (process.env.NODE_ENV !== 'test')
  {
      console.log(...params)
  }}

const error = (...params) => {
  console.error(...params)
}

module.exports = {
  info, error
}

إعادة ضبط قاعدة البيانات قبل الاختبارات

تبدو الاختبارات حتى هذه اللحظة سهلة وناجحة. لكنها اختبارت سيئة كونها تعتمد على حالة قاعدة البيانات. ولكي نجعل اختباراتنا أكثر واقعية، علينا إعادة ضبط قاعدة البيانات وتوليد بيانات الاختبار المطلوب بطريقة مضبوطة قبل أن نبدأ الاختبار.

تستخدم اختباراتنا الدالة afterAll من المكتبة Jest لإنهاء الاتصال مع قاعدة البيانات بعد أن ينتهي تنفيذ الاختبار. لكن هناك العديد من الدوال التي تقدمها Jest والتي يمكنها تنفيذ العديد من المهام قبل أن يُنفذ أي اختبار أو قبل كل مرة يُنفذ فيها الاختبار. سنعيد ضبط قاعدة البيانات قبل كل اختبار مستخدمين الدالة beforeEach:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML is easy',
    date: new Date(),
    important: false,
  },
  {
    content: 'Browser can execute only Javascript',
    date: new Date(),
    important: true,
  },
]

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(initialNotes[0])
  await noteObject.save()

  noteObject = new Note(initialNotes[1])
  await noteObject.save()
})

تُمحى في البداية البيانات الموجودة في قاعدة البيانات، ثم تُحفظ الملاحظتين المُخزنتين في المصفوفة initialNotes فيها. نضمن عند قيامنا بذلك، بقاء حالة قاعدة البيانات كما هي قبل كل اختبار.

سنجري بعض التعديلات على الاختبارين الأخيرين:

test('all notes are returned', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(initialNotes.length)})

test('a specific note is within the returned notes', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)
  expect(contents).toContain(
    'Browser can execute only Javascript'  )
})

انتبه جيدًا للتعليمة expect في الاختبار الأخير. ينشئ الأمر (response.body.map(r => r.content مصفوفة تضم محتوى كل ملاحظة تعيدها الواجهة البرمجية. بينما يتحقق التابع toContain من وجود الملاحظة التي تُمرر إليه كوسيط، ضمن قائمة الملاحظات التي تعيدها الواجهة البرمجية.

إجراء الاختبارات واحدًا تلو الآخر

يُنفٍّذ الأمر npm test كل اختبارات التطبيق. لكن من الأفضل عند كتابة الاختبارات أن ننفذ كل اختبار أو اختبارين معًا. تقدم Jest طرقًا عدة لإنجاز الأمر. من هذه الطرق استخدام التابع only، لكنها ليست فعالة عندما يُكتب الاختبار ليُنفَّذ عبر ملفات عدة. أما الطريقة الأفضل فهي تحديد الاختبارات التي نريد تنفيذها كمعاملات للأمر npm test. فالأمر التالي على سبيل المثال ينفذ فقط الاختبارات الموجودة في الملف note_api.test.js ضمن المجلد tests:

npm test -- tests/note_api.test.js

نستخدم الخيار t- لتنفيذ اختبار يحمل اسمًا محددًا:

npm test -- -t 'a specific note is within the returned notes'

يمكن للمعامل الذي نمرره، أن يشير إلى اسم الاختبار أو إلى كتلة الوصف describe block. كما يمكن أن يحمل المعامل جزءًا من الاسم فقط. سينفذ الأمر التالي،على سبيل المثال، كل الاختبارات التي تحتوي أسماؤها على الكلمة notes:

npm test -- -t 'notes'

ملاحظة: يمكن أن يبقى اتصال Mongoose مفتوحًا عندما ننفذ اختبارًا واحدًا، إن لم تكن هناك اختبارات قيد التنفيذ تستعمل الاتصال. قد تكون المشكلة أن supertest ستجهز الاتصال، لكن Jest لا تنفذ شيفرة الدالة afterAll.

التعليمتين async/await

قبل أن نستأنف كتابة المزيد من الاختبارات، لنلق نظرة على التعليميتن await وasync.

قُدٍّمت التعليمتان لأول مرة في الإصدار ES7، حيث مكّنتا من استخدام "الدوال غير المتزامنة التي تعيد وعدًا" بطريقة تبدو وكأنها متزامنة. وكمثال على ما ذكرناه، سيبدو إحضار الملاحظات من قاعدة البيانات باستخدام الوعود كالتالي:

Note.find({}).then(notes => {
  console.log('operation returned the following notes', notes)
})

يعيد التابع ()Note.find وعدًا يمكن الوصول إلى نتيجته بالتصريح عن دالة استدعاء كوسيط للتابع then. وسنجد كل الشيفرة التي يجب تنفيذها حالما تنتهي العملية ضمن دالة الاستدعاء. فلو أردنا أن نجري عدة استدعاءات غير متزامنة للدالة بشكل متسلسل، ستسوء الأمور. لأن الطلبات غير المتزامنة يجب أن تتم ضمن دوال استدعاء، وهذا ما سيعقد الشيفرة وقد يقود إلى ما يسمى جحيم الاستدعاءات (callbak hell).

يمكن أن نبقي الأمور تحت السيطرة نوعًا ما باستخدام سلاسل الوعود، وبالتالي نتحاشى "جحيم الاستدعاءات" بإنشاء سلسلة واضحة من التوابع then. ولقد صادفنا ذلك مراتٍ عدة في منهاجنا. لتوضيح ذلك، سنقدم مثالًا افتراضيًا عن دالة تحضر جميع الملاحظات وتحذف الأولى:

Note.find({})
  .then(notes => {
    return notes[0].remove()
  })

  .then(response => {
    console.log('the first note is removed')
    // المزيد من الشيفرة هنا
  })

لا بأس باستخدام سلسلة التابع then، لكن هناك طريقة أفضل. تؤمن التوابع المولٍّدة (genertaor functions) التي قُدّمت في الإصدار ES6، طريقة ذكية في كتابة الشيفرة غير المتزامنة بطريقة تجعلها "وكأنها متزامنة". لكنها طريقة غريبة ولم تستخدم بشكل واسع.

تقدم التعليمتان await/async اللتان ظهرتا للوجود في الإصدار ES7، الوظائف نفسها التي تقدمها المولدات، لكن بطريقة واضحة ومنهجية ومفهومة بالنسبة لكل مستخدمي JavaScript. يمكن إحضار الملاحظات من قاعدة البيانات باستخدام await كالتالي:

const notes = await Note.find({})

console.log('operation returned the following notes', notes)

تبدو الشيفرة وكأنها متزامنة. يتوقف التنفيذ مؤقتًا عند العبارة ({})const notes= await Note.find حتى يتحقق الوعد، ثم ينتقل التنفيذ بعدها إلى السطر التالي. تُسند نتيجة العملية التي تعيد الوعد بعد متابعة التنفيذ إلى المتغير notes.

يمكن أن ننفذ المثال الذي طرحناه سابقًا (والذي يحمل قليلًا من التعقيد) مستخدمين await كالتالي:

const notes = await Note.find({})
const response = await notes[0].remove()

console.log('the first note is removed')

لقد غدا الأمر أسهل وأوضح من طريقة سلسلة then بفضل القاعدة الجديدة.

لكن عليك الانتباه إلى بعض التفاصيل المهمة عند استخدام async/await. فيجب على await أن تعيد وعدًا إذا أردت تطبيقها على عملية غير متزامنة. وطبعًا لن يكون الأمر صعبًا، طالما أن الدوال النظامية غير المتزامنة التي تستخدم الاستدعاءات يمكنها تدبر أمر الوعود. كما لا يمكن استعمال await في أي مكان ضمن شيفرة JavaScript، بل فقط ضمن دالة غير متزامنة. أي حتى يعمل المثال السابق لا بد من استعمال دوال غير متزامنة.

لنركز الآن على السطر الأول من الدالة السهمية:

const main = async () => {  const notes = await Note.find({})
  console.log('operation returned the following notes', notes)

  const response = await notes[0].remove()
  console.log('the first note is removed')
}
main()

تصرح الشيفرة بوضوح أن الدالة السهمية المُسندة إلى main غير متزامنة، ثم تُستدعى الدالة بعد ذلك باستخدام ()main.

استخدام async/await في الواجهة الخلفية

سنغيّر شيفرة الواجهة الخلفية لتستخدم await وasync. يكفي أن نغير معالج المسار في أي دالة إلى دالة غير متزامنة حتى نستخدم التعليمة await، طالما أن كل العمليات غير المتزامنة ستجري داخل دالة.

سنغير المسار الذي يستخدم لإحضار الملاحظات جميعها على النحو التالي:

notesRouter.get('/', async (request, response) => { 
  const notes = await Note.find({})
  response.json(notes)
})

يمكنك التحقق من نجاح الأمر باختبار الشيفرة ضمن المتصفح وكذلك بتنفيذ الاختبارات التي كتبناها سابقًا.

يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-3 ضمن المستودع الخاص بالتطبيق على GitHub.

اختبارت أكثر وإعادة كتابة الواجهة الخلفية

يبقى دائمًا خطر التدهور (regression) قائمًا عند إعادة كتابة الشيفرة. ونقصد بذلك احتمال توقف التطبيق عن تنفيذ الوظيفة المطلوبة. إذُا سنعيد كتابة ما تبقى من عمليات بكتابة اختبار لكل مسار من مسارات الواجهة البرمجية.

سنبدأ بعملية إضافة ملاحظة جديدة، حيث سنكتب اختبارًا ينشئ ملاحظة جديدة ويتحقق أن عدد الملاحظات الذي ستعيده الواجهة البرمجية سيزداد، وأن الملاحظة الجديدة موجودة بالفعل ضمن قائمة الملاحظات.

test('a valid note can be added', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(200)
    .expect('Content-Type', /application\/json/)

  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)

  expect(response.body).toHaveLength(initialNotes.length + 1)
  expect(contents).toContain(
    'async/await simplifies making async calls'
  )
})

سيُنفًّذ الاختبار بنجاح كما خططنا وتوقعنا.

لنتكب اختبارًا آخر يتحقق من عدم تخزين أية ملاحظة في قاعدة البيانات ما لم يكن فيها محتوًى.

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(initialNotes.length)
})

يتحقق كلا الاختبارين من حالة الملاحظة المخزنة في قاعدة البيانات، وذلك بإحضار كل الملاحظات في التطبيق.

const response = await api.get('/api/notes')

سننفذ بنفس خطوات التحقق السابقة في الاختبارات القادمة، لذا يفضل نقل شيفرة الاختبارات إلى الدوال المساعدة. ولهذا سنضيف دالة تضم الشيفرة السابقة إلى ملف جديد يدعى test_helper.js ونضعه في المجلد tests:

const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML is easy',
    date: new Date(),
    important: false
  },
  {
    content: 'Browser can execute only Javascript',
    date: new Date(),
    important: true
  }
]

const nonExistingId = async () => {
  const note = new Note({ content: 'willremovethissoon', date: new Date() })
  await note.save()
  await note.remove()

  return note._id.toString()
}

const notesInDb = async () => {
  const notes = await Note.find({})
  return notes.map(note => note.toJSON())
}

module.exports = {
  initialNotes, nonExistingId, notesInDb
}

تعرف الوحدة الدالة notesIdDb التي تتحقق من الملاحظات المخزنة في قاعدة البيانات. كما تضم الوحدة المصفوفة initialNotes التي تخزن الحالة الأساسية لقاعدة البيانات.كما تعرف الوحدة الدالة nonExistingId التي يمكن استخدامها لاحقًا في إنشاء كائن مُعرِّف قاعدة اليانات (ID Object)، وهو كائن لا يتبع لأي ملاحظة مخزنة في القاعدة.

سنتمكن الآن من استخدام وحدة مساعدة في الاختبارات التي ستصبح على النحو:

const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')const app = require('../app')
const api = supertest(app)

const Note = require('../models/note')

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])  await noteObject.save()
})

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

test('all notes are returned', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(helper.initialNotes.length)})

test('a specific note is within the returned notes', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)
  expect(contents).toContain(
    'Browser can execute only Javascript'
  )
})

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(200)
    .expect('Content-Type', /application\/json/)

  const notesAtEnd = await helper.notesInDb()  expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)
  const contents = notesAtEnd.map(n => n.content)  expect(contents).toContain(
    'async/await simplifies making async calls'
  )
})

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const notesAtEnd = await helper.notesInDb()
  expect(notesAtEnd).toHaveLength(helper.initialNotes.length)})

afterAll(() => {
  mongoose.connection.close()
}) 

ستعمل الشيفرة التي تعتمد على الوعود وستنجح الاختبارات التي سننفذها، وبالتالي أصبحنا مستعدين لاستخدام العبارة async/await.

سنغير في الشيفرة المسؤولة عن إنشاء ملاحظة جديدة، وانتبه إلى أن تعريف معالج المسار سيُسبق بالكلمة async:

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })

  const savedNote = await note.save()
  response.json(savedNote)
})

لاتزال هناك هفوة صغيرة في شيفرتنا، فلم نكتب أي شيء للتعامل مع الأخطاء. كيف سنعالج الأمر إذًا؟

معالجة الأخطاء عند استعمال async/await

إن حدث خطأ في معالجة الطلب POST، سنواجهة حالة الخطأ المألوفة التالية:

post_erorr_02.png

أي سينتهي بنا المطاف أمام حالة رفض للوعد لم يهيئ التطبيق لالتقاطه، ولن تكون هناك أية استجابة للطلب.

إن الطريقة المفضلة للتعامل مع الاستثناءات عندما نستعمل async/await هي الطريقة القديمة المألوفة try/catch:

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })
  try {
      const savedNote = await note.save()
      response.json(savedNote)
  }
  catch(exception)
  {
          next(exception)
  }})

تستدعي الكتلة catch ببساطة الدالة next التي تمرر معالجة الطلب إلى الوحدة الوسطية التي تعالج الأخطاء.

ستنجح كل الاختبارات أيضًا عند إجراء التعديلات السابقة. سنكتب تاليًا اختبارات لإحضار وحذف ملاحظة واحدة:

test('a specific note can be viewed', async () => {
  const notesAtStart = await helper.notesInDb()

  const noteToView = notesAtStart[0]

  const resultNote = await api    
  .get(`/api/notes/${noteToView.id}`)    
  .expect(200)    
  .expect('Content-Type', /application\/json/)
  const processedNoteToView = JSON.parse(JSON.stringify(noteToView))

  expect(resultNote.body).toEqual(processedNoteToView)
})

test('a note can be deleted', async () => {
  const notesAtStart = await helper.notesInDb()
  const noteToDelete = notesAtStart[0]

  await api
      .delete(`/api/notes/${noteToDelete.id}`)
      .expect(204)
  const notesAtEnd = await helper.notesInDb()

  expect(notesAtEnd).toHaveLength(
    helper.initialNotes.length - 1
  )

  const contents = notesAtEnd.map(r => r.content)

  expect(contents).not.toContain(noteToDelete.content)
})

نحصل في الاختبار الأول على كائن الملاحظة بعد أن تخضع الاستجابة إلى عملية تقسيم ومعالجة خاصة ببيانات JSON. ستحول عملية المعالجة قيمة الخاصية date لكائن الملاحظة من كائن Date إلى نص. ولهذا، لن نتمكن مباشرة من الموازنة بين resultNote.body وnoteToView. بدلًا من ذلك علينا أن نُخضع noteToView إلى نفس عملية التقسيم والمعالجة كما يفعل الخادم مع كائن الملاحظة.

لكلا الاختبارين الهيكل ذاته. ففي مرحلة إعادة الضبط (التصفير) سيحضر الاختبارين الملاحظة من قاعدة البيانات. يستدعي الاختباران بعد ذلك العملية الرئيسية التي يجري اختبارها. وأخيرًا يتحقق الاختباران من أن نتيجة العملية كما هو متوقع أو لا.

سينجح الاختباران وبالتالي سنتمكن من إعادة كتابة شيفرة المسارات المختبرة بأسلوب:

notesRouter.get('/:id', async (request, response, next) => {
  try{
    const note = await Note.findById(request.params.id)
    if (note) {
      response.json(note)
    } else {
      response.status(404).end()
    }
  } catch(exception) {
    next(exception)
  }
})

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndRemove(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-4 ضمن المستودع الخاص بالتطبيق على GitHub.

الاستغناء عن الكتلة try/catch

سترتب العبارة async/await الشيفرة قليلًا، لكن الثمن هو الحاجة إلى استخدام الكتلة catch لالتقاط الاستثناءات. تشترك معالجات المسار بالبنية نفسها:

try {
  // do the async operations here
} catch(exception) {
  next(exception)
}

قد يتسائل أحدنا عن إمكانية إعادة كتابة الشيفرة بالشكل الذي نستغني فيه عن كتلة catch. والحل بالطبع موجود في المكتبة express-async-errors. لثبتها إذًا.

npm install express-async-errors --save

إن استخدام المكتبة سهل للغاية. حيث ستدرج المكتبة ضمن الملف app.js في المجلد src كالتالي:

const config = require('./utils/config')
const express = require('express')
                require('express-async-errors')
const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')

// ...

module.exports = app

ستلغي هذه المكتبة كتل try/catch كليًا. فلو أخذنا على سبيل المثال المسار الذي يحذف ملاحظة:

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndRemove(request.params.id)
    response.status(204).end()
  } 
  catch (exception) {
    next(exception)
  }
})

سيتحول إلى الشكل:

notesRouter.delete('/:id', async (request, response) => {
  await Note.findByIdAndRemove(request.params.id)
  response.status(204).end()
})

لم يعد علينا استدعاء next(exception)‎ بعد الآن، حيث تعالج المكتبة كل ما هو مطلوب في الخفاء. وإن حدث استثناء في مسار async سيُمرر تلقائيًا إلى الأداة الوسطية لمعالجة الأخطاء.

ستصبح المسارات الأخرى على النحو:

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
    date: new Date(),
  })

  const savedNote = await note.save()
  response.json(savedNote)
})

notesRouter.get('/:id', async (request, response) => {
  const note = await Note.findById(request.params.id)
  if (note) {
    response.json(note)
  } else {
    response.status(404).end()
  }
})

ستجد شيفرة التطبيق في الفرع part4-5 ضمن المستودع المخصص على GitHub.

تحسين الدالة beforeEach

لنعد إلى كتابة الاختبارات ولنحاول أن نلقي نظرةً أقرب إلى الدالة beforeEach التي تضبط الاختبارات:

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])
  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])
  await noteObject.save()
})

تحفظ الدالة أول ملاحظتين من المصفوفة helper.initialNotes ضمن قاعدة البيانات عبر عمليتين منفصلتين. لابأس بذلك، لكن هناك طريقة أفضل لتخزين عدة كائنات في قاعدة البيانات:

beforeEach(async () => {
  await Note.deleteMany({})
  console.log('cleared')

  helper.initialNotes.forEach(async (note) => {
    let noteObject = new Note(note)
    await noteObject.save()
    console.log('saved')
  })
  console.log('done')
})

test('notes are returned as json', async () => {
  console.log('entered test')
  // ...
}

لقد خزَّنا الملاحظات الموجودة في المصفوفة ضمن قاعدة البيانات باستعمال حلقة forEach. لن يعمل التطبيق كما أردنا تمامًا، لذلك كان علينا أن نطبع بعض السجلات على الطرفية لتساعدنا على فهم المشكلة. فأظهرت الطرفية ما يلي:

cleared
done
entered test
saved
saved

على الرغم من استخدام async/await، لم يعمل الحل المقترح بالطريقة المتوقعة. بل بدأ الاختبار قبل أن يُعاد ضبط قاعدة البيانات.

تكمن المشكلة في أن كل تكرار للحلقة forEach يولد عملية غير متزامنة خاصة به، ولن تنتظر الحلقة forEach كل تكرار لكي يُنهي تنفيذ عمليته. بمعنى آخر، لم توضع التعليمات await الموجودة داخل حلقة forEach في الدالة beforeEach بل في دوال منفصلة لن تنتظرها beforeEach حتى يكتمل تنفيذها.

طالما ستُنفَّذ الاختبارات مباشرة بعد الانتهاء من تنفيذ beforeEach، إذًا ستُنفذ قبل إعادة ضبط قاعدة البيانات.

إحدى الطرق المتبعة في معالجة الموضوع هي انتظار العمليات غير المتزامنة حتى تنتهي باستعمال التابع Promise.all:

beforeEach(async () => {
  await Note.deleteMany({})

  const noteObjects = helper.initialNotes
    .map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

إن الأسلوب المتبع متقدم على الرغم من مظهره المختصر. نلاحظ كيف يُسند المتغير noteObjects إلى مصفوفة من كائنات Mongoose التي أنشئت باستخدام الدالة البانية Note من أجل كل ملاحظة موجودة في المصفوفة helper.initialNotes. يُنشئ السطر التالي من الشيفرة مصفوفة جديدة تتضمن الوعود التي أُنشئت بدورها باستدعاء التابع save من أجل كل عنصر من عناصر المصفوفة noteOjects. بمعنًى آخر، تمثل المصفوفة مصفوفةً لتخزين كل العناصر ضمن قاعدة البيانات.

يستخدم التابع Promise.all لتحويل مصفوفة من الوعود إلى وعد وحيد يتحقق بمجرد تحقق كل الوعود في المصفوفة التي تمرر إلى التابع. ينتظر الأمر (await Promise.all(promiseArray في السطر الأخير من الشيفرة كل وعد سيخزن ملاحظة في قاعدة البيانات حتى يتحقق، ويعني هذا أن قاعدة البيانات قد أعيد ضبطها.

اقتباس

يمكن أن تصل إلى القيم التي يعيدها كل وعد في المصفوفة عندما تستخدم التابع Promise.all. فلو انتظرنا حتى تتحقق الوعود باستعمال الأمر (const results = await Promise.all(promiseArray، ستعيد العملية مصفوفة تحتوي على القيم التي يعيدها كل وعد، وستظهر بنفس الترتيب الذي تتخذه الوعود في المصفوفة.

ينفذ التابع Promise.all الوعود التي تمرر إليه بالتوازي. وبالتالي إن أردنا تنفيذ الوعود بترتيب معين ستظهر المشاكل. يمكن أن تنفذ العمليات في حالات كهذه ضمن كتلة for…of التي تضمن ترتيبًا محددًا للتنفيذ:

beforeEach(async () => {
  await Note.deleteMany({})

  for (let note of helper.initialNotes) {
    let noteObject = new Note(note)
    await noteObject.save()
  }
})

قد تقود الطبيعة غير المتزامنة للغة JavaScript إلى سلوك مفاجئ، ولهذا يجب الانتباه والحرص عند استخدام async/await. وعلى الرغم من أن استعمالها يسهل التعامل مع الوعود، لكن يجدر بنا فهم الآلية التي تعمل بها الوعود بشكل جيد.

التمارين 4.8 - 4.12

ملاحظة: نستخدم في التمارين تابع المطابقة toContain في أماكن عدة لنتأكد من وجود عنصر معين في مصفوفة. يستخدم التابع السابق العامل (===) في الموازنة والمطابقة بين العناصر، وهذا غير ملائم في العديد من الحالات بما فيها الكشف عن تطابق كائنين. بينما يعتبر تابع المطابقة toContainEqual ملائمًا في معظم الحالات للمقارنة بين الكائنات ضمن المصفوفات. لا يتحقق نموذج الحل من الكائنات في المصفوفات باستخدام توابع المطابقة، لذا فاستخدام هذا الأسلوب ليس ملزمًا في حل التمارين.

تحذير: إن وجدت نفسك قد استخدمت async/await مع then في نفس الشيفرة فهذا دليل على ارتكابك خطأً ما. استخدم أحد الأسلوبين وليس كلاهما.

4.8 اختبارات على قائمة المدونات: الخطوة 1

استخدم الحزمة supertest لكتابة اختبار يرسل طلب HTTP GET إلى العنوان api/blogs/. وتحقق أن تطبيق قائمة المدونات سيعيد العدد الصحيح من منشورات المدونات بصيغة JSON.

حالما ينتهي الاختبار، أعد كتابة معالج المسار مستخدمًا async/await بدلًا من الوعود.

لاحظ أنه عليك إجراء نفس التغيرات التي أجريناها سابقًا على شيفرتك كتحديد بيئة الاختبار، لتتمكن من كتابة اختبارات يتعامل كل منها مع قاعدة بيانات خاصة به.

ملاحظة: قد تواجه التحذير التالي عندما تنفذ الاختبار:

blog_test_warn_03.png

إن حدث ذلك، اتبع التعليمات وانشئ ملفًا جديدًا باسم jest.config.js عند جذر المشروع بحيث يحتوي الشيفرة التالية:

module.exports = {
  testEnvironment: 'node'
}

ملاحظة: يفضل عند كتابة الاختبارات أن لا تنفذها دفعة واحدة. اختبر فقط ذلك الذي تعمل عليه.

4.9 اختبارات على قائمة المدونات: الخطوة 2 *

اكتب اختبارًا يتحقق من أن اسم المعرِّف الفريد لكل منشور في المدونة هو id. ستسمي قاعدة البيانات هذه الخاصية بالاسم id_ بشكل افتراضي. يمكن تحقيق ذلك بسهولة عند استخدام تابع المطابقة toBeDefined العائد للمكتبة.

أجري التعديلات المناسبة على الشيفرة حتي يُنفذ الاختبار بنجاح. قد يكون التابع toJSON الذي قدمناه في القسم3، المكان الأنسب لتعريف المعامل id.

4.10 اختبارات على قائمة المدونات: الخطوة 3

اكتب اختبارًا تتحقق فيه أن طلب HTTP POST إلى العنوان api/blogs/، سينشئ بنجاح منشورًا جديدًا. تحقق على الأقل أن العدد الكلي للمنشورات في المدونة قد ازداد بمقدار 1. كما يمكنك التحقق أيضًا، أن محتوى المنشور قد حُفظ بالشكل الصحيح في قاعدة البيانات.

حالما ينجح الاختبار، أعد كتابة الشيفرة مستخدمًا async/await بدلًا من الوعود.

4.11 اختبارت على قائمة المدونات: الخطوة 4 *

اكتب اختبارًا تتحقق فيه من وجود الخاصية likes في الطلب، واجلعها 0 إن لم تكن موجودة. لا تختبر بقية خصائص المدونات التي أنشئت.

عدل في الشيفرة حتى تنجز الاختبار بنجاح.

4.12 اختبارات على قائمة المدونات: الخطوة 5 *

اكتب اختبارًا متعلقًا بإنشاء مدونة جديدة من خلال الوجهة api/blogs/. حيث يتحقق من وجود الخاصيتين title وurl ضمن بيانات الطلب. فإن لم يعثر عليهما ستستجيب الواجهة الخلفية برمز الحالة 400 (طلب سيء).

إعادة كتابة الاختبارت

لم تكتمل تغطيتنا بعد لموضوع الاختبارات. فلم نختبر طلبات مثل GET /api/notes/:id وDELETE /api/notes/:id عندما ترسل بمعرف غير صالح. كما تحتاج عملية تنظيم وتجميع الاختبارات إلى بعض التحسينات، حيث كتبت جميعها في المستوى الأعلى نفسه ضمن ملف الاختبار.

قد تتحسن قابلية قراءة الاختبارات عندما نجمع الاختبارات المترابطة باستخدام كتل الوصف (descripe blocks):

const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')
const app = require('../app')
const api = supertest(app)

const Note = require('../models/note')

beforeEach(async () => {
  await Note.deleteMany({})

  const noteObjects = helper.initialNotes
    .map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

describe('when there is initially some notes saved', () => {
  test('notes are returned as json', async () => {
    await api
      .get('/api/notes')
      .expect(200)
      .expect('Content-Type', /application\/json/)
  })

  test('all notes are returned', async () => {
    const response = await api.get('/api/notes')

    expect(response.body).toHaveLength(helper.initialNotes.length)
  })

  test('a specific note is within the returned notes', async () => {
    const response = await api.get('/api/notes')

    const contents = response.body.map(r => r.content)
    expect(contents).toContain(
      'Browser can execute only Javascript'
    )
  })
})

describe('viewing a specific note', () => {
  test('succeeds with a valid id', async () => {
    const notesAtStart = await helper.notesInDb()

    const noteToView = notesAtStart[0]

    const resultNote = await api
      .get(`/api/notes/${noteToView.id}`)
      .expect(200)
      .expect('Content-Type', /application\/json/)

    const processedNoteToView = JSON.parse(JSON.stringify(noteToView))

    expect(resultNote.body).toEqual(processedNoteToView)
  })

  test('fails with statuscode 404 if note does not exist', async () => {
    const validNonexistingId = await helper.nonExistingId()

    console.log(validNonexistingId)

    await api
      .get(`/api/notes/${validNonexistingId}`)
      .expect(404)
  })

  test('fails with statuscode 400 id is invalid', async () => {
    const invalidId = '5a3d5da59070081a82a3445'

    await api
      .get(`/api/notes/${invalidId}`)
      .expect(400)
  })
})

describe('addition of a new note', () => {
  test('succeeds with valid data', async () => {
    const newNote = {
      content: 'async/await simplifies making async calls',
      important: true,
    }

    await api
      .post('/api/notes')
      .send(newNote)
      .expect(200)
      .expect('Content-Type', /application\/json/)


    const notesAtEnd = await helper.notesInDb()
    expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)

    const contents = notesAtEnd.map(n => n.content)
    expect(contents).toContain(
      'async/await simplifies making async calls'
    )
  })

  test('fails with status code 400 if data invaild', async () => {
    const newNote = {
      important: true
    }

    await api
      .post('/api/notes')
      .send(newNote)
      .expect(400)

    const notesAtEnd = await helper.notesInDb()

    expect(notesAtEnd).toHaveLength(helper.initialNotes.length)
  })
})

describe('deletion of a note', () => {
  test('succeeds with status code 204 if id is valid', async () => {
    const notesAtStart = await helper.notesInDb()
    const noteToDelete = notesAtStart[0]

    await api
      .delete(`/api/notes/${noteToDelete.id}`)
      .expect(204)

    const notesAtEnd = await helper.notesInDb()

    expect(notesAtEnd).toHaveLength(
      helper.initialNotes.length - 1
    )

    const contents = notesAtEnd.map(r => r.content)

    expect(contents).not.toContain(noteToDelete.content)
  })
})

afterAll(() => {
  mongoose.connection.close()
})

جُمّعت مخرجات الاختبارات وفقًا لكتل الوصف:

test_output_004.png

هنالك متسع للتحسينات أيضًا، لكن لابد من المضي قُدمًا.

إن هذه الطريقة في الاختبار التي تعتمد على إجراء طلبات HTTP وتحري قواعد البيانات باستخدام Mongoose، ليست الطريقة الوحيدة وليست الأفضل في إنجاز اختبارات التكامل على مستوى الواجهة البرمجية لتطبيقات الخوادم. في الواقع لا توجد طريقة مثلى معتمدة عالميًا لكتابة الاختبارات، لأنها تعتمد على طبيعة التطبيق المختبر والموارد المتاحة.

يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-6 ضمن المستودع الخاص بالتطبيق على GitHub.

التمارين 4.13 - 4.14

4.13 التوسع في قائمة المدونات: خطوة 1

أضف إلى التطبيق وظيفة حذف منشور واحد. استخدم العبارة async/await. إجعل عملك متوافقًا مع REST عندما تعرّف الواجهة البرمجية لطلبات HTTP.

أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى.

4.14 التوسع في قائمة المدونات: خطوة 2

أضف إلى التطبيق وظيفة تعديل منشور واحد. استخدم العبارة async/await.

أهم ما يتطلبه التطبيق هو تحديث عدد الإعجابات للمنشور. يمكنك إضافة هذه الوظيفة كما حدثنا الملاحظات في القسم 3.

أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى.

ترجمة -وبتصرف- للفصل testing the backend من سلسلة Deep Dive Into Modern Web Development





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن