اذهب إلى المحتوى

الاختبار المشترك للواجهتين: الأمامية React والخلفية Node.js عبر مكتبة Cypress


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

اختبرنا حتى هذه اللحظة الواجهة الخلفية ككيان واحد على مستوى الواجهة البرمجية مستخدمين اختبارات التكامل. واختبرنا كذلك بعض مكوِّنات الواجهة الأمامية باستخدام اختبارات الأجزاء.

سنلقي نظرة تاليًا على طريقة لاختبار النظام ككل باستخدام الاختبارات المشتركة للواجهتين (end to end).

يمكننا تنفيذ الاختبارات المشتركة للواجهتين (E2E) لتطبيق الويب باستخدام متصفح ومكتبة اختبارات. وهناك العديد من المكتبات المتوفرة مثل Selenium التي يمكن استخدامها مع كل المتصفحات تقريبًا. وكخيارٍ للمتصفحات، يمكن اعتماد المتصفحات الاختبارية (Headless Browsers)، وهي متصفحات لا تقدم واجهة تخاطب رسومية. يمكن للمتصفح Chrome أن يعمل كمتصفح اختباري مثلًا.

وربما تكون اختبارات E2E هي الأكثر فائدة بين فئات الاختبارات، لأنها تختبر النظام من خلال الواجهة نفسها التي سيستعملها المستخدم الحقيقي.

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

والأسوء من ذلك كله أنها اختبارات غير مستقرة (flaky) فقد تنجح أحيانًا وتفشل في أخرى، على الرغم من عدم تغير الشيفرة.

Cypress: مكتبة الاختبارات المشتركة للواجهتين

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

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

npm install --save-dev cypress

و كذلك كتابة سكربت npm لتشغيل الاختبار:

{
  // ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 db.json",
    "cypress:open": "cypress open"  },
  // ...
}

وعلى خلاف اختبارات الأجزاء، يمكن أن تكون اختبارات Cypress في مستودع الواجهة الخلفية أو الأمامية أو في مستودعها الخاص. وتتطلب أن يكون النظام المختبر في حالة عمل، واختبارات Cypress على خلاف اختبارات تكامل الواجهة الخلفية لن تُشغِّل النظام عندما يبدأ تنفيذها.

لنكتب سكربت npm للواجهة الخلفية بحيث تعمل في وضع الاختبار. أي تكون قيمة متغير البيئة NODE_ENV هي test:

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "dev": "cross-env 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": "cross-env NODE_ENV=test jest --verbose --runInBand",
    "start:test": "cross-env NODE_ENV=test node index.js"  },
  // ...
}

عندما تبدأ الواجهتين الأمامية والخلفية بالعمل، يمكن تشغيل Cypress بتنفيذ الأمر:

npm run cypress:open

تُنشئ Cypress مجلدًا باسم "cypress". يضم هذا المجلد مجلدًا آخر باسم "integration" حيث سنضع اختباراتنا. كما تنشئ كمية من الاختبارات في المجلد "integration/examples". يمكننا حذف هذه الاختبارات وكتابة الاختبارات التي نريد في الملف note_app.spec.js:

describe('Note app', function() {
  it('front page can be opened', function() {
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2020')
  })
})

نبدأ الاختبار من النافذة المفتوحة التالية:

cypress_test_window_01.png

يفتح تشغيل الاختبار المتصفح تلقائيًا، بحيث يعرض سلوك التطبيق أثناء الاختبار:

browser_test_show_02.png

من المفترض أن تكون بنية الاختبار مألوفة بالنسبة لك. فهي تستخدم كتلة الوصف (describe) لتجميع الاختبارات التي لها نفس الغاية كما تفعل Jest. تُعرَّف حالات الاختبارات باستخدام التابع it، حيث استعارت هذا الجزء من مكتبة الاختبارات Mocha والذي تستخدمه تحت الستار.

لاحظ أن cy.visit وcy.contains هما أمران عائدان للمكتبة Cypress والغرض منهما واضح للغاية. فالأمر cy.visit يفتح عنوان الويب الذي يمرر إليه كمعامل ضمن المتصفح، بينما يبحث الأمر cy.contains عن النص الذي يُمرر إليه كمعامل.

يمكننا تعريف الاختبار باستعمال الدالة السهمية:

describe('Note app', () => {  
    it('front page can be opened', () => { 
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2020')
  })
})

