Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Testy jednostkowe dla event handlerów

4 min czytania ·

Jak testować kod związany z obsługą wydarzeń.

Po każdej zmianie w panelu kontrolnym mógłbyś ręcznie klikać każdą kartę dinozaura i sprawdzać, czy alarm nadal działa. To tak, jakby codziennie obchodzić wszystkie ogrodzenia z latarką. Testy automatyczne robią ten obchód za Ciebie w ułamku sekundy. Użyjemy Jest, popularnego frameworka testowego dla JavaScriptu. Funkcja describe grupuje testy, test opisuje jeden przypadek, beforeEach przygotowuje świeże dane przed każdym testem, a expect sprawdza wynik. Najważniejsze narzędzie to jest.fn(): tworzy udawaną funkcję, czyli mock, która zapamiętuje każde wywołanie i jego argumenty.

Komponent, który testujemy

Zanim napiszemy testy, poznajmy bohatera. DinosaurCardHandler podpina kliknięcie do karty, blokuje domyślną akcję i ogłasza wybór dinozaura własnym wydarzeniem.

class DinosaurCardHandler {
  constructor(element) {
    this.element = element;
    this.handleClick = this.handleClick.bind(this);
    this.element.addEventListener('click', this.handleClick);
  }

  handleClick(event) {
    event.preventDefault();
    this.emitDinosaurSelected(event.target.dataset.dinoId);
  }

  emitDinosaurSelected(dinosaurId) {
    this.element.dispatchEvent(
      new CustomEvent('dinosaurSelected', { detail: { dinosaurId } })
    );
  }

  destroy() {
    this.element.removeEventListener('click', this.handleClick);
  }
}

Klasa ma cztery zachowania warte sprawdzenia: rejestruje listener w konstruktorze, w handleClick() wywołuje preventDefault(), wysyła wydarzenie dinosaurSelected z identyfikatorem i w destroy() zdejmuje tę samą funkcję, którą podpięła. Każde z nich dostanie własny test.

Testowanie wydarzeń

Zaczynamy od przygotowania. Zamiast prawdziwego elementu DOM tworzymy zwykły obiekt z trzema metodami z jest.fn().

// Przykład testów z Jest
describe('Event Handlers', () => {
  let mockElement;
  let eventHandler;

  beforeEach(() => {
    // Tworzenie mock elementu DOM
    mockElement = {
      addEventListener: jest.fn(),
      removeEventListener: jest.fn(),
      dispatchEvent: jest.fn()
    };

    // Mock funkcji globalnych
    global.document = {
      getElementById: jest.fn(() => mockElement),
      createElement: jest.fn(() => mockElement)
    };
  });

Tak buduje się mock elementu: do addEventListener przypisujemy jest.fn(), a potem pytamy go, jak był wywoływany. Dzięki beforeEach każdy test dostaje świeży mock, więc wywołania z jednego testu nie mieszają się z następnym. Podmiana całego global.document jest tu tylko na zapas, bo testy i tak przekazują mockElement bezpośrednio. W środowisku jsdom document już istnieje, więc zamiast go nadpisywać, podmień pojedynczą metodę przez jest.spyOn(document, 'getElementById'). Pierwszy test sprawdza rejestrację.

  test('should add event listener on init', () => {
    const handler = new DinosaurCardHandler(mockElement);

    expect(mockElement.addEventListener).toHaveBeenCalledWith(
      'click',
      expect.any(Function)
    );
  });

Matcher toHaveBeenCalledWith() sprawdza, że listener dodano z typem 'click', a expect.any(Function) akceptuje dowolną funkcję, bo nie interesuje nas, która dokładnie. Drugi test sprawdza samą obsługę kliknięcia.

  test('should handle click event correctly', () => {
    const mockCallback = jest.fn();
    const handler = new DinosaurCardHandler(mockElement);

    // Symulacja wydarzenia
    const mockEvent = {
      target: { dataset: { dinoId: '123' } },
      preventDefault: jest.fn()
    };

    // Wywołanie handlera bezpośrednio
    handler.handleClick(mockEvent);

    expect(mockEvent.preventDefault).toHaveBeenCalled();
  });

Mock elementu nie ma prawdziwego systemu wydarzeń, więc wywołujemy handleClick() bezpośrednio z udawanym obiektem wydarzenia. Jego preventDefault to znów jest.fn(), więc możemy sprawdzić, że został wywołany. Zmienna mockCallback nie jest tu nigdzie używana, a w prawdziwym projekcie taką martwą linię warto usunąć. Trzeci test pilnuje sprzątania.

  test('should remove event listener on destroy', () => {
    const handler = new DinosaurCardHandler(mockElement);
    handler.destroy();

    expect(mockElement.removeEventListener).toHaveBeenCalledWith(
      'click',
      expect.any(Function)
    );
  });

Ten test potwierdza, że destroy() wywołało removeEventListener() dla kliknięcia. Mocniejsza wersja porównałaby funkcję z tą przekazaną wcześniej do addEventListener(), którą Jest przechowuje w mock.calls. Czwarty test sprawdza własne wydarzenie.

  test('should emit custom event', () => {
    const handler = new DinosaurCardHandler(mockElement);
    const customEvent = new CustomEvent('dinosaurSelected');

    handler.emitDinosaurSelected('123');

    expect(mockElement.dispatchEvent).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'dinosaurSelected',
        detail: { dinosaurId: '123' }
      })
    );
  });
});

Matcher expect.objectContaining() sprawdza tylko wskazane pola wysłanego obiektu: typ i detail. Zmienna customEvent jest tu zbędna, bo asercja patrzy na wydarzenie utworzone przez sam handler.

Test integracyjny z prawdziwym DOM

Testy jednostkowe sprawdzają logikę w izolacji. Test integracyjny tworzy prawdziwy przycisk i sprawdza, czy przeglądarkowy mechanizm wydarzeń naprawdę łączy go z listenerem.

// Test integracyjny z rzeczywistym DOM
describe('Integration Tests', () => {
  test('should handle real DOM events', () => {
    // Tworzenie rzeczywistego elementu
    const button = document.createElement('button');
    document.body.appendChild(button);

    let clickCount = 0;
    button.addEventListener('click', () => {
      clickCount++;
    });

    // Symulacja kliknięcia
    button.click();

    expect(clickCount).toBe(1);

    // Cleanup
    document.body.removeChild(button);
  });
});

Metoda click() symuluje kliknięcie i wysyła prawdziwe wydarzenie click, które bąbelkuje jak po kliknięciu myszą. Na końcu usuwamy przycisk, żeby nie wpływał na kolejne testy. Taki test potrzebuje środowiska jsdom: od wersji 27 Jest domyślnie uruchamia testy w środowisku Node, więc trzeba ustawić testEnvironment: 'jsdom', a od wersji 28 doinstalować pakiet jest-environment-jsdom.

Każdy test handlera przechodzi te same cztery kroki: tworzymy mock elementu DOM z jest.fn(), inicjalizujemy komponent z tym mockiem, symulujemy wydarzenie, na przykład kliknięcie, i sprawdzamy asercje przez expect.

Moja rada: logikę sprawdzaj szybkimi testami jednostkowymi z mockami, a połączenie z DOM kilkoma testami integracyjnymi. Dla wydarzeń bez skrótu takiego jak click() użyj element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })). W piaskownicy poniżej uruchomisz zestaw testów jednym przyciskiem i zobaczysz ich wyniki. Następna lekcja podsumuje cały moduł wydarzeń.

