Testing the Grid
How to test an application that contains a GridCraft grid — what is worth asserting, what is a waste of a test, and the two mistakes that make a green suite meaningless.
The layer that pays for itself: your data source
A GridDataSource is plain TypeScript with no DOM and no Angular. It is also where the expensive bugs live — an off-by-one in paging, a sort key that never reaches the server, a filter shape the backend does not understand. Test it directly, with no grid in sight:
// Your data source is plain TypeScript. Test it WITHOUT a grid — it is the piece
// most likely to carry a bug, and it needs no DOM at all.
import { firstValueFrom } from 'rxjs';
import { InvoiceDataSource } from './invoice.data-source';
it('translates sort + filter into query params', async () => {
const http = { get: jest.fn().mockReturnValue(of({ rows: [], totalCount: 0 })) };
const ds = new InvoiceDataSource(http as any);
await firstValueFrom(ds.fetchRows({
offset: 40, limit: 20,
sorts: [{ key: 'total', dir: 'desc' }],
filters: { status: { operator: 'equals', value: 'paid' } },
}));
expect(http.get).toHaveBeenCalledWith('/api/invoices', {
params: expect.objectContaining({ offset: 40, limit: 20, sort: 'total:desc' }),
});
});This is the highest-value test in the list, and it runs in milliseconds. If you write only one kind of grid-adjacent test, write this one.
Component tests
Mount your page component and assert the things you own — that columns are built correctly, that a valueFormatter produces the string you expect, that an event handler updates the right state.
import { TestBed } from '@angular/core/testing';
import { GridcraftComponent, type GridColumn, type GridConfig } from '@gridcraft/angular';
import { of } from 'rxjs';
import { InvoicesPage } from './invoices-page';
describe('InvoicesPage', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [InvoicesPage] }));
it('gives every column a stable key', () => {
const fixture = TestBed.createComponent(InvoicesPage);
fixture.detectChanges();
const keys = fixture.componentInstance.columns.map(c => c.key);
expect(new Set(keys).size).toBe(keys.length);
});
});Note what is not here: no assertion about rendered rows. A component test can mount the grid, but the grid's rendering is asynchronous, virtualised and scroll-dependent, so asserting DOM rows in a unit test is slow, brittle, and tests our code rather than yours. Save row assertions for the browser.
End-to-end tests
This is where rendering assertions belong. Two rules make the difference between a test that catches regressions and one that only appears to:
Wait for data, not for the grid
The grid element exists immediately; the rows do not. A selector that resolves before the first fetch returns gives you an empty list, and every assertion after it passes vacuously. Wait for a row — or better, for a cell with a value in it.
import { test, expect } from '@playwright/test';
test('sorting reorders the rows', async ({ page }) => {
await page.goto('/invoices');
// Wait for DATA, not for the grid element — an empty grid renders instantly and
// every assertion after it passes against nothing.
const cells = page.locator('.gc-row .gc-cell[data-col="total"]');
await expect(cells.first()).toBeVisible();
const before = await cells.allTextContents();
await page.getByRole('columnheader', { name: 'Total' }).click();
await expect.poll(() => cells.allTextContents()).not.toEqual(before);
});Assert the outcome, not the interaction
Clicking a header that is not sortable is a silent no-op that still looks like a successful click. The assertion that catches it is the data reordered — read the values before and after, and compare. The same principle applies throughout: infinite scroll means more rows loaded, a merged cell means one value where there were two, select-all means the count changed. Asserting that a class was added proves the CSS ran, not that the feature worked.
Selectors that will not move under you
Prefer roles and accessible names — the grid renders proper columnheader, row and gridcell semantics, and those are a contract we keep. Class names like .gc-row and .gc-cell are stable in practice and fine to use; the deeper internal classes are not, and a test that reaches for one is a test that will need fixing on a release that changed nothing you use.
A thin helper keeps that decision in one place:
// A tiny helper beats repeating selectors across 40 specs.
export const grid = (page: Page) => ({
rows: () => page.locator('.gc-row'),
cell: (row: number, col: string) =>
page.locator('.gc-row').nth(row).locator(`[data-col="${col}"]`),
sortBy: (header: string) => page.getByRole('columnheader', { name: header }).click(),
waitForData: () => page.locator('.gc-row').first().waitFor(),
});Accessibility
The grid is built to WCAG 2.1 AA and is checked against axe-core on every change to our own codebase. That covers the grid; it does not cover the page you put around it, which is where violations usually are. Scope a check to your grid and one to the whole page — the difference between the two results is yours to fix:
import AxeBuilder from '@axe-core/playwright';
test('the grid has no accessibility violations', async ({ page }) => {
await page.goto('/invoices');
await page.waitForSelector('.gc-row');
const results = await new AxeBuilder({ page }).include('.gridcraft').analyze();
expect(results.violations).toEqual([]);
});Memory and teardown
A grid that is mounted and unmounted repeatedly — a route the user visits often, a dialog, a tab — should return its memory. The grid tears down its own listeners, observers and timers on destroy, and we gate that in CI. If you add custom cell templates that register listeners or timers of their own, they are yours to clean up, and this is how you find out whether you did:
test('unmounting the grid releases its listeners', async ({ page }) => {
await page.goto('/invoices');
await page.waitForSelector('.gc-row');
// Navigate away and back a few times, then compare — assert GROWTH, never an
// absolute number. Absolute heap figures differ per machine and per Chrome build,
// and a test that pins one is a test that fails for the wrong reason.
const sample = async () => {
await page.evaluate(() => (globalThis as any).gc?.());
return page.evaluate(() => (performance as any).memory.usedJSHeapSize);
};
const first = await sample();
for (let i = 0; i < 5; i++) {
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Invoices' }).click();
await page.waitForSelector('.gc-row');
}
const last = await sample();
expect(last - first).toBeLessThan(first * 0.5);
});Performance
If you care about render cost, measure deterministic invariants rather than frame rate. The number of rendered DOM rows should stay bounded while scrolling a large dataset — that is a property, it is stable across machines, and it goes red the moment virtualisation stops working. Frames per second on a shared CI runner is noise, and a gate built on noise gets ignored.
Give any performance harness a fixed grid height. Without one the grid renders every row, there is no virtual window, and a "constant DOM size" assertion passes for the worst possible reason.
A short checklist
- Data source tested directly, with no DOM.
- Column definitions and formatters tested in a component spec.
- One e2e test per feature you actually rely on, asserting reordered/filtered data.
- Every e2e test waits for a row before asserting anything.
- One axe check scoped to the grid, one to the page.
- A mount/unmount loop if the grid lives on a route users come back to.
- Nothing that asserts against an internal class name or an implementation detail.