تُفضِّل Mocha عدم استخدام الدوال السهمية لأنها تسبب بعض المشاكل في حالات خاصة.

إن لم يعثر cy.contains على النص الذي يبحث عنه، سيفشل الاختبار. لذا لو وسعنا الاختبار كالتالي:

describe('Note app', function() {
  it('front page can be opened',  function() {
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2020')
  })

  it('front page contains random text', function() {    
cy.visit('http://localhost:3000')    
cy.contains('wtf is this app?')  })})

سيخفق الاختبار:

cycontains_failed_03.png

لنُزِل الشيفرة التي أخفقت من الاختبار.

الكتابة في نموذج

لنوسع اختباراتنا بحيث يحاول الاختبار تسجيل الدخول إلى التطبيق. سنفترض هنا وجود مستخدم في الواجهة الخلفية باسم "mluukkai" وكلمة سره "Salainen". يبدأ الاختبار بفتح نموذج تسجيل الدخول.

describe('Note app',  function() {
  // ...

  it('login form can be opened', function() {
    cy.visit('http://localhost:3000')
    cy.contains('login').click()
  })
})

يبحث الاختبار في البداية عن زر تسجيل الدخول من خلال النص الظاهر عليه مستخدمًا الأمر cy.click. يبدأ الاختباران بفتح الصفحة http://localhost:3000، لذلك لابد من فصل الجزء المشترك في كتلة beforeEach قبل كل اختبار.

describe('Note app', function() {
  beforeEach(function() {    
      cy.visit('http://localhost:3000')
  })
  it('front page can be opened', function() {
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2020')
  })

  it('login form can be opened', function() {
    cy.contains('login').click()
  })
})

يحتوي حقل تسجيل الدخول على حقلين لإدخال النصوص، من المفترض أن يملأهما الاختبار. يسمح الأمر cy.get في البحث عن العناصر باستخدام مُحدِّد CSS. يمكننا الوصول إلى أول وآخر حقل إدخال نصي في الصفحة، ومن ثم الكتابة فيهما باستخدام الأمر cy.type كالتالي:

it('user can login', function () {
  cy.contains('login').click()
  cy.get('input:first').type('mluukkai')
  cy.get('input:last').type('salainen')})  

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

سنغير في نموذج تسجيل الدخول قليلًا:

const LoginForm = ({ ... }) => {
  return (
    <div>
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          username
          <input
            id='username'            
            value={username}
            onChange={handleUsernameChange}
          />
        </div>
        <div>
          password
          <input
            id='password'            
            type="password"
            value={password}
            onChange={handlePasswordChange}
          />
        </div>
        <button id="login-button" type="submit">          
            login
        </button>
      </form>
    </div>
  )
}

سنضيف أيضًا معرفات فريدة إلى زر الإرسال لكي نتمكن من الوصول إليه بمُعرِّفه.

سيصبح الاختبار كالتالي:

describe('Note app',  function() {
  // ..
  it('user can log in', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai') 
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()
    cy.contains('Matti Luukkainen logged in')  
  })
})

تتأكد الدالة السهمية بأن تسجيل الدخول سينجح.

لاحظ أننا وضعنا (#) قبل اسم المستخدم وكلمة المرور عندما نريد البحث عنهما بالاستعانة بالمعرف id. ذلك أن محدد التنسيق id يستخدم الرمز (#).

بعض الأشياء التي يجدر ملاحظتها

ينقر الاختبار أولًا الزر الذي يفتح نموذج تسجيل الدخول:

cy.contains('login').click()

بعد أن تُملأ حقوله، يُرسل النموذج بالنقر على الزر submit:

cy.get('#login-button').click()

يحمل كل من الزرين النص ذاته، لكنهما زران منفصلان. وكلاهما في الواقع موجود في نموذج DOM الخاص بالتطبيق، لكن أحدهما فقط سيظهر نظرًا لاستخدام التنسيق display:None.

فلو بحثنا عن الزر باستخدام نصه، سيعيد الأمر cy.contains الأول بينهما أو الزر الذي يفتح نموذج تسجيل الدخول. وسيحدث هذا حتى لو لم يكن الزر مرئيًا. لتفادي المشكلة الناتجة عن تضارب الأسماء، أعطينا زر الإرسال الاسم "login-button".

سنلاحظ مباشرة الخطأ الذي يعطيه المدقق ESlint حول المتغير cy الذي نستخدمه:

cy_lint_error_04.png

يمكن التخلص من هذا الخطأ بتثبيت المكتبة eslint-plugin-cypress كاعتمادية تطوير:

npm install eslint-plugin-cypress --save-dev

كما علينا تغيير إعدادات التهيئة في الملف eslintrc.js كالتالي:

module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "jest/globals": true,
        "cypress/globals": true    },
    "extends": [ 
      // ...
    ],
    "parserOptions": {
      // ...
    },
    "plugins": [
        "react", "jest", "cypress"    ],
    "rules": {
      // ...
    }
}

