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.| Tool | Fits | Limitation |
|---|---|---|
| Vitest + jsdom | Fast unit tests for plugins and handlers | No layout, so no real measurements |
| Jest + jsdom | The same, in an older ecosystem | Slower startup; config is heavier |
| Playwright | Real events, real layout, real network | Slower; needs a running server |
| Selenium | Legacy browser matrix | Slowest and most brittle |
A hand-rolled assertion on innerHTML | Quick smoke checks | Misses 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.innerHTMLbetween tests. Delegated handlers bound todocumentsurvive 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
| Behaviour | jsdom | Real browser required |
|---|---|---|
| Class toggling | Works | No |
| Delegated click handling | Works | No |
| Form serialisation | Mostly works | No |
:visible selectors | Returns nothing useful | Yes |
| Animation and transition end | Not implemented | Yes |
| Focus and tab order | Partial | Yes |
| Scroll position | Not implemented | Yes |
| File upload | Requires a File polyfill | Yes |
| Drag and drop | Very limited | Yes |
// 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.
Related
Writing a jQuery plugin Performance, event delegation and memory
Last refreshed 2026-09-18.