Testing jQuery code

Run jQuery under jsdom with Vitest or Jest, simulate real events, assert on the DOM, test a plugin's lifecycle, and escalate to a real browser for the cases jsdom cannot reach.

Setting up the environment

// vitest.config.js — jsdom gives you a document, jQuery runs on top of it
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.js']
  }
});
// test/setup.js — load jQuery into the jsdom window once
import jqueryFactory from 'jquery';
import { beforeEach } from 'vitest';

const $ = jqueryFactory(window);
globalThis.$ = $;
globalThis.jQuery = $;

// Reset the document between tests so one test's DOM cannot leak into the next.
beforeEach(() => {
  document.body.innerHTML = '';
  window.localStorage.clear();
});

// If the code under test is a plain script that expects a global jQuery,
// this setup is what makes it loadable under test at all.
ToolFitsLimitation
Vitest + jsdomFast unit tests for plugins and handlersNo layout, so no real measurements
Jest + jsdomThe same, in an older ecosystemSlower startup; config is heavier
PlaywrightReal events, real layout, real networkSlower; needs a running server
SeleniumLegacy browser matrixSlowest and most brittle
A hand-rolled assertion on innerHTMLQuick smoke checksMisses everything about events
💡
jsdom implements the DOM but not layout: every element has zero size and no position. Anything that measures — getBoundingClientRect, offsetWidth, visibility — will be zero or absent, so tests must assert on classes and attributes rather than geometry.

Testing behaviour and plugins

import { describe, it, expect, vi } from 'vitest';

describe('inbox delete', () => {
  it('removes the row when the delete button is clicked', () => {
    document.body.innerHTML = `
      <table><tbody id="tbody">
        <tr class="row" data-id="42"><td>#42</td><td><button class="delete">x</button></td></tr>
      </tbody></table>`;

    // Load the module under test AFTER the DOM exists, because it binds on load.
    require('../src/inbox.js');

    const $delete = $('#tbody .delete');
    $delete.trigger('click');

    expect(document.querySelectorAll('#tbody .row')).toHaveLength(0);
  });

  it('works for rows added after the handler was bound', () => {
    document.body.innerHTML = '<table><tbody id="tbody"></tbody></table>';
    require('../src/inbox.js');

    // Delegated handlers make this pass; per-row handlers make it fail.
    $('#tbody').append('<tr class="row" data-id="7"><td><button class="delete">x</button></td></tr>');
    $('#tbody .delete').trigger('click');

    expect(document.querySelectorAll('#tbody .row')).toHaveLength(0);
  });

  it('sends the id it read from the row', async () => {
    document.body.innerHTML = '<table><tbody id="tbody"><tr class="row" data-id="42"><td><button class="delete">x</button></td></tr></tbody></table>';
    const fetchMock = vi.fn(() => Promise.resolve({ ok: true, status: 204 }));
    globalThis.fetch = fetchMock;

    require('../src/inbox.js');
    $('#tbody .delete').trigger('click');

    expect(fetchMock).toHaveBeenCalledWith('/api/items/42', expect.objectContaining({ method: 'DELETE' }));
  });
});
// Simulating events: trigger() is convenient but it fires a jQuery synthetic
// event. For handlers that read event properties, dispatch a real one.
const button = document.querySelector('#save');

// jQuery synthetic — enough for click handlers
$(button).trigger('click');

// Native event with real properties, which is what user code sees in the browser
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));

// Form submission the way a browser does it
const form = document.querySelector('#signup');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));

// Asserting on emitted events: listen for the plugin's namespaced event
const spy = vi.fn();
$('#counter').on('change.plugin_counter', spy);
$('#counter').trigger('click');                       // or dispatch on the button
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0].value).toBe(1);
  • .trigger('click') also runs the browser's default action for some events, which can surprise you on a link or a submit button. Prefer .triggerHandler() when you want the handler without the default.
  • Always reset document.body.innerHTML between tests. Delegated handlers bound to document survive otherwise and fire in the next test.
  • If the module binds on load, clear the module registry between tests — vi.resetModules() in Vitest — or the second import is a no-op and the new DOM gets no handlers.
  • Assert on observable output: the resulting DOM, the fetch call, the emitted event. Asserting on internal state ties the test to the implementation.

What needs a real browser

BehaviourjsdomReal browser required
Class togglingWorksNo
Delegated click handlingWorksNo
Form serialisationMostly worksNo
:visible selectorsReturns nothing usefulYes
Animation and transition endNot implementedYes
Focus and tab orderPartialYes
Scroll positionNot implementedYes
File uploadRequires a File polyfillYes
Drag and dropVery limitedYes
// test/e2e/inbox.spec.js — Playwright for the parts jsdom cannot model
import { test, expect } from '@playwright/test';

test('a new row can be deleted immediately', async ({ page }) => {
  await page.goto('/inbox');

  // Add a row through the UI so it is bound by delegation, not by the initial render
  await page.getByRole('button', { name: 'Add row' }).click();
  const row = page.locator('tbody tr').last();
  await expect(row).toContainText('#43');

  await row.getByRole('button', { name: 'Delete' }).click();
  await expect(page.locator('tbody tr')).toHaveCount(2);

  // The keyboard path: this is the check jsdom cannot do meaningfully
  await page.keyboard.press('Tab');
  await expect(page.getByRole('button', { name: 'Delete' }).first()).toBeFocused();
});
// Keep one integration test that exercises the whole plugin lifecycle.
it('survives two mount/unmount cycles without leaking handlers', () => {
  document.body.innerHTML = '<div id="widget"></div>';

  for (let i = 0; i < 2; i++) {
    $('#widget').counter({ start: i });
    expect($('#widget').counter('getValue')).toBe(i);
    $('#widget').counter('destroy');
    expect($('#widget').counter('getValue')).toBeUndefined();
  }

  // After the second destroy, a click must do nothing.
  $('#widget').append('<button data-counter-increment></button>');
  $('#widget button').trigger('click');
  expect($('#widget').counter('getValue')).toBeUndefined();
});

FAQ

Why does my test see no jQuery handlers?
Almost always module caching or DOM ordering: the module was imported once in an earlier test and the current DOM was never bound, or the module ran before the markup existed. Reset modules between tests and make the binding happen after the DOM is set up.
Should I test the plugin or the page?
Test the plugin's contract — initialise, methods, events, destroy — with jsdom because those tests are fast and precise. Test the page's critical journeys in a real browser, where layout, focus and scrolling actually exist. Do not try to make jsdom behave like a browser.

Writing a jQuery plugin Performance, event delegation and memory

Last refreshed 2026-09-18.