وصفات الاختبار في React

من موسوعة حسوب
مراجعة 04:44، 26 مارس 2021 بواسطة رقية-بورية (نقاش | مساهمات) (رفع المحتوى)
(فرق) → مراجعة أقدم | المراجعة الحالية (فرق) | مراجعة أحدث ← (فرق)

تحتوي هذه الصفحة على أكثر أنماط الاختبار شيوعًا لمكونات ريآكت.

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

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

محتويات الصفحة:

  • إعداد وإنهاء الاختبار
  • دالّة act()‎
  • التصيير (Rendering)
  • جلب البيانات (Data Fetching)
  • مُحاكاة الوحدات (Mocking Modules)
  • الأحداث (Events)
  • المؤقتات (Timers)
  • اختبار التغيير (Snapshot Testing)
  • التصيير المتعددة (Multiple Renderers)
  • سيناريو آخر؟

إعداد وإنهاء الاختبار

ستحتاج عند إجراء كل اختبار إلى تصيير وعرض هيكل ريآكت الشجري وتحويله إلى عنصر نموذج كائن المستند (DOM) مُرتبط بصفحة التطبيق. تتمثل أهمية هذه النقطة في أنها خطوة أساسية تحتاجها عناصر الصفحة لاستقبال أحداث نموذج كائن المستند (DOM events). بعد ذلك سترغب في تنظيف مُخلفات الاختبار بمجرد انتهائه من الهيكل الشجري للصفحة.

يُعد استخدام زوج من دوال beforeEach و afterEach أكثر الطرق شيوعًا لفعل ذلك إذ يمكن لتلك الطريقة تصيير وعزل تأثيرات الاختبار عن النسخة النهائية من الصفحة:

import { unmountComponentAtNode } from "react-dom";

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

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

دالّة act()

يمكنك اعتبار مهام مثل تصيير المكونات واختبار تفاعلات المستخدم وجلب البيانات كوحدات لتفاعل المستخدم مع التطبيق أثناء تصميمك اختبارات واجهة المستخدم. توفر أدوات react-dom/test-utils من ريآكت دالّة مُساعدة تُسمى act() تحرص على تصيير التحديثات المتعلقة بتلك العناصر وتطبيقها على نموذج كائن المستند قبل إجراء أي تقييم:

act(() => {
  // تصيير المكونات
});
// تقييم النتائج

يساعد ذلك في جعل اختبارك يعمل بشكل مشابه لكيفية تفاعل المستخدم معه عند استخدام التطبيق. ستستخدم بقية الأمثلة التالية دالّة act() لاستغلال تلك النقطة.

من ناحية أخرى فقد تشعر بأن استخدام دالّة act() عملية مُضنية. لتفادي التكرار ننصح باستخدام مكتبات مثل مكتبة اختبار ريآكت React Testing Library إذ تستخدم مكوناتها دالة act() بشكل مُدمج.

ملاحظة: أتى اسم دالة act() من اسم نمط اختبار Arrange-Act-Assert.

التصيير

سترغب في العادة في اختبار إذا ما كانت المكونات تُعالج المعلومات المُدخلة بشكل صحيح. لنجرب اختبار مكون بسيط يعالج رسالة بناءً على مُدخلات معينة:

// hello.js

import React from "react";

export default function Hello(props) {
  if (props.name) {
    return <h1>Hello, {props.name}!</h1>;
  } else {
    return <span>Hey, stranger</span>;
  }
}

يمكننا تصميم اختبار لذلك المكون على النحو التالي:

// hello.test.js

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Hello from "./hello";

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("renders with or without a name", () => {
  act(() => {
    render(<Hello />, container);
  });
  expect(container.textContent).toBe("Hey, stranger");

  act(() => {
    render(<Hello name="Jenny" />, container);
  });
  expect(container.textContent).toBe("Hello, Jenny!");

  act(() => {
    render(<Hello name="Margaret" />, container);
  });
  expect(container.textContent).toBe("Hello, Margaret!");
});

جلب البيانات

يمكنك مُحاكاة الاستعلامات باستخدام بيانات مُزيفة عوضًا عن استخدام واجهة برمجة (API) حقيقية في اختباراتك. تساعد هذه الطريقة في منع انهيار الاختبارات بسبب عدم القدرة على الوصول إلى خادم البيانات البعيد كما تسرّع من تصيير هذه الاختبارات.

ملاحظة: قد ترغب أحيانًا في إجراء اختبارات كاملة فرعية باستخدام إطار عمل يدعم إنشاء مسارات اختبار كاملة (end-to-end) لتحديد إذا ما كان التطبيق كاملًا يعمل بشكل صحيح كوحدة واحدة.

// user.js

import React, { useState, useEffect } from "react";

export default function User(props) {
  const [user, setUser] = useState(null);

  async function fetchUserData(id) {
    const response = await fetch("/" + id);
    setUser(await response.json());
  }

  useEffect(() => {
    fetchUserData(props.id);
  }, [props.id]);

  if (!user) {
    return "loading...";
  }

  return (
    <details>
      <summary>{user.name}</summary>
      <strong>{user.age}</strong> years old
      <br />
      lives in {user.address}
    </details>
  );
}

