.ai/TESTING.md
Runner:
handsontable/jest.config.js)test/scripts/run-puppeteer.mjs)npm run test:walkontable)jest-jasmine2 (not the default Jest runner)Assertion Library:
expect() for unit testsexpect() for E2E teststest/helpers/custom-matchers.js (shared between unit and E2E)Run Commands:
# From handsontable/ directory:
npm run test:unit # Run all Jest unit tests (~216 files)
npm run test:e2e # Build + run Jasmine E2E tests (~946 spec files)
npm run test:walkontable # Run Walkontable-specific tests
npm run test:e2e.watch # Watch mode for E2E tests
npm run test:types # TypeScript type checking only
npm run test # Full pipeline: lint + unit + types + walkontable + e2e + production
# From monorepo root (pnpm):
pnpm --filter handsontable run test:unit
pnpm --filter handsontable run test:e2e
# Run specific unit test pattern (must be run from handsontable/ directory):
npm run test:unit --testPathPattern=cellMeta
# Run specific E2E test pattern (must be run from handsontable/ directory):
# The pattern is baked into the Rspack bundle at dump time via __ENV_ARGS__.testPathPattern.
# rspack.config.js copies the lowercase npm_config_testpathpattern to npm_config_testPathPattern
# so the standard npm --key=value syntax works.
# Step 1: rebuild the test bundle with the pattern (skips full UMD build):
npm run test:e2e.dump --testPathPattern=filters
# Step 2: run puppeteer against the filtered bundle:
npm run test:e2e.puppeteer
# Or, to do both in one command (also rebuilds UMD bundles):
npm run test:e2e --testPathPattern=filters
# NOTE: changing the pattern requires re-running test:e2e.dump — the pattern is compiled in.
# NOTE: when invoking the two steps separately, pass --testPathPattern AND --theme to BOTH
# commands. Each `npm run` is a separate npm process with its own env; puppeteer computes
# the same hash as dump to locate the runner HTML, so the flags must match.
# Coverage:
npm run test:unit -- --coverage
Parallel E2E runs:
test:e2e.dump hashes --testPathPattern + --theme into a short runId. Each run's bundle and runner HTML are suffixed with that ID, so runs with different inputs do not clobber each other:
handsontable/test/dist/main.entry.<runId>.jshandsontable/test/E2ERunner-<runId>.htmlhandsontable/test/E2ERunner.htmlrun-puppeteer.mjs computes the same hash from npm_config_testpathpattern / npm_config_theme and opens the matching HTML. It also binds the local HTTP server to the first free port starting at 8086 (retries on EADDRINUSE, up to 100 ports), so each concurrent run gets its own port. Any number of npm run test:e2e --testPathPattern=<X> invocations with distinct patterns (or themes) can run in parallel without further configuration -- the practical limit is machine resources, not the tooling.
The helper that derives the hash lives in handsontable/.config/helper/run-id.js -- used by both the Rspack config and the Puppeteer script so they stay in lockstep.
Test Environment:
test/bootstrap.js)Location:
src/**/__tests__/ directorieshandsontable/test/helpers/__tests__/ (for shared test helpers like themeLayoutFromTokens and its contract tests)handsontable/test/e2e/ (top-level core tests)handsontable/test/e2e/core/ (per-method tests like selectCell.spec.js)handsontable/test/e2e/settings/ (per-setting tests like colWidths.spec.js)src/plugins/{pluginName}/__tests__/ (alongside plugin source)Naming:
{feature}.unit.js (e.g., cellMeta.unit.js, dataFilter.unit.js){Feature}.spec.js or Core_{feature}.spec.js (e.g., Core_dataSchema.spec.js, filters.spec.js)*.types.ts in test/types/Structure Examples:
src/dataMap/metaManager/metaLayers/
├── cellMeta.js
├── columnMeta.js
├── globalMeta.js
├── tableMeta.js
└── __tests__/
├── cellMeta.unit.js
├── columnMeta.unit.js
├── globalMeta.unit.js
└── tableMeta.unit.js
src/plugins/filters/
├── filters.js
├── index.js
├── conditionCollection.js
└── __tests__/
├── filters.spec.js
├── filtersUI.spec.js
├── conditionCollection.unit.js
├── dataFilter.unit.js
├── filters.types.ts
├── helpers/
│ ├── fixtures.js # Test data factories
│ └── utils.js # Plugin-specific helpers (auto-loaded)
├── hooks/
├── methods/
├── component/
└── a11y/
test/e2e/
├── Core_dataSchema.spec.js # Core feature tests
├── Core_render.spec.js
├── core/ # Per-method API tests
│ ├── selectCell.spec.js
│ ├── getData.spec.js
│ └── alter/
├── settings/ # Per-setting tests
│ ├── colWidths.spec.js
│ ├── fixedColumnsStart.spec.js
│ └── validator.spec.js
├── hooks/ # Hook-specific tests
└── i18n/ # Internationalization tests
E2E Suite Organization (Jasmine) -- Standard Boilerplate:
describe('MyPlugin', () => {
const id = 'testContainer';
beforeEach(function() {
this.$container = $(`<div id="${id}"></div>`).appendTo('body');
});
afterEach(function() {
if (this.$container) {
destroy();
this.$container.remove();
}
});
it('should do something', async() => {
handsontable({
data: createSpreadsheetData(5, 5),
myPlugin: true,
});
await selectCell(0, 0);
expect(getDataAtCell(0, 0)).toBe('A1');
});
});
Critical patterns:
beforeEach uses function() (not arrow) to access this for $containerafterEach always calls destroy() then this.$container.remove()it() callbacks must be async in *.spec.js (ESLint enforced)await-ed (ESLint enforced)Unit Test Structure (Jest):
import GlobalMeta from '../globalMeta';
import ColumnMeta from '../columnMeta';
import CellMeta from '../cellMeta';
import { registerAllCellTypes } from '../../../../cellTypes';
registerAllCellTypes();
describe('CellMeta', () => {
it('should reflect changes when global meta properties changed', () => {
const globalMeta = new GlobalMeta();
const columnMeta = new ColumnMeta(globalMeta);
const meta = new CellMeta(columnMeta);
globalMeta.getMeta().copyable = false;
expect(meta.getMeta(2, 0)).toHaveProperty('copyable', false);
});
});
Key differences between unit and E2E tests:
E2E Tests Must Be async:
Enforced by ESLint rule handsontable/require-async-in-it in *.spec.js (applies to it(), it.flaky(), and fit.flaky() calls):
// CORRECT
it('should do something', async() => {
handsontable(config);
await render();
await selectCell(0, 0);
expect(getDataAtCell(0, 0)).toBe('A1');
});
// INCORRECT -- ESLint error
it('should do something', () => {
handsontable(config);
render(); // Must be await-ed
});
Flaky Test Retries:
Use it.flaky() (or fit.flaky()) to mark tests that fail intermittently due to timing or environment issues. The test is retried up to 3 times before reporting a failure. Between retries, any existing Handsontable instance is destroyed and the container is cleaned up.
it.flaky('should handle a timing-sensitive operation', async() => {
handsontable({ data: createSpreadsheetData(5, 5) });
await selectCell(0, 0);
expect(getDataAtCell(0, 0)).toBe('A1');
});
The test description is prefixed with [flaky] in CI output for visibility. Defined in test/helpers/it-themes-extension.js.
HOT Methods Requiring await (from handsontable/.eslintrc.js):
Core API: alter, clear, deselectCell, emptySelectedCells, listen, loadData, populateFromArray, refreshDimensions, render, scrollToFocusedCell, scrollViewportTo, unlisten
Selection: selectAll, selectCell, selectColumns, selectRows
Data mutation: setDataAtCell, setDataAtRowProp, spliceCellsMeta, spliceCol, spliceRow
Settings: updateData, updateSettings, useTheme
Validation: validateCell, validateCells, validateColumns, validateRows
Rendering control: suspendExecution, suspendRender
Scroll helpers: scrollViewportVertically, scrollViewportHorizontally, scrollWindowTo, scrollWindowBy
Context menu: contextMenu, selectContextMenuOption, openContextSubmenuOption, selectContextSubmenuOption
Dropdown menu: dropdownMenu, selectDropdownMenuOption, openDropdownSubmenuOption, openDropdownByConditionMenu, selectDropdownByConditionMenuOption
Column/row operations: resizeColumn, resizeRow, moveSecondDisplayedRowBeforeFirstRow, moveFirstDisplayedRowAfterSecondRow, swapDisplayedColumns
Mouse events: simulateTouch, triggerTouchEvent, mouseDown, mouseOver, mouseUp, mouseClick, contextMenuEvent, simulateClick, mouseDoubleClick, mouseRightDown, mouseRightUp
Keyboard events: keyDownUp, keyDown, keyUp
Scroll-Awaiting Pattern (test/helpers/utils.js):
Many helpers (e.g., selectCell, scrollViewportTo, selectAll) are wrapped with waitOnScroll(), which returns a Promise that resolves after any triggered scroll completes. This is why they must be await-ed.
Delay Pattern:
await sleep(100); // Wait for async validation or animation
await sleep(200); // Wait for dropdown menus to render
Unit tests are synchronous: handsontable/require-async-in-it and handsontable/require-await are both off for *.unit.js.
Framework: Mocks provided via Jest config and test bootstrap
Available Mocks in test/__mocks__/:
resizeObserverMock.js -- ResizeObserverMock class with no-op observe(), unobserve(), disconnect()intersectionObserverMock.js -- IntersectionObserverMock classstyleMock.js -- Returns empty object {} for CSS/SCSS imports (Jest moduleNameMapper)cssPolyfill.js -- CSS polyfill mockMock Setup in test/bootstrap.js:
import { ResizeObserverMock } from './__mocks__/resizeObserverMock';
import { IntersectionObserverMock } from './__mocks__/intersectionObserverMock';
beforeAll(() => {
window.IntersectionObserver = window.IntersectionObserver ?? IntersectionObserverMock;
window.ResizeObserver = window.ResizeObserver ?? ResizeObserverMock;
});
Spy Pattern for Hooks:
const onAfterValidate = jasmine.createSpy('onAfterValidate');
handsontable({
data: arrayOfObjects(),
columns: [{ data: 'id', validator(value, cb) { cb(true); } }],
afterValidate: onAfterValidate
});
await setDataAtCell(2, 0, 123);
await sleep(100);
expect(onAfterValidate).toHaveBeenCalledWith(true, 123, 2, 'id');
What to Mock:
What NOT to Mock:
Test Data Creation:
// Generate spreadsheet data with cell coordinates as values
const data = createSpreadsheetData(5, 5);
// Returns: [['A1', 'B1', 'C1', ...], ['A2', 'B2', 'C2', ...], ...]
handsontable({
data: createSpreadsheetData(10, 10),
colHeaders: true
});
Plugin-Specific Test Data:
Plugins define their own fixtures in src/plugins/{name}/__tests__/helpers/fixtures.js:
// Example: src/plugins/filters/__tests__/helpers/fixtures.js
export function getDataForFilters() { /* ... */ }
export function getColumnsForFilters() { /* ... */ }
Auto-Loading of Plugin Helpers:
test/helpers/index.js uses require.context to auto-discover and export all files in src/plugins/**/helpers/*.js to the global scope:
[
require.context('./../../src/plugins', true, /^\.\/.*\/helpers\/.*\.js$/),
].forEach((req) => {
req.keys().forEach((key) => {
exportToWindow(req(key));
});
});
Location Summary:
test/helpers/common.js -- Core test helpers (HOT API wrappers, data factories)test/helpers/mouseEvents.js -- Mouse event simulationtest/helpers/keyboardEvents.js -- Keyboard event simulationtest/helpers/asciiTable.js -- ASCII table generation for selection assertionstest/helpers/custom-matchers.js -- Custom Jasmine/Jest matcherstest/helpers/utils.js -- waitOnScroll() decorator and utilitiestest/helpers/focusNavigator.js -- Focus navigation helperstest/helpers/htmlNormalize.js -- HTML normalization for assertionstest/helpers/it-themes-extension.js -- Theme-specific and flaky test extensionssrc/plugins/{name}/__tests__/helpers/ -- Plugin-specific test data and utilitiesFrom test/helpers/custom-matchers.js:
toBeInArray(expected) -- Check if value is in an arraytoBeFunction() -- Check if value is a functiontoEqualCellRange(pattern) -- Compact cell range comparison:
expect(hot.getSelectedRangeLast()).toEqualCellRange('highlight: 3,2 from: 3,2 to: 5,5');
expect(hot.getSelectedRange()).toEqualCellRange([
'highlight: 2,3 from: 1,2 to: 4,4',
'highlight: 3,2 from: 3,2 to: 5,5',
]);
toBeVisible() -- Check CSS visibility/display of elementtoBeAroundValue(expected, diff) -- Fuzzy numeric comparison within margintoBeVisibleAsSelection() -- ASCII table-based selection visualization assertionASCII Table Selection Testing (from test/helpers/asciiTable.js):
Tests selection patterns by rendering an ASCII representation of the table's selection state using symbols like # (current), 0 (area), r (row), c (column).
Theme-dependent expected values in E2E tests come from a single resolver:
| File | Role |
|---|---|
test/helpers/themeLayoutFromTokens.js | Public entry point -- token-backed primitives (defaultDataRowHeight, overlayHeight, …) plus scenario-specific e2e* regression helpers with descriptive names (e.g. e2eGcrEditedCellOuterHeight, e2eManualRowResizerPositionFixedTopMasterFourthRow); auto-discovers themes from src/themes/theme/index.js; call via global getThemeLayout() in specs |
themeLayoutFromTokens(themeName) reads density and tokens from handsontable/src/themes/theme/<name>.js.
Changing a theme's density in that module propagates to all tests automatically -- no edits to test helpers are needed.
All expectations must be pure expressions over tokens + density tokens + sizing tokens, or derived from live DOM measurements. Numeric density triplets ({ compact: N, default: N, comfortable: N }) are not used anywhere -- they were the old pattern and have been eliminated.
When a value cannot be derived from tokens (text shaping, autosize widths, pixel rounding), the spec uses one of:
getCell(r, c).offsetWidth, hot().getColWidth(col), hot().getPlugin('autoColumnSize').getColumnWidth(col)expect(widthAfter).toBeGreaterThan(widthBefore), expect(inputWidth).toBeLessThanOrEqual(menuWidth)toBeAroundValue(expected, 2) or Math.abs(actual - expected) <= 1See the handsontable-css-dev skill for the full four-layer token process. The steps specific to E2E test infrastructure are:
src/themes/static/variables/tokens/<name>.js -- camelCase token keys, values reference other tokens or primitives.src/themes/static/variables/colors/<name>.js -- color palette for the theme.src/themes/static/variables/icons/<name>.js -- icon definitions (or re-export an existing one if icons are shared).src/themes/static/css/theme/ht-theme-<name>.css + ht-theme-<name>-no-icons.css -- declare all --ht-* variables for the new theme.src/themes/theme/<name>.js -- exports { name, density, icons, colors, tokens }.src/themes/theme/index.js so auto-discovery picks it up.src/themes/engine/utils/validation.js (VALID_TOKEN_KEYS Set) -- add any new token keys introduced by the theme.TokenKey union: types/themes.d.ts -- add any new token keys so TypeScript consumers get correct types..github/workflows/test.yml.No edits needed to themeLayoutFromTokens.js, common.js, unit tests, or any spec file. Auto-discovery handles the rest.
Iframe doc.write shells must use absolute stylesheet URLs (about:blank iframes). Use globals from test/helpers/common.js: getE2eThemeStylesheetLinkTagsHtml() (all themes in E2E_REGISTERED_THEME_KEYS order), getE2eThemeStylesheetLinkTagHtml(key) for a single theme, and getE2eNormalizeStylesheetLinkTagHtml() when tests need lib/normalize.css. E2E_REGISTERED_THEME_KEYS is derived automatically from src/themes/theme/index.js -- no manual registration required.
const layout = getThemeLayout(); // global, backed by themeLayoutFromTokens(getLoadedTheme())
expect(getRowHeight(0)).toBe(layout.defaultDataRowHeight);
expect(getMaster().height()).toBe(layout.overlayHeight({ rows: 3 }));
expect(topOverlay().getScrollPosition()).toBe(layout.verticalScrollForRow(250));
From getThemeLayout():
defaultDataRowHeight -- outer height of a data row (content + 1px border)defaultColumnHeaderHeight -- content height of column header (no border)firstRenderedRowDefaultHeight -- first row in an overlay (extra 1px compensation)defaultColumnWidth -- 50px (Walkontable constant)defaultRowHeaderWidth -- 50px for every theme (Walkontable default row-header column width; used for E2E container width math so horizontal viewport matches across themes)cellContentHeight -- same as defaultColumnHeaderHeight (TD clientHeight)lineHeight, cellVerticalPadding, cellHorizontalPadding, cellBorderWidth -- token primitives to compose formulas fromdensityLevel -- 'compact' | 'default' | 'comfortable' read from the theme module (exposed for diagnostic access; do not branch on it -- primitives already vary per theme)overlayHeight({ rows, includeFirstRowCompensation }) -- compute overlay section heightverticalScrollForRow(rowIndex) -- compute vertical scroll for row-at-top snape2e*() helpers -- shared regression geometry with descriptive names (e.g. e2eGcrEditedCellOuterHeight(), e2eManualRowResizerPositionFixedTopMasterFourthRow()) expressed as pure arithmetic expressions over the primitives above. No density-name branching, no hardcoded per-theme literals. Add new scenarios in themeLayoutFromTokens.js (in the buildThemeLayoutE2eHelpers function) when multiple specs would otherwise embed the same formula. Add targeted unit tests in themeLayoutFromTokens.unit.js for token-derived formulas (not bulk loops that only restate helper return values).Additional viewport helpers in common.js (globals in E2E):
expectedVisibleRows(containerHeight, colHeaderRows = 1) -- number of fully visible data rows for a given container heightexpectedLastFullyVisibleRow(containerHeight, colHeaderRows = 1) -- 0-based index of the last fully visible data rowcontainerHeightForRows(rowCount, colHeaderRows = 1) -- container height that guarantees exactly rowCount fully visible data rows (prefer this over hardcoded height: literals when the test's intent is "N rows visible")scaleHeight(mainThemeHeight) / scaleHeightWithScrollbar(mainThemeHeight) -- scale a main-theme pixel height proportionally to the current theme's row heightgetPaginationContainerHeight() -- returns the pagination bar's rendered offsetHeight measured live from the DOM once per run; theme/density/token independentlayout.verticalScrollForRow(rowIndex)layout.overlayHeight({ rows: N })layout.cellContentHeightlayout.e2e*() helpers with descriptive names (e.g. layout.e2eGcrEditedCellOuterHeight()) instead of if (getLoadedTheme() === '…') branches in spec files{ compact: N, default: N, comfortable: N } -- the legacy pattern; derive from tokens or measure from DOM insteadgetLoadedTheme() !== 'main' guards in spec files -- every test should run under every themeswitch / getLoadedTheme() comparisons in spec files for layout numbers -- derive from tokens or add a token-formula helper in themeLayoutFromTokens.jsswitch statements in helpers for values derivable from tokensRequirements: No enforced minimum coverage target
View Coverage:
npm run test:unit -- --coverage # Show coverage after Jest run
Coverage Output:
handsontable/coverage/src/ directoryCoverage Goals:
Unit Tests (*.unit.js):
jest-jasmine2 runner)src/**/__tests__/E2E Tests (*.spec.js):
test/e2e/ and src/plugins/{name}/__tests__/Type Tests (*.types.ts):
test/types/handsontable/types/Walkontable Tests:
npm run test:walkontable)src/3rdparty/walkontable/test/SpecRunner.html)Visual Regression Tests:
visual-tests/visual-tests/playwright.config.ts and playwright-cross-browser.config.tsvisual-tests/screenshots/Test against all lifecycle states:
// 1. Enable via settings
it('should enable plugin when option is true', async() => {
handsontable({ data: createSpreadsheetData(5, 5), myPlugin: true });
expect(getPlugin('myPlugin').isEnabled()).toBe(true);
});
// 2. Disable via updateSettings
it('should disable plugin via updateSettings', async() => {
handsontable({ data: createSpreadsheetData(5, 5), myPlugin: true });
await updateSettings({ myPlugin: false });
expect(getPlugin('myPlugin').isEnabled()).toBe(false);
});
// 3. Enable/disable programmatically
it('should re-enable after programmatic disable', async() => {
handsontable({ data: createSpreadsheetData(5, 5), myPlugin: true });
getPlugin('myPlugin').disablePlugin();
await render();
getPlugin('myPlugin').enablePlugin();
await render();
expect(getPlugin('myPlugin').isEnabled()).toBe(true);
});
When fixing bugs or adding features handling data arrays, include tests with 50k+ rows to catch stack overflow and performance issues:
it('should handle 50k rows without stack overflow', () => {
const largeData = createSpreadsheetData(50000, 10);
largeData.forEach(row => processRow(row)); // forEach, not spread
expect(largeData.length).toBe(50000);
});
Test non-consecutive selections, header selections, and active layers:
it('should maintain selections after render', async() => {
handsontable(config);
await selectCell(0, 0);
await keyDown('ctrl');
await selectCell(2, 2);
await keyUp('ctrl');
await render();
const selected = getSelected();
expect(selected.length).toBe(2);
});
HOT API Helpers (from test/helpers/common.js, available globally):
handsontable(options), getInstance(), destroy(), spec()render(), refreshDimensions()selectCell(), selectCells(), selectColumns(), selectRows(), selectAll(), deselectCell()getData(), getDataAtCell(), getDataAtCol(), getDataAtRow(), getSourceData()setDataAtCell(), setDataAtRowProp(), loadData(), updateData(), clear()updateSettings(), getSettings(), useTheme()getCell(), getCellMeta(), getCellEditor(), getCellRenderer(), getCellValidator()getPlugin(name)countRows(), countCols(), countRenderedRows(), countRenderedCols()alter(), spliceCol(), spliceRow(), spliceCellsMeta()validateCell(), validateCells(), validateColumns(), validateRows()getColHeader(), getRowHeader(), countColHeaders(), countRowHeaders()scrollViewportTo(), scrollToFocusedCell()toPhysicalRow(), toPhysicalColumn(), toVisualRow(), toVisualColumn()Data Generation:
createSpreadsheetData(rows, cols) -- Grid with coordinates as values ('A1', 'B2', etc.)Async Utilities:
sleep(delay = 100) -- Promise-based delaypromisfy(fn) -- Convert callback to PromiseDOM Event Helpers (from test/helpers/mouseEvents.js):
mouseDown(element, button, eventProps)mouseUp(element, button, eventProps)mouseOver(element, button, eventProps)mouseClick(element, button, eventProps)mouseMove(element, button, eventProps)contextMenuEvent(element)simulateClick(element, buttonKey, eventProps) -- Full click sequence (mousedown + mouseup + click + focus)mouseDoubleClick(element, buttonKey, eventProps)mouseRightDown(element), mouseRightUp(element)Keyboard Event Helpers (from test/helpers/keyboardEvents.js):
keyDown(key) -- Simulate keydown eventkeyUp(key) -- Simulate keyup eventkeyDownUp(key) -- Simulate full keydown+keyup sequence'enter', 'esc', 'tab', 'arrowdown', 'arrowup', 'arrowleft', 'arrowright', 'ctrl', 'shift', 'space', 'backspace', 'delete', etc.Test Bootstrap (handsontable/test/bootstrap.js):
ResizeObserver and IntersectionObserver if missingexportToGlobal()E2E Bootstrap (handsontable/test/helpers/index.js):
test/bootstrap.jscommon.js, mouseEvents.js, keyboardEvents.js to windowrequire.contextE2E Common Bootstrap (handsontable/test/helpers/common.js):
beforeEach/afterEach for scroll reset and spec context management$.fn.handsontable to auto-inject theme in E2E testshandsontableMethodFactory() that creates global wrappers for all HOT instance methodswaitOnScroll() from test/helpers/utils.jsJest Configuration (handsontable/jest.config.js):
{
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/test/bootstrap.js'],
testRegex: '\\.unit\\.js$',
testRunner: 'jest-jasmine2',
moduleNameMapper: {
'^handsontable(.*)$': '<rootDir>/src$1',
'^walkontable(.*)$': '<rootDir>/src/3rdparty/walkontable/src$1',
'\\.(css|scss)$': '<rootDir>/test/__mocks__/styleMock.js',
}
}