اختبار نموذج إنشاء ملاحظة جديدة

لنضف الآن اختبارًا لإنشاء ملاحظة جديدة:

describe('Note app', function() {
  // ..
  describe('when logged in', function() {
      beforeEach(function() {
          cy.contains('login').click()
          cy.get('input:first').type('mluukkai')
          cy.get('input:last').type('salainen')      
          cy.get('#login-button').click()    
      })
    it('a new note can be created', function() {      
        cy.contains('new note').click()      
        cy.get('input').type('a note created by cypress')
        cy.contains('save').click()      
        cy.contains('a note created by cypress')    
    })  
  })})

عُرِّف الاختبار ضمن كتلة وصف خاص به. يمكن فقط للمستخدمين المسجلين إضافة ملاحظة جديدة، لذلك أضفنا تسجيل الدخول إلى كتلة beforeEach.

يتوقع الاختبار وجود حقل نصي واحد لإدخال البيانات في الصفحة، لذا سيبحث عنه كالتالي:

cy.get('input')

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

many_inputs_fail_05.png

لذلك من الأفضل إعطاء عنصر الإدخال معرِّف فريد خاص به والبحث عن العنصر باستخدام معرِّفه.

ستبدو بنية الاختبار كالتالي:

describe('Note app', function() {
  // ...

  it('user can log in', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('login').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ...
    })
  })
})

ستُنفِّذ Cypress الاختبارات وفق ترتيب ظهورها في الشيفرة. ففي البداية ستنفذ المكتبة الاختبار الذي يحمل العنوان "user can log in" حيث سيسجل المستخدم دخوله. وبعدها ستُنفّذ المكتبة الاختبار الذي يحمل العنوان "a new note can be created" والذي سيجعل كتلة beforeEach تُجري تسجيل دخول جديد.

لم حدث ذلك طالما أننا سجلنا الدخول أول مرة؟ السبب أن كل اختبار سيبدأ من نقطة الصفر طالما أن المتصفح يتطلب ذلك. وسيعود المتصفح إلى حالته الأصلية بعد انتهاء الاختبار.

التحكم بحالة قاعدة البيانات

ستغدو الأمور أكثر تعقيدًا عندما يحاول الاختبار إجراء تغييرات على قاعدة بيانات الخادم. إذ ينبغي على قاعدة البيانات أن تبقى كما هي في كل مرة نجري فيها الاختبار، وذلك لتبقى الاختبارات مفيدة و سهلة التكرار. وكما هي حال اختباري الأجزاء والتكامل، تتطلب اختبارات E2E إفراغ قاعدة البيانات أو حتى إعادة تنسيقها أحيانًا قبل كل اختبار. إن التحدي مع هذا النوع من الاختبارات أنها لا تستطيع الولوج إلى قاعدة البيانات. لحل هذه المشكلة لابد من من استخدام طرفيات خدمية على الواجهة الخلفية ترتبط بها الواجهة البرمجية من أجل الاختبار. إذ يمكننا إفراغ قاعدة البيانات باستخدام تلك الطرفيات مثلًا. لننشئ الآن متحكمًا بالمسار لهذه الاختبارات:

const router = require('express').Router()
const Note = require('../models/note')
const User = require('../models/user')

router.post('/reset', async (request, response) => {
  await Note.deleteMany({})
  await User.deleteMany({})

  response.status(204).end()
})

module.exports = router

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

// ...

app.use('/api/login', loginRouter)
app.use('/api/users', usersRouter)
app.use('/api/notes', notesRouter)

if (process.env.NODE_ENV === 'test') {  const testingRouter = require('./controllers/testing')  app.use('/api/testing', testingRouter)}
app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

بعد إجراء التغييرات، يُرسل طلب HTTP-POST إلى الطرفية api/testing/reset/ لتفريغ قاعدة البيانات.

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

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