يمكننا اختبار المكون أعلاه على النحو التالي:

// user.test.js

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import User from "./user";

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("renders user data", async () => {
  const fakeUser = {
    name: "Joni Baez",
    age: "32",
    address: "123, Charming Avenue"
  };
  jest.spyOn(global, "fetch").mockImplementation(() =>
    Promise.resolve({
      json: () => Promise.resolve(fakeUser)
    })
  );

  // استخدم نسخة غير متزامنة من دالّة act لتطبيق ناتج العملية المُنفذة (promise)
  await act(async () => {
    render(<User id="123" />, container);
  });

  expect(container.querySelector("summary").textContent).toBe(fakeUser.name);
  expect(container.querySelector("strong").textContent).toBe(fakeUser.age);
  expect(container.textContent).toContain(fakeUser.address);

  // احذف عملية المُحاكاة للتأكد من بقاء الاختبار معزولًا
  global.fetch.mockRestore();
});

مُحاكاة الوحدات (Mocking Modules)

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

لنفترض أن تطبيقك يتضمن مكوِّن جهات اتصال Contact يحتوي على ميزة حفظ الموقع باستخدام مكون داخلي مرتبط بخدمة خرائط جوجل GoogleMap:

// map.js

import React from "react";

import { LoadScript, GoogleMap } from "react-google-maps";
export default function Map(props) {
  return (
    <LoadScript id="script-loader" googleMapsApiKey="YOUR_API_KEY">
      <GoogleMap id="example-map" center={props.center} />
    </LoadScript>
  );
}

// contact.js

import React from "react";
import Map from "./map";

export default function Contact(props) {
  return (
    <div>
      <address>
        Contact {props.name} via{" "}
        <a data-testid="email" href={"mailto:" + props.email}>
          email
        </a>
        or on their <a data-testid="site" href={props.site}>
          website
        </a>.
      </address>
      <Map center={props.center} />
    </div>
  );
}

إذا لم تكن ترغب بتحميل هذا المكون ضمن اختبارك فيمكنك استبدال مُحاكاة ذلك الجزء عبر استخدام مكون مزيف وإجراء الاختبار:

// contact.test.js

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Contact from "./contact";
import MockedMap from "./map";

jest.mock("./map", () => {
  return function DummyMap(props) {
    return (
      <div data-testid="map">
        {props.center.lat}:{props.center.long}
      </div>
    );
  };
});

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("should render contact information", () => {
  const center = { lat: 0, long: 0 };
  act(() => {
    render(
      <Contact
        name="Joni Baez"
        email="test@example.com"
        site="http://test.com"
        center={center}
      />,
      container
    );
  });

  expect(
    container.querySelector("[data-testid='email']").getAttribute("href")
  ).toEqual("mailto:test@example.com");

  expect(
    container.querySelector('[data-testid="site"]').getAttribute("href")
  ).toEqual("http://test.com");

  expect(container.querySelector('[data-testid="map"]').textContent).toEqual(
    "0:0"
  );
});

الأحداث

لكي تختبر تفاعل المستخدم مع صفحات تطبيقك يُوصى باستخدام أحداث حقيقة على عناصر نموذج كائن المستند (DOM) ثم تقييم تلك النتائج. على سبيل المثال ألقِ نظرة على مكوِّن زر التبديل (مكون باسم Toggle) التالي:

// toggle.js

import React, { useState } from "react";

export default function Toggle(props) {
  const [state, setState] = useState(false);
  return (
    <button
      onClick={() => {
        setState(previousState => !previousState);
        props.onChange(!state);
      }}
      data-testid="toggle"
    >
      {state === true ? "Turn off" : "Turn on"}
    </button>
  );
}

يمكننا تصميم اختبار ملائم على النحو التالي:

// toggle.test.js

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Toggle from "./toggle";

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("changes value when clicked", () => {
  const onChange = jest.fn();
  act(() => {
    render(<Toggle onChange={onChange} />, container);
  });

  // استهدف الزر المُحدد وحاكي عملية النقر عليه
  const button = document.querySelector("[data-testid=toggle]");
  expect(button.innerHTML).toBe("Turn on");

  act(() => {
    button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(onChange).toHaveBeenCalledTimes(1);
  expect(button.innerHTML).toBe("Turn off");

  act(() => {
    for (let i = 0; i < 5; i++) {
      button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    }
  });

  expect(onChange).toHaveBeenCalledTimes(6);
  expect(button.innerHTML).toBe("Turn on");
});

يمكنك قراءة المزيد من التفاصيل عن أحداث نموذج كائن المستند المختلفة في توثيق شبكة مطوري موزيلا MDN. أيضًا يجدر بنا تذكيركم بتمرير خاصية { bubbles: true } لكل حدث تُنشئه لكي تتمكن ريآكت من رصده كونه يربط الأحداث تلقائيًا بجذر هيكل المستند.

ملاحظة: توفر مكتبات اختبار ريآكت أدوات مساعدة مُختصرة لتفعيل الأحداث المُرتبطة بالصفحة.

المؤقتات

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

// card.js

import React, { useEffect } from "react";

export default function Card(props) {
  useEffect(() => {
    const timeoutID = setTimeout(() => {
      props.onSelect(null);
    }, 5000);
    return () => {
      clearTimeout(timeoutID);
    };
  }, [props.onSelect]);

  return [1, 2, 3, 4].map(choice => (
    <button
      key={choice}
      data-testid={choice}
      onClick={() => props.onSelect(choice)}
    >
      {choice}
    </button>
  ));
}

يمكننا تصميم اختبارات هذا المكوّن عبر استخدام ميزة مُحاكاة المؤقتات في مكتبة Jest واختبار حالاته المتعددة كما في المثال التالي:

// card.test.js

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Card from "./card";

jest.useFakeTimers();

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("should select null after timing out", () => {
  const onSelect = jest.fn();
  act(() => {
    render(<Card onSelect={onSelect} />, container);
  });

  // قدِّم الوقت بمقدار 100 مللي ثانية
  act(() => {
    jest.advanceTimersByTime(100);
  });
  expect(onSelect).not.toHaveBeenCalled();

  // قدِّم الوقت مجددًا بمقدار 5 ثوان
  act(() => {
    jest.advanceTimersByTime(5000);
  });
  expect(onSelect).toHaveBeenCalledWith(null);
});

it("should cleanup on being removed", () => {
  const onSelect = jest.fn();
  act(() => {
    render(<Card onSelect={onSelect} />, container);
  });
  act(() => {
    jest.advanceTimersByTime(100);
  });
  expect(onSelect).not.toHaveBeenCalled();

  // unmount the app
  act(() => {
    render(null, container);
  });
  act(() => {
    jest.advanceTimersByTime(5000);
  });
  expect(onSelect).not.toHaveBeenCalled();
});

it("should accept selections", () => {
  const onSelect = jest.fn();
  act(() => {
    render(<Card onSelect={onSelect} />, container);
  });

  act(() => {
    container
      .querySelector("[data-testid='2']")
      .dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(onSelect).toHaveBeenCalledWith(2);
});

يمكنك استخدام مؤقتات مُزيفة في اختبارات معينة فقط، وكما هو ظاهر في المثال أعلاه فقد فعَّلنها عبر استخدام دالّة jest.useFakeTimers(). الهدف الرئيسي من فعل ذلك هو تفادي انتظار تلك الثواني الخمس قبل تنفيذ التعليمات البرمجية المُحددة كما لن تحتاج إلى كتابة تعليمات معقدة فقط لأجل الاختبار.

اختبار التغيير

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

يوضح المثال التالي عملية تصيير مكون ما وتنسيق أكواد HTML المُنتجة باستخدام حزمة pretty قبل حفظها على هيئة كود مصدري:

// hello.test.js, again

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import pretty from "pretty";

import Hello from "./hello";

let container = null;
beforeEach(() => {
  // اختر عنصر DOM كهدف للتصيير
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // احذف المخلفات عند الخروج من الاختبار
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("should render a greeting", () => {
  act(() => {
    render(<Hello />, container);
  });

  expect(
    pretty(container.innerHTML)
  ).toMatchInlineSnapshot(); /* ... ستملأ مكتبة jest محتويات هذا القسم تلقائيًا ... */

  act(() => {
    render(<Hello name="Jenny" />, container);
  });

  expect(
    pretty(container.innerHTML)
  ).toMatchInlineSnapshot(); /* ... ستملأ مكتبة jest محتويات هذا القسم تلقائيًا ... */

  act(() => {
    render(<Hello name="Margaret" />, container);
  });

  expect(
    pretty(container.innerHTML)
  ).toMatchInlineSnapshot(); /* ... ستملأ مكتبة jest محتويات هذا القسم تلقائيًا ... */
});

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

التصيير المتعدد

قد تحتاج أحيانًا إلى إجراء اختبار على مكوِّن يستخدم عدة خطوط تصيير. على سبيل المثال عند إجراء اختبار مقارن (snapshot test) على مكوِّن باستخدام حزمة تصيير react-test-renderer والتي تستخدم بدورها دالّة ReactDOM.render داخل مكوِّن فرعي لتصيير محتوى ما. في مثل هذه الحالات النادرة يمكنك إحاطة البيانات المُحدثة باستخدام دوال act() مرتبطة بكل عملية تصيير كما هو موضح أدناه:

import { act as domAct } from "react-dom/test-utils";
import { act as testAct, create } from "react-test-renderer";
// ...
let root;
domAct(() => {
  testAct(() => {
    root = create(<App />);
  });
});
expect(root).toMatchSnapshot();

حالات شائعة أخرى

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

ترجمة -وبتصرف- للمقال Testing Recipes، من توثيق ريآكت