Pamiętaj: dobry test to strażnik, który co noc obchodzi ogrodzenia za Ciebie i budzi Cię tylko wtedy, gdy coś naprawdę jest nie tak.

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Testowanie Event Handlerów</title>
    <style>
        body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
        .test-section { background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; margin: 20px 0; }
        .test-result { padding: 10px; margin: 10px 0; border-radius: 6px; }
        .pass { background: rgba(46, 204, 113, 0.2); border-left: 4px solid #27ae60; }
        .fail { background: rgba(231, 76, 60, 0.2); border-left: 4px solid #e74c3c; }
        button { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 5px; }
        code { background: rgba(255,255,255,0.1); padding: 2px 6px; border-radius: 3px; font-family: monospace; }
    </style>
</head>
<body>
    <h1>Testing Event Handlers</h1>

    <div class="test-section">
        <h2>Component to Test</h2>
        <button id="test-button" data-dino="T-Rex">Click Me</button>
        <div id="click-counter">Clicks: 0</div>
    </div>

    <div class="test-section">
        <h2>Test Suite Results</h2>
        <button onclick="runTests()">▶Run All Tests</button>
        <div id="test-results"></div>
    </div>

    <script>
        console.log('Event Handler Testing Suite');

        // COMPONENT TO TEST
        class DinoButton {
            constructor(element) {
                this.element = element;
                this.clickCount = 0;
                this.handleClick = this.handleClick.bind(this);
                this.init();
            }

            init() {
                this.element.addEventListener('click', this.handleClick);
            }

            handleClick(e) {
                this.clickCount++;
                this.updateDisplay();
                console.log(`Clicked: ${this.element.dataset.dino}`);
            }

            updateDisplay() {
                document.getElementById('click-counter').textContent = `Clicks: ${this.clickCount}`;
            }

            getClickCount() {
                return this.clickCount;
            }

            destroy() {
                this.element.removeEventListener('click', this.handleClick);
            }
        }

        // Create component
        const testButton = document.getElementById('test-button');
        const dinoButton = new DinoButton(testButton);

        // TEST SUITE
        class TestRunner {
            constructor() {
                this.results = [];
            }

            test(name, testFn) {
                try {
                    testFn();
                    this.results.push({ name, passed: true, error: null });
                    console.log(`PASS: ${name}`);
                } catch (error) {
                    this.results.push({ name, passed: false, error: error.message });
                    console.log(`FAIL: ${name} - ${error.message}`);
                }
            }

            expect(value) {
                return {
                    toBe: (expected) => {
                        if (value !== expected) {
                            throw new Error(`Expected ${expected}, got ${value}`);
                        }
                    },
                    toBeGreaterThan: (expected) => {
                        if (value <= expected) {
                            throw new Error(`Expected > ${expected}, got ${value}`);
                        }
                    },
                    toBeDefined: () => {
                        if (value === undefined || value === null) {
                            throw new Error(`Expected value to be defined`);
                        }
                    }
                };
            }

            displayResults() {
                const container = document.getElementById('test-results');
                container.innerHTML = '';

                const summary = document.createElement('div');
                const passed = this.results.filter(r => r.passed).length;
                const total = this.results.length;
                summary.innerHTML = `<strong>Results: ${passed}/${total} tests passed</strong>`;
                container.appendChild(summary);

                this.results.forEach(result => {
                    const div = document.createElement('div');
                    div.className = `test-result ${result.passed ? 'pass' : 'fail'}`;
                    div.innerHTML = `
                        ${result.passed ? 'OK' : '×'} <strong>${result.name}</strong>
                        ${result.error ? `<br><code>${result.error}</code>` : ''}
                    `;
                    container.appendChild(div);
                });
            }
        }

        // RUN TESTS
        function runTests() {
            const runner = new TestRunner();

            runner.test('Component initializes with click count 0', () => {
                const button = document.createElement('button');
                const component = new DinoButton(button);
                runner.expect(component.getClickCount()).toBe(0);
                component.destroy();
            });

            runner.test('Click count increments on click', () => {
                const button = document.createElement('button');
                const component = new DinoButton(button);
                const initialCount = component.getClickCount();

                // Simulate click
                button.click();

                runner.expect(component.getClickCount()).toBeGreaterThan(initialCount);
                component.destroy();
            });

            runner.test('Element has correct data attribute', () => {
                const button = document.getElementById('test-button');
                const dino = button.dataset.dino;
                runner.expect(dino).toBe('T-Rex');
            });

            runner.test('Event listener is added', () => {
                const button = document.createElement('button');
                const component = new DinoButton(button);

                // Event listener powinien być dodany
                runner.expect(component.element).toBeDefined();
                runner.expect(component.handleClick).toBeDefined();

                component.destroy();
            });

            runner.test('Event listener is removed on destroy', () => {
                const button = document.createElement('button');
                const component = new DinoButton(button);
                const countBefore = component.getClickCount();

                component.destroy();
                button.click(); // Ten click nie powinien zwiększyć licznika

                // Licznik nie powinien się zmienić po destroy
                runner.expect(component.getClickCount()).toBe(countBefore);
            });

            runner.test('Multiple clicks increment correctly', () => {
                const button = document.createElement('button');
                const component = new DinoButton(button);

                button.click();
                button.click();
                button.click();

                runner.expect(component.getClickCount()).toBe(3);
                component.destroy();
            });

            runner.displayResults();
            console.log('Test suite completed');
        }

        console.log('Test suite ready - click "Run All Tests"');
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Którą funkcją z API Jest (bez dodatkowych bibliotek) tworzymy mock metody addEventListener na elemencie DOM?

  2. 2. Jak zasymulować kliknięcie na elemencie DOM w testach integracyjnych?

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.js jest komponent DinosaurCardHandler z lekcji o testach, prosty jest.fn() (mock zapamiętuje wywołania w mock.calls) i trzy testy napisane jako funkcje, które zwracają true, gdy komponent działa poprawnie. Uzupełnij luki: ___BLANK1___ to typ zdarzenia, które konstruktor ma zarejestrować przez addEventListener, ___BLANK2___ to liczba wywołań preventDefault() po jednym kliknięciu, a ___BLANK3___ to operator porównania, który sprawdza, że destroy() usuwa tę samą funkcję, którą dodał konstruktor. Sprawdzarka uruchomi Twoje testy na poprawnym komponencie (muszą zwrócić true) i na zepsutych wersjach (muszą zwrócić false).

  • Układanie w pionie

    Ułóż kroki testowania event handlera w prawidłowej kolejności:

Przydatne artykuły