describe('Note app', function() {
   beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/testing/reset')    
       const user = {      
           name: 'Matti Luukkainen',      
           username: 'mluukkai',      
           password: 'salainen'    
       }    
       cy.request('POST', 'http://localhost:3001/api/users/', user)     
       cy.visit('http://localhost:3000')
  })

  it('front page can be opened', function() {
    // ...
  })

  it('user can login', function() {
    // ...
  })

  describe('when logged in', function() {
    // ...
  })
})

أثناء إعادة التنسيق يرسل الاختبار طلب HTTP إلى الواجهة الخلفية باستخدام cy.request. وعلى عكس ما حصل سابقًا، سيبدأ الاختبار الآن عند الواجهة الخلفية وبنفس الحالة كل مرة. حيث ستحتوي على مستخدم واحد وبدون أية ملاحظات.

لنضف اختبارًا آخر نتحقق فيه من إمكانية تغير أهمية الملاحظة. سنغيّر أولًا الواجهة الأمامية بحيث تكون الملاحظة غير مهمة افتراضيًا. أو أن الحقل "importance" يحوي القيمة (خاطئ- false).

const NoteForm = ({ createNote }) => {
  // ...

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: false    })

    setNewNote('')
  }
  // ...
} 

هناك طرق عدة لاختبار ذلك. نبحث في المثال التالي عن ملاحظة ونضغط على الزر المجاور لها "make importnant". ونتحقق بعد ذلك من احتواء الملاحظة لهذا الزر.

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    // ...

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made important', function () {
        cy.contains('another note cypress')
          .contains('make important')
          .click()

        cy.contains('another note cypress')
          .contains('make not important')
      })
    })
  })
})

يبحث الأمر الأول عن المكوِّن الذي يحتوي النص "another note cypress"، ثم عن الزر "make important" ضمنه. بعد ذلك ينقر على هذا الزر. يتحقق الأمر الثاني أن النص على الزر قد تغير إلى "make not important".

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

اختبار إخفاق تسجيل الدخول

لنكتب اختبارًا يتحقق من فشل تسجيل الدخول عندما تكون كلمة المرور خاطئة.

ستنفذ Cypress كل الاختبارات الموجودة بشكل افتراضي، وطالما أن عدد الاختبارات في ازدياد، سيؤدي ذلك إلى زيادة وقت التنفيذ. لذلك عندما نطور اختبارًا جديدًا أو نحاول إصلاح اختبار، من الأفضل تحديد هذا الاختبار فقط ليُنفّذ وذلك باستخدام الأمر it.only. ويمكننا بعدها إزالة الكلمة only عند نجاح الاختبار.

ستبدو النسخة الأولى من الاختبار كالتالي:

describe('Note app', function() {
  // ...

  it.only('login fails with wrong password', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('wrong')
    cy.get('#login-button').click()

    cy.contains('wrong credentials')
  })

  // ...
)}

يستخدم الاختبار الأمر cy.contains للتأكد من أن التطبيق سيطبع رسالة خطأ. سيصيّر التطبيق رسالة الخطأ إلى مكوِّن يمتلك صنف تنسيق CSS اسمه "error":

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className="error">      
      {message}
    </div>
  )
}

يمكننا أن نجعل الاختبار يتحقق من أن رسالة الخطأ قد صُيِّرت إلى المكوِّن الصحيح الذي يمتلك صنف CSS باسم "error".

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').contains('wrong credentials')})

نستخدم أولًا cy.get للبحث عن المكوّن الذي يمتلك صنف التنسيق "error". بعد ذلك نتحقق أن رسالة الخطأ موجودة ضمن هذا المكوِّن. وانتبه إلى استعمال العبارة 'error.' كمعامل للتابع cy.get ذلك أن محدد الأصناف في تنسيق CSS يبدأ بالمحرف (.).

كما يمكن تنفيذ ذلك باستخدام العبارة should:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials')})

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

يمكنك الاطلاع ضمن مستندات المكتبة Cypress على أكثر المفاتيح استخدامًا مع should.

يمكننا أن نتحقق مثلًا من أن رسالة الخطأ ستظهر باللون الأحمر ومحاطة بإطار:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials') 
  cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)')
  cy.get('.error').should('have.css', 'border-style', 'solid')
})

تتطلب Cypress أن تُعطى الألوان بطريقة rgb.

