Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM
Testy jednostkowe dla event handlerów
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. Którą funkcją z API Jest (bez dodatkowych bibliotek) tworzymy mock metody addEventListener na elemencie DOM?
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: