Back to Repositories

Testing Event Handler Implementation in Preact Components

This test suite comprehensively validates event handling functionality in Preact components. It focuses on event listener registration, handling, and lifecycle management across different event types and naming conventions. The tests ensure proper event propagation and capture phase behavior.

Test Coverage Overview

The test suite provides extensive coverage of Preact’s event handling system, including:

  • Event listener registration and cleanup
  • Support for both native and camelCase event names
  • Handling of capturing and non-capturing events
  • Event handler updates and removal
  • Special cases like pointer capture events
  • Focus event handling

Implementation Analysis

The testing approach utilizes Jest and Sinon for spy/stub functionality to verify event listener behavior. Tests implement a systematic pattern of rendering components, triggering events, and verifying handler execution. The implementation leverages mock DOM events and custom helper utilities for setup and teardown.

Technical Details

  • Testing Framework: Jest
  • Mocking Library: Sinon
  • Custom Helpers: setupScratch, teardown, supportsPassiveEvents
  • DOM Event Simulation: document.createEvent
  • Spy Implementation: addEventListener and removeEventListener tracking

Best Practices Demonstrated

The test suite exemplifies several testing best practices:

  • Proper test isolation through beforeEach/afterEach hooks
  • Comprehensive edge case coverage
  • Browser compatibility considerations
  • Consistent cleanup of event listeners
  • Clear test case organization and naming

preactjs/preact

test/browser/events.test.js

            
import { createElement, render } from 'preact';
import {
	setupScratch,
	teardown,
	supportsPassiveEvents
} from '../_util/helpers';

/** @jsx createElement */

