Testing React Keyboard Events with Vitest + Happy DOM

Handling keyboard events is a common requirement when developing React applications. This guide will walk you through setting up an efficient and reliable testing environment using Vitest and Happy DOM, specifically for testing keyboard events and shortcuts.

Introduction: Why Vitest + Happy DOM?

  • Vitest: A blazing-fast unit test framework powered by Vite. It offers a Jest-compatible API, out-of-the-box TypeScript/JSX support, and leverages the many advantages of the Vite ecosystem.
  • Happy DOM: A lightweight, standards-compliant JavaScript DOM implementation designed for Node.js. It's generally faster and uses less memory than JSDOM, making it ideal for running front-end unit tests.
  • React Testing Library: Encourages writing tests that resemble how users interact with your components, focusing on behavior rather than implementation details.

This combination allows you to efficiently test your React components' keyboard interaction logic in a simulated browser environment.

Prerequisites

Before you begin, ensure your project is set up with Vite (or at least has a configuration compatible with Vitest), and you have a basic understanding of React and unit testing concepts.

Environment Setup

Install Core Dependencies

First, install the necessary development dependencies:

  • vitest: The core test runner.
  • @vitejs/plugin-react: Allows Vitest to process React (JSX, Fast Refresh).
  • happy-dom: Provides the DOM environment needed for testing.
  • @testing-library/react: For rendering React components and querying the DOM.
  • @testing-library/user-event: Provides event dispatching methods that more closely resemble real user interactions (while this example primarily uses fireEvent, user-event is generally recommended for richer interactions).

Project Structure (Example)

A typical project structure might look like this:

project/
├── src/
│   └── useTinykeys.ts  # Our custom Hook to be tested
│   └── SomeComponent.tsx # A component potentially using useTinykeys
├── tests/
│   └── useTinykeys.spec.tsx # Test file for useTinykeys
├── vitest.config.ts     # Vitest configuration file
├── tsconfig.json          # TypeScript configuration (if using TS)
└── package.json

3. Configuring Vitest

Create or modify the vitest.config.ts file in your project root:

import { configDefaults, defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(), // Add React plugin support
  ],
  test: {
    // --- Core Configuration ---
    globals: true, // Enable global APIs (describe, it, expect, etc.) without manual imports
    environment: 'happy-dom', // Specify Happy DOM as the testing environment

    // --- Optional Configuration ---
    setupFiles: './tests/setup.ts', // (Optional) Global setup file for the test environment
    testTimeout: 1000 * 10, // Default timeout for a single test (10 seconds)
    // Exclude node_modules and other default exclusions
    exclude: [...configDefaults.exclude, '**/node_modules/**', '**/dist/**'],
    // Include all files ending with .test.tsx, .spec.tsx, etc.
    include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],

    // --- Coverage Configuration (Optional) ---
    // coverage: {
    //   provider: 'v8', // or 'istanbul'
    //   reporter: ['text', 'json', 'html'],
    // },
  },
});

Key Configuration Explained:

  • plugins: [react()]: Ensures Vitest can correctly process JSX and other React features.
  • globals: true: Allows you to use describe, it, expect, etc., directly in your test files without importing them from the vitest module.
  • environment: 'happy-dom': This is crucial! It tells Vitest to use Happy DOM to simulate a browser environment, making global objects like document and window available and enabling DOM event handling.
  • setupFiles: (Optional) If you have global mocks or setup that needs to run before all tests (e.g., extending expect, setting up global mocks), you can specify the file here.
  • testTimeout: Adjust as needed to prevent complex tests from timing out.

4. Understanding useTinykeys Hook

To demonstrate keyboard event testing, we'll focus on a custom React Hook named useTinykeys. The purpose of this hook is to listen for specific keyboard combinations and execute a callback when they match.

Let's assume the core functionality of src/useTinykeys.ts is as follows (this is a simplified concept; a real implementation might be more complex):

https://github.com/hyperse-io/tinykeys/blob/main/src/useTinykeys.ts

5. Writing Test Cases

Now, let's write tests for useTinykeys. We'll use @testing-library/react to render a simple component that incorporates this hook and fireEvent to simulate keyboard events.

tests/useTinykeys.spec.tsx:

import { type ComponentType, useEffect } from 'react';
import { vi } from 'vitest';
import type { RenderResult } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import { useTinykeys } from '../src/useTinykeys.js';

const mockOnActionSelect = vi.fn();

const actionTree = {
  search: {
    id: 'search',
    shortcut: ['$mod+k'],
  },
  sequence: {
    id: 'sequence',
    shortcut: ['a', 'b', 'c'],
  },
};

function Viewer() {
  const bindingEvents = useTinykeys({
    actionTree,
    onActionSelect: mockOnActionSelect,
  });

  useEffect(() => {
    const unsubscribe = bindingEvents();
    return () => {
      unsubscribe();
    };
  }, [bindingEvents]);

  return <div />;
}

type Utils = RenderResult;

const setup = (Component: ComponentType) => {
  const utils = render(<Component />);
  return {
    ...utils,
  } as Utils;
};

describe('useTinykeys tests', () => {
  let utils: Utils;

  beforeEach(() => {
    utils = setup(Viewer);
    mockOnActionSelect.mockClear();
  });

  it('should call onActionSelect when Command+K is pressed', () => {
    // For macOS, use metaKey for Command
    fireEvent.keyDown(utils.container, {
      key: 'k',
      code: 'KeyK',
      metaKey: true,
      ctrlKey: false,
      altKey: false,
      shiftKey: false,
    });

    // For Windows/Linux, use ctrlKey for Control
    fireEvent.keyDown(utils.container, {
      key: 'k',
      code: 'KeyK',
      metaKey: false,
      ctrlKey: true,
      altKey: false,
      shiftKey: false,
    });
    expect(mockOnActionSelect).toHaveBeenCalledWith(actionTree.search);
  });

  it('should call onActionSelect with sequence when keys are pressed in order', () => {
    fireEvent.keyDown(utils.container, { key: 'a', code: 'KeyA' });
    fireEvent.keyDown(utils.container, { key: 'b', code: 'KeyB' });
    fireEvent.keyDown(utils.container, { key: 'c', code: 'KeyC' });
    expect(mockOnActionSelect).toHaveBeenCalledWith(actionTree.sequence);
  });
});

6. Running Tests

Add a test script to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui", // Use Vitest UI (optional)
    "coverage": "vitest run --coverage" // Generate coverage report
  }
}

Then run:

npm test
# Or, for watch mode:
# npm test -- --watch

Vitest will find and execute test files matching the include pattern (and outside the exclude pattern).

7. Key Considerations and Best Practices

  • key vs. code:
    • event.key: Represents the character value produced by the key (e.g., "a", "A", "#", "Enter"). Affected by layout and modifier keys.
    • event.code: Represents the physical key (e.g., "KeyA", "Digit1", "Enter"). Not affected by layout.
    • When testing and implementing shortcuts, event.key is often preferred (especially for alphanumeric keys). event.code might be better for non-character keys or when precise physical key control is needed.
  • Focus Management: If your keyboard events are only active when a specific element is focused, ensure you correctly simulate focus in your tests (e.g., element.focus()). @testing-library/user-event often handles this more gracefully.
  • fireEvent vs. userEvent:
    • fireEvent dispatches a single, raw DOM event.
    • @testing-library/user-event simulates a more complete user interaction sequence (e.g., userEvent.keyboard('A') simulates keydown, keypress, input, keyup), which is closer to real user behavior. For complex interactions, userEvent is generally the better choice. In this guide, fireEvent.keyDown is sufficient for testing the core logic.

8. Conclusion

By combining Vitest's speed, Happy DOM's lightweight DOM environment, and React Testing Library's best practices, we can build an efficient and reliable testing process for keyboard events and shortcut logic in React applications.

9. Further Reading

Hopefully, this guide helps you better test keyboard event handling in your React applications! If you have any questions or suggestions, feel free to discuss.

Community

We're excited to see the community adopt Hyperse-io, raise issues, and provide feedback. Whether it's a feature request, bug report, or a project to showcase, please get involved!