full_stack_101 اختبار تطبيقات React باستعمال Jest ومكتبة React Testing Library


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

توجد طرق عدة لاختبار تطبيقات React. سنلقي نظرة عليها في المادة القادمة.

نضيف الاختبارات عبر مكتبة الاختبارت Jest التي طورتها Facebook والتي استخدمناها في القسم السابق. تُهيئ Jest افتراضيًا في التطبيقات التي تبنى باستخدام create-react-apps.

بالإضافة إلى Jest، سنحتاج إلى مكتبة اختبارت أخرى تساعدنا في تصيير المكوِّنات لأغراض الاختبارات. إن أفضل خيار متاح أمامنا حاليًا هي مكتبة اختبارات React تدعى react-testing-library والتي زادت شعبيتها كثيرًا في الآونة الأخيرة.

إذًا سنثبت هذه المكتبة بتنفيذ الأمر التالي:

npm install --save-dev @testing-library/react @testing-library/jest-dom

سنكتب أولًا اختبارات للمكوِّن المسؤول عن تصيير الملاحظة:

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

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

لاحظ أن للعنصر li صنف تنسيق CSS اسمه note، يستخدم للوصول إلى المكوِّن في اختباراتنا.

تصيير المكوّن للاختبارات

سنكتب اختبارنا في الملف Note.test.js الموجود في نفس مجلد المكوَّن. يتحقق الاختبار الأول من أن المكوّن سيصيّر محتوى الملاحظة:

import React from 'react'
import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const component = render(
    <Note note={note} />
  )

  expect(component.container).toHaveTextContent(
    'Component testing is done with react-testing-library'
  )
})

يصيّر الاختبار المكوِّن بعد التهيئة الأولية باستخدام التابع render العائد للمكتبة react-testing-library:

const component = render(
  <Note note={note} />
)

تُصيّر مكوِّنات React عادة إلى DOM (نموذج كائن document). لكن التابع render سيصيرها إلى تنسيق مناسب للاختبارات دون أن يصيّرها إلى DOM.

يعيد التابع render كائنًا له عدة خصائص، تدعى إحداها container وتحتوي كل شيفرة HTML التي يصيّرها المكوِّن.

تتأكد التعليمة expect أن المكوِّن سيصيّر النص الصحيح، وهو في حالتنا محتوى الملاحظة.

expect(component.container).toHaveTextContent(
  'Component testing is done with react-testing-library'
)

تنفيذ الاختبارات

تهيئ Create-react-app الاختبارت بحيث تُنفَّذ في وضع المراقبة افتراضيُا. ويعني ذلك أن الأمر npm test لن يتوقف بعد أن ينتهي تنفيذ الاختبار، بل ينتظر بدلًا من ذلك أية تغييرات تجري على الشيفرة. وبمجرد حفظ التغييرات الجديدة، سيُنفَّذ الاختبار تلقائيًا من جديد وهكذا.

إن أردت تنفيذ الاختبار بالوضع الاعتيادي، نفذ الأمر:

CI=true npm test

ملاحظة: من الممكن أن ترى تحذيرًا على الطرفية إن لم تكن قد ثبتت Watchman. وهو تطبيق طورته Facebook ليراقب التغيرات التي تحدث في الملفات. سيسرع التطبيق من تنفيذ الاختبارات وسيساعد في التخلص من التحذيرات التي تظهر على الشاشة في وضع المراقبة ابتداء من الإصدار Sierra على الأقل لنظام التشغيل macOS.

يمكنك أن تجد تعليمات استخدام تطبيق Watchman في أنظمة التشغيل المختلفة على الموقع الرسمي للتطبيق.

موقع ملف الاختبار

تتبع React تقليدين مختلفين (على الأقل) لاختيار مكان ملف الاختبار. وقد اتبعنا التقليد الحالي الذي ينص أن يكون ملف الاختبار في نفس مجلد المكوِّن الذي يُختبر.

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