وطالما أن جميع الاختبارات ستجري على المكوِّن نفسه باستخدام cy.get سنسلسل الاختبارات باستخدام and.

it('login fails with wrong password', function() {
  // ...

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')
})

لننهي الاختبار بحيث يتحقق أخيرًا أن التطبيق لن يصيّر رسالة النجاح "Matti Luukkainen logged in":

it.only('login fails with wrong password', function() {
  cy.contains('login').click()
  cy.get('#username').type('mluukkai')
  cy.get('#password').type('wrong')
  cy.get('#login-button').click()

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')

  cy.get('html').should('not.contain', 'Matti Luukkainen logged in')})

لا ينبغي أن تُقيد الأمر should دائمًا بسلسلة get (أو أية تعليمات قابلة للتسلسل). كما يمكن استخدام الأمر ('cy.get('html للوصول إلى كامل محتويات التطبيق المرئية.

تجاوز واجهة المستخدم UI

كتبنا حتى اللحظة الاختبارات التالية:

describe('Note app', function() {
  it('user can login', function() {
    cy.contains('login').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  it.only('login fails with wrong password', function() {
    // ...
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('login').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ... 
    })

  })
})

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

وكما قلنا سابقًا: سيبدأ كل اختبار من الصفر، فلا تبدأ الاختبار أبدًا من حيث ينتهي الاختبار الذي يسبقه.

يقدم لنا توثيق Cypress النصيحة التالية: اختبر ثغرات تسجيل الدخول بشكل كامل- لكن لمرة واحدة!. لذا وبدلًا من تسجيل الدخول باستخدام نموذج تسجيل الدخول الموجود في كتلة الاختبارات beforeEach، تنصحنا Cypress بتجاوز واجهة المستخدم و إرسال طلب HTTP إلى الواجهة الخلفية لتسجيل الدخول. وذلك لأن تسجيل الدخول باستخدام طلب HTTP أسرع بكثير من ملئ النموذج وإرساله.

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

describe('when logged in', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/login', {      
        username: 'mluukkai', password: 'salainen'    
    }).then(response => {      
        localStorage.setItem('loggedNoteappUser', 
                              JSON.stringify(response.body))      
        cy.visit('http://localhost:3000')    })  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

يمكننا الوصول إلى استجابة الطلب cy.request باستخدام التابع then. سنجد في جوار cy.request وعدًا كغيره من أوامر Cypress. تخزّن دالة الاستدعاء تفاصيل تسجيل الدخول في الذاكرة المحلية، ثم تعيد تحميل الصفحة.

لا أهمية الآن لدخول المستخدم من خلال نموذج تسجيل الدخول في أي اختبار سنجريه على التطبيق، إذ سنستخدم شيفرة تسجيل الدخول في المكان الذي نحتاجه. وعلينا أن نجعل تسجيل الدخول كأمر خاص بالمستخدم. يُصرّح عن أوامر المستخدم الخاصة في الملف commands.js ضمن المجلد cypress/support. ستبدو شيفرة تسجيل الدخول كالتالي:

Cypress.Commands.add('login', ({ username, password }) => {
  cy.request('POST', 'http://localhost:3001/api/login', {
    username, password
  }).then(({ body }) => {
    localStorage.setItem('loggedNoteappUser', JSON.stringify(body))
    cy.visit('http://localhost:3000')
  })
})

سيكون استخدام الأمر الخاص بنا سهلًا، وستغدو الاختبارات أكثر وضوحًا:

describe('when logged in', function() {
  beforeEach(function() {
    cy.login({ username: 'mluukkai', password: 'salainen' })
  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

ينطبق ذلك تمامًا على إنشاء ملاحظة جديدة. لدينا اختبار إنشاء ملاحظة جديدة باستخدام النموذج. كما سننشئ ملاحظة جديدة ضمن كتلة beforeEach والتي سنختبر من خلالها تغيّر أهمية الملاحظة:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      cy.contains('new note').click()
      cy.get('input').type('a note created by cypress')
      cy.contains('save').click()

      cy.contains('a note created by cypress')
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

لنكتب أمرًا خاصًا بالمستخدم لإنشاء ملاحظة جديدة عبر طلب HTTP-POST:

Cypress.Commands.add('createNote', ({ content, important }) => {
  cy.request({
    url: 'http://localhost:3001/api/notes',
    method: 'POST',
    body: { content, important },
    headers: {
      'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}`
    }
  })

  cy.visit('http://localhost:3000')
})

يتوقع الأمر أن يسجل المستخدم دخوله، وأن تُخزّن التفاصيل في الذاكرة المحلية.

ستصبح الآن كتلة التنسيق كالتالي:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      // ...
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.createNote({          
            content: 'another note cypress',          
            important: false        
        })      
      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

ستجد الاختبارات و شيفرة الواجهة الأمامية ضمن الفرع part5-10 في المجلد الخاص بالتطبيق على GitHub

تغيير أهمية ملاحظة

لنلقي نظرة أخيرًا على الاختبار الذي أجريناه لتغيير أهمية الملاحظة. سنغير أولًا كتلة التنسيق لتنشئ ثلاث ملاحظات بدلًا من واحدة:

describe('when logged in', function() {
  describe('and several notes exist', function () {
    beforeEach(function () {
      cy.createNote({ content: 'first note', important: false })      
        cy.createNote({ content: 'second note', important: false })     
        cy.createNote({ content: 'third note', important: false })    
    })

    it('one of those can be made important', function () {
      cy.contains('second note')
        .contains('make important')
        .click()

      cy.contains('second note')
        .contains('make not important')
    })
  })
})

كيف يعمل الأمر cy.contains في الواقع؟

سننقر الأمر (cy.contains('second note' في مُنفّذ الاختبار (TestRunner) الخاص بالمكتبة Cypress. سنجد أن هذا الأمر سيبحث عن عنصر يحوي على النص "second note".

test_runer_06.png

بالنقر على السطر الثاني ('contains('make important. :

est_runner_make_important_07.png

سنجد الزر "make important" المرتبط بالملاحظة الثانية:

يستأنف الأمر contains الثاني عندما يُقيّد في السلسة، البحث في المكوِّن الذي وجده الأمر الأول.

فإن لم نقيّد الأمر وكتبنا بدلًا عن ذلك:

cy.contains('second note')
cy.contains('make important').click()

ستكون النتيجة مختلفة تمامًا. حيث سينقر السطر الثاني الزر المتعلق بالملاحظة الخاطئة:

test_wrong_note_08.png

عندما تنفذ الشيفرة الاختبارات، عليك التحقق من خلال مُنفِّذ الاختبارات أن الاختبار يجري على المكوِّن الصحيح.

لنعدّل المكوِّن Note بحيث يُصيّر نص الملاحظة كنعصر span:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      <span>{note.content}</span>      
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

سيفشل الاختبار!

وكما يبين لنا مُنفِّذ الاختبار، سيعيد الأمر ('cy.contains('second note المكوّن الذي يحمل النص الصحيح لكن الزر ليس موجودًا ضمنه.

test_no_content_09.png

إحدى طرق إصلاح الأمر هي التالية:

it('other of those can be made important', function () {
  cy.contains('second note').parent().find('button').click()
  cy.contains('second note').parent().find('button')
    .should('contain', 'make not important')
})

نستخدم في السطر الأول الأمر parent للوصول إلى العنصر الأب للعنصر الذي يحتوي على الملاحظة الثانية وسيجد الزر داخله. ننقر بعدها على الزر ونتحقق من تغيّر النص الظاهر عليه.

لاحظ أننا نستخدم الأمر find للبحث عن الزر. إذ لا يمكننا استخدام الأمر cy.get لأنه يبحث دائمًا عن المطلوب في الصفحة بأكملها، وسيعيد الأزرار الخمسة الموجودة.

لسوء الحظ علينا القيام ببعض عمليات النسخ واللصق في اختبارنا، لأن شيفرة البحث عن الزر الصحيح هي نفسها.

في هذه الحالات، يمكن استخدام الأمر as:

it.only('other of those can be made important', function () {
  cy.contains('second note').parent().find('button').as('theButton')
  cy.get('@theButton').click()
  cy.get('@theButton').should('contain', 'make not important')
})

سيجد السطر الأول الآن الزر الصحيح، ثم يستخدم as لتخزينه بالاسم theButton. ستتمكن السطور التالية من استخدام العنصر السابق بالشكل ('cy.get('@theButton.

تنفيذ وتنقيح الاختبارات

سنذكر أخيرًا بعض الملاحظات عن عمل Cypress وعن تنقيح الاختبارات.

يعطي شكل اختبارت Cypress انطباعًا أن الاختبارات هي شيفرة JavaScript وأنه بالإمكان تنفيذ التالي:

const button = cy.contains('login')
button.click()
debugger() 
cy.contains('logout').click()

لن تعمل الشيفرة السابقة. فعندما تنفذ Cypress اختبارًا فستضيف كل أمر cy إلى صف انتظار لتنفيذه. وعندما تُنفَّذ شيفرة الاختبار، ستُنفّذ الأوامر في الصف واحدًا بعد الآخر.

تعيد Cypress دائمًا كائنًا غير محدد، لذلك سيسبب استخدام الأمر()button.click خطأً. ولن يُوقف تشغيل المنقح تنفيذ الشيفرة في الفترة ما بين أمرين، لكن قبل تنفيذ الأوامر كلها.

تشابه أوامر Cypress الوعود. فإن أردنا الوصول إلى القيم التي تعيدها، علينا استخدام التابع then. فعلى سبيل المثال، ستطبع الشيفرة التالية عدد الأزرار في التطبيق، وستنقر الزر الأول:

it('then example', function() {
  cy.get('button').then( buttons => {
    console.log('number of buttons', buttons.length)
    cy.wrap(buttons[0]).click()
  })
})

من الممكن إيقاف تنفيذ الشيفرة باستخدام المنقح. إذ يعمل المنقح فقط، إن كانت طرفية تطوير مُنفّذ الاختبارات في Cypress مفتوحةً.

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

console_info_10.png

نفّذنا حتى اللحظة جميع اختبارات Cypress باستخدام الواجهة الرسومية لمُنفِّذ الاختبار. من الممكن أيضًا تشغيلها من سطر الأوامر. وكل ما علينا هو إضافة سكربت npm التالي:

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 --watch db.json",
    "cypress:open": "cypress open",
    "test:e2e": "cypress run"  },

e2e_test_command_line_11.png

وهكذا سنتمكن من تنفيذ الاختبارات باستخدام سطر الأوامر بتنفيذ الأمر npm test:e2e

انتبه إلى أن الفيديو الذي يوثق عملية التنفيذ سيُخزّن ضمن المجلد cypress/vedios، لذا من المحتمل أن تجعل git يتجاهل هذا الملف.

ستجد شيفرة الواجهة الأمامية والاختبارت ضمن الفرع part5-11 في المجلد المخصص للتطبيق على GitHub

التمارين 5.17 - 5.22

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

ننصح بقراءة المقالة Introduction to Cypress والتي تذكر مقدمتها ما يلي:

اقتباس

هذا هو الدليل الوحيد والأكثر أهمية عن كيفية تنفيذ الاختبارات باستخدام Cypress. إقرأه وأفهمه.

5.17 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 1

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

على شيفرة الاختبار أن تكون كالتالي:

describe('Blog app', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/testing/reset')
    cy.visit('http://localhost:3000')
  })

  it('Login form is shown', function() {
    // ...
  })
})

يجب أن تُفرّغ كتلة التنسيق beforeEach قاعدة البيانات كما فعلنا في هذا الفصل سابقًا.

5.18 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 2

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

سيتوسع شكل التطبيق ليصبح على النحو:

describe('Blog app', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/testing/reset')
    // create here a user to backend
    cy.visit('http://localhost:3000')
  })

  it('Login form is shown', function() {
    // ...
  })

  describe('Login',function() {
    it('succeeds with correct credentials', function() {
      // ...
    })

    it('fails with wrong credentials', function() {
      // ...
    })
  })
})

تمرين اختياري لعلامة أعلى: تحقق أن التنبيه سيُعرض باللون الأحمر عند إخفاق تسجيل الدخول

5.19 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 3

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

قد تكون بنية شيفرة الاختبار الآن كالتالي:

describe('Blog app', function() {
  // ...

  describe.only('When logged in', function() {
    beforeEach(function() {
      // log in user here
    })

    it('A blog can be created', function() {
      // ...
    })
  })

})

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

5.20 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 4

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

5.21 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 5

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

تمرين اختياري لعلامة أعلى: تحقق أن المستخدمين الآخرين لن يكونوا قادرين على حذفها.

5.22 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 6

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

قد يحمل هذا التمرين بعض الصعوبة. أحد الحلول المقترحة هو إيجاد جميع المدونات، ثم الموازنة بينها باستخدام التابع then.

هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين.

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


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

أفضل التعليقات

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



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...