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

من موسوعة حسوب

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

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

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

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

ستحتاج عند إجراء كلّ اختبار إلى تصيير وعرض هيكل ريآكت الشجري وتحويله إلى عنصر نموذج كائن المستند (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، من توثيق ريآكت