وقد اخترنا وضع الملف في نفس مجلد المكوِّن لأن createreactapp قد هيأت التطبيق لذلك افتراضيًا.

البحث عن محتوى محدد ضمن المكوِّن

تقدم الحزمة react-testing-library طرقًا كثيرةً للبحث في محتويات المكوِّنات التي نختبرها.

لنوسع تطبيقنا قليلًا:

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const component = render(
    <Note note={note} />
  )

  // method 1
  expect(component.container).toHaveTextContent(
    'Component testing is done with react-testing-library'
  )

  // method 2
  const element = component.getByText(
    'Component testing is done with react-testing-library'
  )
  expect(element).toBeDefined()

  // method 3
  const div = component.container.querySelector('.note')
  expect(div).toHaveTextContent(
    'Component testing is done with react-testing-library'
  )
})

تستخدم الطريق الأولى التابع toHaveTextContent للبحث عن نص مطابق لكامل شيفرة HTML التي يصيّرها المكوِّن. وهذا التابع هو واحد من عدة توابع مُطابَقة تقدمها المكتبة jest-dom.

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

تقتضي الطريقة الثالثة البحث عن عنصر محدد من عناصر HTML التي يصيّرها المكوِّن باستخدام التابع querySelector الذي يستقبل كوسيط مُحدِّد CSS.

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

تنقيح الاختبارات

ستواجهنا تقليديًا العديد من المشاكل عند كتابة وتنفيذ الاختبارات. يمتلك الكائن الذي يعيده التابع render التابع debug الذي يمكن استخدامه لطباعة شيفرة HTML التي يصيّرها المكوَّن على الطرفية. لنجرب ذلك بإجراء بعض التعديلات على الشيفرة:

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const component = render(
    <Note note={note} />
  )

  component.debug()
  // ...
})

يمكنك أن ترى شيفرة HTML التي يولدها المكوِّن على الطرفية:

console.log node_modules/@testing-library/react/dist/index.js:90
  <body>
    <div>
      <li
        class="note"
      >
        Component testing is done with react-testing-library
        <button>
          make not important
        </button>
      </li>
    </div>
  </body>

يمكنك أيضًا البحث عن جزء صغير من المكوّن وطباعة شيفرة HTML التي يحتويها. نستخدم لهذا الغرض التابع prettyDOM الذي يمكن إدراجه من الحزمة testing-library/dom@ التي تُثبـت تلقائيًا مع المكتبة react-testing-library.

import React from 'react'
import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import { prettyDOM } from '@testing-library/dom'import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const component = render(
    <Note note={note} />
  )
  const li = component.container.querySelector('li')

  console.log(prettyDOM(li))})

استخدمنا هنا مُحدِّد CSS للعثور على العنصر li داخل المكوِّن، ومن ثم طباعة محتواه:

console.log src/components/Note.test.js:21
  <li
    class="note"
  >
    Component testing is done with react-testing-library
    <button>
      make not important
    </button>
  </li>

النقر على الأزرار أثناء الاختبارات

يتحقق المكون Note، بالإضافة إلى عرضه محتوى الملاحظة، أن النقر على الزر المجاور للملاحظة سيستدعي دالة معالج الحدث toggleImportance.

وللتحقق من هذه الوظيفة يمكن تنفيذ الشيفرة التالية:

import React from 'react'
import { render, fireEvent } from '@testing-library/react'import { prettyDOM } from '@testing-library/dom'
import Note from './Note'

// ...

test('clicking the button calls event handler once', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const mockHandler = jest.fn()

  const component = render(
    <Note note={note} toggleImportance={mockHandler} />
  )

  const button = component.getByText('make not important')
  fireEvent.click(button)

  expect(mockHandler.mock.calls).toHaveLength(1)
})

سنجد عدة نقاط هامة متعلقة بهذا الاختبار. فمعالج الحدث هو دالة محاكاة تُعرَّف باستخدام Jest كالتالي:

const mockHandler = jest.fn()