describe('event handling', () => {
	let scratch, proto;

	function fireEvent(on, type) {
		let e = document.createEvent('Event');
		e.initEvent(type, true, true);
		on.dispatchEvent(e);
	}

	beforeEach(() => {
		scratch = setupScratch();

		proto = document.createElement('div').constructor.prototype;

		sinon.spy(proto, 'addEventListener');
		sinon.spy(proto, 'removeEventListener');
	});

	afterEach(() => {
		teardown(scratch);

		proto.addEventListener.restore();
		proto.removeEventListener.restore();
	});

	it('should only register on* functions as handlers', () => {
		let click = () => {},
			onclick = () => {};

		render(<div click={click} onClick={onclick} />, scratch);

		expect(scratch.childNodes[0].attributes.length).to.equal(0);

		expect(
			proto.addEventListener
		).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
			'click',
			sinon.match.func,
			false
		);
	});

	it('should only register truthy values as handlers', () => {
		function fooHandler() {}
		const falsyHandler = false;

		render(<div onClick={falsyHandler} onOtherClick={fooHandler} />, scratch);

		expect(
			proto.addEventListener
		).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
			'OtherClick',
			sinon.match.func,
			false
		);

		expect(proto.addEventListener).not.to.have.been.calledWith('Click');
		expect(proto.addEventListener).not.to.have.been.calledWith('click');
	});

	it('should support native event names', () => {
		let click = sinon.spy(),
			mousedown = sinon.spy();

		render(<div onclick={() => click(1)} onmousedown={mousedown} />, scratch);

		expect(proto.addEventListener)
			.to.have.been.calledTwice.and.to.have.been.calledWith('click')
			.and.calledWith('mousedown');

		fireEvent(scratch.childNodes[0], 'click');
		expect(click).to.have.been.calledOnce.and.calledWith(1);
	});

	it('should support camel-case event names', () => {
		let click = sinon.spy(),
			mousedown = sinon.spy();

		render(<div onClick={() => click(1)} onMouseDown={mousedown} />, scratch);

		expect(proto.addEventListener)
			.to.have.been.calledTwice.and.to.have.been.calledWith('click')
			.and.calledWith('mousedown');

		fireEvent(scratch.childNodes[0], 'click');
		expect(click).to.have.been.calledOnce.and.calledWith(1);
	});

	it('should update event handlers', () => {
		let click1 = sinon.spy();
		let click2 = sinon.spy();

		render(<div onClick={click1} />, scratch);

		fireEvent(scratch.childNodes[0], 'click');
		expect(click1).to.have.been.calledOnce;
		expect(click2).to.not.have.been.called;

		click1.resetHistory();
		click2.resetHistory();

		render(<div onClick={click2} />, scratch);

		fireEvent(scratch.childNodes[0], 'click');
		expect(click1).to.not.have.been.called;
		expect(click2).to.have.been.called;
	});

	it('should remove event handlers', () => {
		let click = sinon.spy(),
			mousedown = sinon.spy();

		render(<div onClick={() => click(1)} onMouseDown={mousedown} />, scratch);
		render(<div onClick={() => click(2)} />, scratch);

		expect(proto.removeEventListener).to.have.been.calledWith('mousedown');

		fireEvent(scratch.childNodes[0], 'mousedown');
		expect(mousedown).not.to.have.been.called;

		proto.removeEventListener.resetHistory();
		click.resetHistory();
		mousedown.resetHistory();

		render(<div />, scratch);

		expect(proto.removeEventListener).to.have.been.calledWith('click');

		fireEvent(scratch.childNodes[0], 'click');
		expect(click).not.to.have.been.called;
	});

	it('should register events not appearing on dom nodes', () => {
		let onAnimationEnd = () => {};

		render(<div onanimationend={onAnimationEnd} />, scratch);
		expect(
			proto.addEventListener
		).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
			'animationend',
			sinon.match.func,
			false
		);
	});

	// Skip test if browser doesn't support passive events
	if (supportsPassiveEvents()) {
		it('should use capturing for event props ending with *Capture', () => {
			let click = sinon.spy();

			render(
				<div onClickCapture={click}>
					<button type="button">Click me</button>
				</div>,
				scratch
			);

			let btn = scratch.firstChild.firstElementChild;
			btn.click();

			expect(click, 'click').to.have.been.calledOnce;

			// IE doesn't set it
			if (!/Edge/.test(navigator.userAgent)) {
				expect(click).to.have.been.calledWithMatch({ eventPhase: 0 }); // capturing
			}
		});

		it('should support both capturing and non-capturing events on the same element', () => {
			let click = sinon.spy(),
				clickCapture = sinon.spy();

			render(
				<div onClick={click} onClickCapture={clickCapture}>
					<button />
				</div>,
				scratch
			);

			let root = scratch.firstChild;
			root.firstElementChild.click();

			expect(clickCapture, 'click').to.have.been.calledOnce;
			expect(click, 'click').to.have.been.calledOnce;
		});
	}

	// Uniquely named in that the base event names end with 'Capture'
	it('should support (got|lost)PointerCapture events', () => {
		let gotPointerCapture = sinon.spy(),
			gotPointerCaptureCapture = sinon.spy(),
			lostPointerCapture = sinon.spy(),
			lostPointerCaptureCapture = sinon.spy();

		render(
			<div
				onGotPointerCapture={gotPointerCapture}
				onLostPointerCapture={lostPointerCapture}
			/>,
			scratch
		);

		expect(proto.addEventListener)
			.to.have.been.calledTwice.and.to.have.been.calledWith('gotpointercapture')
			.and.calledWith('lostpointercapture');

		proto.addEventListener.resetHistory();

		render(
			<div
				onGotPointerCaptureCapture={gotPointerCaptureCapture}
				onLostPointerCaptureCapture={lostPointerCaptureCapture}
			/>,
			scratch
		);

		expect(proto.addEventListener)
			.to.have.been.calledTwice.and.to.have.been.calledWith('gotpointercapture')
			.and.calledWith('lostpointercapture');
	});

	it('should support camel-case focus event names', () => {
		render(<div onFocusIn={() => {}} onFocusOut={() => {}} />, scratch);

		expect(proto.addEventListener)
			.to.have.been.calledTwice.and.to.have.been.calledWith('focusin')
			.and.calledWith('focusout');
	});
});