בדיקות עם Jest ו-React Testing Library

מבוא

בדיקות אוטומטיות מעניקות ביטחון שהקוד ממשיך לעבוד גם אחרי שינויים. שני כלים משלימים מובילים בעולם ה-React: Jest - סביבת הרצת הבדיקות (test runner) שמריצה את הבדיקות ומספקת פונקציות השוואה (assertions), ו-React Testing Library (RTL) - ספרייה שבודקת קומפוננטות מנקודת מבטו של המשתמש, כלומר לפי מה שמוצג על המסך ולא לפי פרטי המימוש הפנימיים.

התקנה

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

מבנה בסיסי של בדיקה

// Component.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import MyComponent from './MyComponent';

describe('MyComponent', () => {
    test('renders correctly', () => {
        render(<MyComponent />);

        expect(screen.getByText('שלום')).toBeInTheDocument();
    });
});

render ו-screen

import { render, screen } from '@testing-library/react';

test('renders button with text', () => {
    // render - מרנדר את הקומפוננטה
    render(<Button>לחץ כאן</Button>);

    // screen - גישה לאלמנטים שרונדרו
    const button = screen.getByRole('button', { name: /לחץ כאן/i });

    expect(button).toBeInTheDocument();
});

Queries - חיפוש אלמנטים

סוגי Queries

Query מתי להשתמש
getBy... האלמנט חייב להיות קיים
queryBy... האלמנט עלול לא להיות קיים
findBy... אלמנט אסינכרוני (Promise)

שיטות חיפוש

// לפי Role (מומלץ!)
screen.getByRole('button');
screen.getByRole('heading', { level: 1 });
screen.getByRole('textbox', { name: /אימייל/i });

// לפי Label
screen.getByLabelText('סיסמה');

// לפי Placeholder
screen.getByPlaceholderText('הקלד כאן...');

// לפי טקסט
screen.getByText('שלום עולם');
screen.getByText(/שלום/i); // case-insensitive

// לפי Test ID
screen.getByTestId('submit-button');

// לפי Alt
screen.getByAltText('לוגו החברה');

דוגמאות

test('form elements', () => {
    render(<LoginForm />);

    // getBy - זורק שגיאה אם לא נמצא
    const emailInput = screen.getByLabelText(/אימייל/i);
    const passwordInput = screen.getByLabelText(/סיסמה/i);
    const submitButton = screen.getByRole('button', { name: /התחבר/i });

    // queryBy - מחזיר null אם לא נמצא
    const error = screen.queryByText(/שגיאה/i);
    expect(error).not.toBeInTheDocument();
});

User Events - אינטראקציות

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

import userEvent from '@testing-library/user-event';

test('user interactions', async () => {
    const user = userEvent.setup();
    render(<MyForm onSubmit={handleSubmit} />);

    // הקלדה
    await user.type(screen.getByLabelText(/שם/i), 'דני');

    // לחיצה
    await user.click(screen.getByRole('button'));

    // Clear + type
    await user.clear(screen.getByLabelText(/שם/i));
    await user.type(screen.getByLabelText(/שם/i), 'רונית');

    // Hover
    await user.hover(screen.getByText('עזרה'));

    // Select
    await user.selectOptions(screen.getByRole('combobox'), 'option1');

    // Checkbox
    await user.click(screen.getByRole('checkbox'));
});

Assertions - בדיקות

import '@testing-library/jest-dom';

test('assertions', () => {
    render(<MyComponent />);

    const element = screen.getByText('שלום');

    // נוכחות
    expect(element).toBeInTheDocument();
    expect(screen.queryByText('לא קיים')).not.toBeInTheDocument();

    // תוכן
    expect(element).toHaveTextContent('שלום');
    expect(screen.getByLabelText('שם')).toHaveValue('דני');

    // מצב
    expect(screen.getByRole('button')).toBeEnabled();
    expect(screen.getByRole('button')).toBeDisabled();

    // Style
    expect(element).toHaveClass('active');
    expect(element).toHaveStyle({ color: 'red' });

    // Attributes
    expect(element).toHaveAttribute('href', '/about');
});

דוגמאות מלאות

בדיקת קומפוננטה פשוטה

// Greeting.jsx
function Greeting({ name }) {
    return <h1>שלום, {name}!</h1>;
}

// Greeting.test.jsx
test('renders greeting with name', () => {
    render(<Greeting name="דני" />);

    expect(screen.getByRole('heading')).toHaveTextContent('שלום, דני!');
});

test('renders greeting with different name', () => {
    render(<Greeting name="רונית" />);

    expect(screen.getByText(/שלום, רונית/i)).toBeInTheDocument();
});

בדיקת Counter

// Counter.jsx
function Counter() {
    const [count, setCount] = useState(0);
    return (
        <div>
            <p data-testid="count">{count}</p>
            <button onClick={() => setCount(c => c + 1)}>הוסף</button>
            <button onClick={() => setCount(c => c - 1)}>הפחת</button>
        </div>
    );
}