يعثر الاختبار على الزر عن طريق النص الذي يظهر عليه بعد تصيير المكون ومن ثم سينقره:

const button = getByText('make not important')
fireEvent.click(button)

أما آلية النقر فينفذها التابع fireEvent.

تتحقق التعليمة expect في هذا الاختبار من استدعاء دالة المحاكاة مرة واحدة فقط.

expect(mockHandler.mock.calls).toHaveLength(1)

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

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

اختبارات على المكوِّن Togglable

سنكتب عدة اختبارات للمكوِّن togglable. لنضف بداية صنف CSS الذي يدعى togglableContent إلى العنصر div الذي يعيد المكوّنات الأبناء:

const Togglable = React.forwardRef((props, ref) => {
  // ...

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>
          {props.buttonLabel}
        </button>
      </div>
      <div style={showWhenVisible} className="togglableContent">        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

فيما يلي سنجد شيفرة الاختبارات التي سنجريها:

import React from 'react'
import '@testing-library/jest-dom/extend-expect'
import { render, fireEvent } from '@testing-library/react'
import Togglable from './Togglable'

describe('<Togglable />', () => {
  let component

  beforeEach(() => {
    component = render(
      <Togglable buttonLabel="show...">
        <div className="testDiv" />
      </Togglable>
    )
  })

  test('renders its children', () => {
    expect(
      component.container.querySelector('.testDiv')
    ).toBeDefined()
  })

  test('at start the children are not displayed', () => {
    const div = component.container.querySelector('.togglableContent')

    expect(div).toHaveStyle('display: none')
  })

  test('after clicking the button, children are displayed', () => {
    const button = component.getByText('show...')
    fireEvent.click(button)

    const div = component.container.querySelector('.togglableContent')
    expect(div).not.toHaveStyle('display: none')
  })

})

تُستدعى الدالة beforeEach قبل كل اختبار، ومن ثم تصيّر المكوِّن Togglable إلى المتغير component.

يتحقق الاختبار الأول أن المكوّن Togglable سيصيّر المكون الابن <div className="testDiv" /‎>. بينما تستخدم بقية الاختبارات التابع toHaveStyle للتحقق أن المكوِّن الابن للمكوِّن Togglable ليس مرئيًا بشكل افتراضي، بالتحقق أن تنسيق العنصر div يتضمن الأمر { display: 'none' }. اختبار آخر يتحقق من ظهور المكوّن عند النقر على الزر، أي بمعنًى آخر، لم يعد التنسيق الذي يسبب اختفاء المكوِّن مُسندًا إليه. وتذكر أن البحث عن الزر يجري اعتمادًا على النص الذي كُتب عليه، كما يمكن إيجاده اعتمادًا على مُحدِّد CSS.

const button = component.container.querySelector('button')

يحتوي المكوِّن على زرين، وطالما أن التابع querySelector سيعيد الزر الأول الذي يتطابق مع معيار البحث، فسنكون قد حصلنا على الزر المطلوب مصادفةً.

لنكتب اختبارًا يتحقق أن المحتوى المرئي يمكن أن يُخفى بالنقر على الزر الثاني للمكوِّن:

test('toggled content can be closed', () => {
  const button = component.container.querySelector('button')
  fireEvent.click(button)

  const closeButton = component.container.querySelector(
    'button:nth-child(2)'
  )
  fireEvent.click(closeButton)

  const div = component.container.querySelector('.togglableContent')
  expect(div).toHaveStyle('display: none')
})

عرّفنا محدِّد تنسيق CSS لكي نستخدمه في إعادة الزر الثاني (button:nth-child(2. فليس من الحكمة الاعتماد على ترتيب الزر في المكوِّن، ومن الأجدى أن نجد الزر بناء على النص الذي يظهر عليه.

test('toggled content can be closed', () => {
  const button = component.getByText('show...')
  fireEvent.click(button)

  const closeButton = component.getByText('cancel')
  fireEvent.click(closeButton)

  const div = component.container.querySelector('.togglableContent')
  expect(div).toHaveStyle('display: none')
})

وتذكر أن التابع getByText الذي استخدمناه سابقًا هو واحد من توابع كثيرة للاستقصاء تقدمها المكتبة react-testing-library.

اختبار النماذج

لقد استخدمنا للتو الدالة fireEvent في اختباراتنا السابقة لتنفيذ شيفرة نقر الزر.

const button = component.getByText('show...')
fireEvent.click(button)

نستخدم عمليًا تلك الدالة fireEvent لإنشاء حدث النقر على زر المكوِّن. ويمكننا كذلك محاكاة إدخال نص باستخدامها أيضًا.

لنجر اختبارًا على المكوّن NoteForm. تمثل الشيفرة التالية شيفرة هذا المكوِّن:

import React, { useState } from 'react'

const NoteForm = ({ createNote }) => {
  const [newNote, setNewNote] = useState('')

  const handleChange = (event) => {
    setNewNote(event.target.value)
  }

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

    setNewNote('')
  }

  return (
    <div className="formDiv">      
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <button type="submit">save</button>
      </form>
    </div>
  )
}

export default NoteForm

يعمل النموذج باستدعاء الدالة createNote التي تُمرر إليه كخاصية تحمل تفاصيل الملاحظة الجديدة.

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

import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import NoteForm from './NoteForm'

test('<NoteForm /> updates parent state and calls onSubmit', () => {
  const createNote = jest.fn()

  const component = render(
    <NoteForm createNote={createNote} />
  )

  const input = component.container.querySelector('input')
  const form = component.container.querySelector('form')

  fireEvent.change(input, { 
    target: { value: 'testing of forms could be easier' } 
  })
  fireEvent.submit(form)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing of forms could be easier' )
})

يمكننا محاكاة الكتابة إلى حقول النصوص بإنشاء الحدث change لكل حقل، ثم تعريف كائن يحتوي على النص الذي سيكتب في حقل النص.

سيُرسل النموذج بمحاكاة عمل الحدث submit الذي يستخدم لإرسال نموذج.

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

مدى الاختبار

يمكننا أن نجد بسهولة المدى الذي تغطيه اختباراتنا (coverage) بتنفيذها من خلال الأمر:

CI=true npm test -- --coverage

test_covarge_comand_01.png

ستجد تقرير HTML بسيط حول مدى التغطية في المجلد coverage/lcov-report. سيخبرنا التقرير مثلًا عن عدد الأسطر البرمجية التي لم تُختبر في المكوّن:

test_covarge_html_02.png

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

التمارين 5.13 - 5.16

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

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

اضف أصناف CSS إلى المكوّن للمساعدة في الاختبار عند الحاجة.

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

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

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

اكتب اختبارًا يتأكد أن معالج الحدث الذي يُمّرر إلى المكون كخاصية، سيُستدعى مرتين تمامًا عندما يٌنقر الزر like مرتين.

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

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

فلو حملت الصفة id للعنصر input مثلًا القيمة "author":

<input
  id='author'
  value={author}
  onChange={() => {}}
/>

يمكنك الوصول إلى محتوى العنصر بالأمر:

const author = component.container.querySelector('#author')

اختبارات تكامل الواجهة الأمامية

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

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

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

اختبارات اللقطات البرمجية

تقدم Jest بديلًا مختلفًا تمامًا عن الاختبارات التقليدية ويدعى اختبارات اللقطات snapshot. والميزة الأهم في هذه اللقطات، أنه ليس على المطورين كتابة أية اختبارت بأنفسهم، وكل ما عليهم هو اختيار هذا الاختبار.

يعتمد المبدأ الأساسي لهذه الاختبارات على موازنة شيفرة HTML التي يُعرِّفها المكوّن بعد أن تتغير مع شيفرة HTML قبل التغيير.

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

ترجمة -وبتصرف- للفصل Testing react apps من سلسلة Deep Dive Into Modern Web Development





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


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



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

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

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


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

تسجيل الدخول

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


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