בדיקות שעונים עם react-testing-library

11/11/2022

ספריית react-testing-library תומכת בצורה מלאה בקומפוננטות שעושות דברים ברקע בצורה אסינכרונית ובבדיקה שלהן. בפוסט זה נדבר על שעונים ונראה איך לבדוק קומפוננטות שמושפעות מזמן.

1. הקומפוננטה

אני מתחיל עם הקומפוננטה הבאה:

import { useState, useEffect } from 'react';

export default function Timer() {
  const [ms, setMs] = useState(1000);
  const [ticks, setTicks] = useState(0);
  const [ticking, setTicking] = useState(true);

  useEffect(() => {
    let clock = null;
    if (ticking) {
      clock = setInterval(() => {
        setTicks(t => t + 1);
      }, ms);
    }

    return () => {
      clearInterval(clock);
    };
  }, [ticking, ms]);

  function start() {
    setTicking(true);
  }

  function stop() {
    setTicking(false);
  }

  return (
    <div>
      <p>Ticks: {ticks}</p>
      {ticking
        ? <button onClick={stop}>Stop</button>
        : <button onClick={start}>Start</button>
      }
      <input type="number" value={ms} onChange={(e) => setMs(e.currentTarget.value)} />
    </div>
  );
}

הקומפוננטה מייצגת שעון אסינכרוני. כשהקומפוננטה נכנסת למסך השעון במצב "דולק" ואז כל שניה הקומפוננטה מציגה מספר עולה (מתחיל ב-0). אפשר ללחוץ על כפתור Stop כדי לעצור את השעון ואז שוב על כפתור Start כדי להפעיל אותו שוב, ואפשר לשנות את המהירות באמצעות שינוי הטקסט בתיבה.

2. בדיקת מצב ראשוני

בבדיקה הראשונה על טיימר נרצה לוודא שהוא מופיע במצב הראשוני שהגדרנו, כלומר עם הערך 0, עם מהירות של שניה ועם הכפתור Stop:

test('initial values', () => {
  render(<Timer />);
  expect(screen.getByText(/0/)).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
  expect(screen.getByRole('spinbutton')).toHaveDisplayValue(1000);
});

3. בדיקת התקדמות השעון

ועכשיו אפשר להמשיך לבדיקה יותר מעניינת - בואו נראה שאחרי שניה השעון באמת מתקדם. אנחנו יודעים שאחרי שהשעון יתקדם הטקסט יתעדכן למספר 1, ולכן יכולים לכתוב את הבדיקה:

test('after 1 second the value changes to 1', async () => {
  render(<Timer />);
  expect(await screen.findByText(/1/, {}, { timeout: 3000 })).toBeInTheDocument();
});

אני משתמש ב findByText כדי למצוא את האלמנט עם הטקסט 1. החיפוש עם find מחכה עד שהאלמנט יופיע, ובגלל זה עלינו לכתוב לפניו await. בגלל שיש await בקוד הבדיקה, פונקציית הבדיקה צריכה להיות מסומנת בתור async.

הפעלה של הבדיקה מגלה שהיא עובדת, אבל לאט. find מחכה לפעמים גם שתי שניות עד שהערך יתעדכן, כנראה בגלל שספריית הבדיקות לא רואה את השינוי בדיוק כשהוא קורה.

לכן ההעדפה שלנו בבדיקת קוד אסינכרוני במידת האפשר היא להפוך את הקוד לסינכרוני או לפחות למיידי. במקרה של שעונים הטריק הוא לדרוס את הפונקציות setInterval ו setTimeout ולייצר "שעון" מזויף, שאפשר להזיז אותו בצורה תכנותית. ג'סט מספק לי את הכלים לזה באמצעות הפונקציה:

jest.useFakeTimers();

אני מוסיף לתוכנית הבדיקה שלי את הקוד הבא:

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

הבלוק beforeEach מגדיר קוד שירוץ לפני כל בדיקה, והבלוק afterEach מגדיר את הקוד שירוץ אחרי כל בדיקה (יש גם beforeAll ו afterAll). אני משתמש בשני הבלוקים כדי להפעיל שעונים מזויפים לפני כל בדיקה, ולהחזיר את המצב לקדמותו בסוף הבדיקות.

עכשיו בקוד הבדיקות אני יכול לקרוא ל:

jest.advanceTimersByTime(1000);

כדי לזוז קדימה בזמן. אני מעדכן את קוד הבדיקה כדי להינות מהשעון המזויף החדש שלי:

test('after 1 second the value changes to 1', () => {
  render(<Timer />);
  act(() => {
    jest.advanceTimersByTime(1000);
  });

  expect(screen.getByText(/1/)).toBeInTheDocument();
});

בנוסף לפונקציה advanceTimersByTime אני רואה עוד דבר חדש: הקריאה לפונקציה act, שעוטפת את advanceTimersByTime. פונקציית act שייכת ל react-testing-library והיא נועדה לאפשר ל testing library לזהות שהיה שינוי ולרנדר מחדש את הקומפוננטה. בדרך כלל כשאנחנו עובדים עם שעונים אמיתיים ועם find הדבר הזה קורה אוטומטית, אבל במעבר לשעון מזויף אנחנו מאבדים את הפינוק וצריכים להגיד בצורה מפורשת ל testing library שקידום השעון גם יגרום ל render מחדש.

עכשיו באותה קלות אני יכול לכתוב קוד שידמה המתנה של 10 שניות ויוודא את הערך של השעון:

test('after 10 seconds the value changes to 10', () => {
  render(<Timer />);
  act(() => {
    jest.advanceTimersByTime(10000);
  });

  expect(screen.getByText(/10/)).toBeInTheDocument();
});

וגם הוא ירוץ בצורה מיידית בזכות השעון המזויף.

4. בדיקת עצירה והפעלה מחדש של השעון

עכשיו שאני יודע להזיז את הזמן קדימה אפשר לנסות לעצור את השעון ולראות שהוא באמת לא מתקדם כשהשניות עוברות. הנה הבדיקה הבאה:

test('clicking "stop" stops the timer', () => {
  render(<Timer />);
  const stopButton = screen.getByRole('button', { name: /stop/i });
  userEvent.click(stopButton);

  act(() => {
    jest.advanceTimersByTime(1000);
  });

  expect(screen.getByText(/0/)).toBeInTheDocument();
});

5. בדיקת שינוי קצב השעון

בדיקה אחרונה לקומפוננטה היא שינוי קצב השעון, וגם אותה אני כותב יחסית בקלות אחרי כל מה שראינו:

test('after changing the speed, the timer moves faster according to the new value', () => {
  render(<Timer />);
  const speed = screen.getByRole('spinbutton');
  userEvent.clear(speed);
  userEvent.type(speed, "500");

  act(() => {
    jest.advanceTimersByTime(1000);
  });

  expect(screen.getByText(/2/)).toBeInTheDocument();
});

הפעם כשאני מתקדם בשניה אני מצפה שהמספר יעלה ל-2, ובאמת כך קורה.