// Counter.test.jsx
describe('Counter', () => {
    test('starts at 0', () => {
        render(<Counter />);
        expect(screen.getByTestId('count')).toHaveTextContent('0');
    });

    test('increments on button click', async () => {
        const user = userEvent.setup();
        render(<Counter />);

        await user.click(screen.getByRole('button', { name: /הוסף/i }));

        expect(screen.getByTestId('count')).toHaveTextContent('1');
    });

    test('decrements on button click', async () => {
        const user = userEvent.setup();
        render(<Counter />);

        await user.click(screen.getByRole('button', { name: /הפחת/i }));

        expect(screen.getByTestId('count')).toHaveTextContent('-1');
    });
});

בדיקת טופס

// LoginForm.test.jsx
describe('LoginForm', () => {
    const mockSubmit = jest.fn();

    beforeEach(() => {
        mockSubmit.mockClear();
    });

    test('submits form with valid data', async () => {
        const user = userEvent.setup();
        render(<LoginForm onSubmit={mockSubmit} />);

        await user.type(screen.getByLabelText(/אימייל/i), '[email protected]');
        await user.type(screen.getByLabelText(/סיסמה/i), '123456');
        await user.click(screen.getByRole('button', { name: /התחבר/i }));

        expect(mockSubmit).toHaveBeenCalledWith({
            email: '[email protected]',
            password: '123456'
        });
    });

    test('shows error for invalid email', async () => {
        const user = userEvent.setup();
        render(<LoginForm onSubmit={mockSubmit} />);

        await user.type(screen.getByLabelText(/אימייל/i), 'invalid');
        await user.click(screen.getByRole('button', { name: /התחבר/i }));

        expect(screen.getByText(/אימייל לא תקין/i)).toBeInTheDocument();
        expect(mockSubmit).not.toHaveBeenCalled();
    });
});

בדיקות אסינכרוניות

// async component
function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        fetch(`/api/users/${userId}`)
            .then(res => res.json())
            .then(data => {
                setUser(data);
                setLoading(false);
            });
    }, [userId]);

    if (loading) return <p>טוען...</p>;
    return <h1>{user.name}</h1>;
}

// test
test('loads and displays user', async () => {
    // Mock fetch
    global.fetch = jest.fn(() =>
        Promise.resolve({
            json: () => Promise.resolve({ name: 'דני' })
        })
    );

    render(<UserProfile userId="1" />);

    // בהתחלה מציג loading
    expect(screen.getByText(/טוען/i)).toBeInTheDocument();

    // מחכה לתוכן האסינכרוני
    const heading = await screen.findByRole('heading');
    expect(heading).toHaveTextContent('דני');
});

Mocking

לעיתים קרובות נרצה לבודד את הקומפוננטה הנבדקת מהתלויות החיצוניות שלה - למשל קריאות לשרת או פונקציות שמועברות כ-props. לשם כך משתמשים ב-Mocks: תחליפים מזויפים שמאפשרים לשלוט בהתנהגות ולוודא שהקומפוננטה קראה להם כצפוי:

Mock Functions

test('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>לחץ</Button>);

    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
});

Mock Modules

// __mocks__/api.js
export const fetchUsers = jest.fn();

// Component.test.jsx
jest.mock('./api');
import { fetchUsers } from './api';

beforeEach(() => {
    fetchUsers.mockResolvedValue([
        { id: 1, name: 'דני' }
    ]);
});

test('displays fetched users', async () => {
    render(<UserList />);

    expect(await screen.findByText('דני')).toBeInTheDocument();
});

Best Practices

  1. בדקו התנהגות, לא מימוש - התמקדו במה שהמשתמש רואה ועושה, ולא בפרטים הפנימיים של הקוד
  2. העדיפו חיפוש לפי Role - הוא עמיד יותר ומקדם נגישות, בניגוד ל-test-id
  3. כתבו בדיקות שמדמות שימוש אמיתי - בדיקה טובה משקפת את דרך העבודה של משתמש חי
  4. השתמשו ב-userEvent ולא ב-fireEvent - הוא מדמה אינטראקציות נאמנה יותר למציאות

סיכום

API שימוש
render() רינדור קומפוננטה
screen גישה לאלמנטים
getBy/queryBy/findBy חיפוש אלמנטים
userEvent סימולציית אינטראקציות
expect() assertions
jest.fn() mock function

הקודם: ← RTK Query
סיום! 🎉

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. מה הגישה של React Testing Library?

    1. לבדוק CSS
    2. לבדוק state פנימי
    3. לבדוק רק snapshot
    4. לבדוק כמו משתמש: מה מוצג, ומה קורה כשלוחצים
    הצגת התשובה

    תשובה ד. בדיקות שלא תלויות במימוש נשארות תקינות גם כשמשכתבים קוד.

  2. איזו שאילתה עדיפה למציאת כפתור?

    1. getByClassName
    2. getByRole('button', { name: 'שמירה' })
    3. getByTestId
    4. querySelector
    הצגת התשובה

    תשובה ב. שאילתה לפי תפקיד ושם נגיש בודקת גם שהממשק נגיש.