I had a scenario where certain presentation aspects depended on the scroll position. To make tests clearer, I defined the following mocks in test setup:
1. Mocks that ensure programmatic scrolls trigger the appropriate events:
const scrollMock = (leftOrOptions, top) => {
let left;
if (typeof (leftOrOptions) === 'function') {
// eslint-disable-next-line no-param-reassign
({ top, left } = leftOrOptions);
} else {
left = leftOrOptions;
}
Object.assign(document.body, {
scrollLeft: left,
scrollTop: top,
});
Object.assign(window, {
scrollX: left,
scrollY: top,
scrollLeft: left,
scrollTop: top,
}).dispatchEvent(new window.Event('scroll'));
};
const scrollByMock = function scrollByMock(left, top) { scrollMock(window.screenX + left, window.screenY + top); };
const resizeMock = (width, height) => {
Object.defineProperties(document.body, {
scrollHeight: { value: 1000, writable: false },
scrollWidth: { value: 1000, writable: false },
});
Object.assign(window, {
innerWidth: width,
innerHeight: height,
outerWidth: width,
outerHeight: height,
}).dispatchEvent(new window.Event('resize'));
};
const scrollIntoViewMock = function scrollIntoViewMock() {
const [left, top] = this.getBoundingClientRect();
window.scrollTo(left, top);
};
const getBoundingClientRectMock = function getBoundingClientRectMock() {
let offsetParent = this;
const result = new DOMRect(0, 0, this.offsetWidth, this.offsetHeight);
while (offsetParent) {
result.x += offsetParent.offsetX;
result.y += offsetParent.offsetY;
offsetParent = offsetParent.offsetParent;
}
return result;
};
function mockGlobal(key, value) {
mockedGlobals[key] = global[key]; // this is just to be able to reset the mocks after the tests
global[key] = value;
}
beforeAll(async () => {
mockGlobal('scroll', scrollMock);
mockGlobal('scrollTo', scrollMock);
mockGlobal('scrollBy', scrollByMock);
mockGlobal('resizeTo', resizeMock);
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: scrollIntoViewMock, writable: false });
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { value: getBoundingClientRectMock, writable: false });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { value: 250, writable: false });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { value: 250, writable: false });
});
The above ensures that, after a programmatic scroll takes place, the appropriate ScrollEvent
will be published, and the window
properties are updated accordingly.
2. Mocks that setup a basic layout for a collection of siblings
export function getPosition(element) {
return element?.getClientRects()[0];
}
export function scrollToElement(element, [extraX = 0, extraY = 0]) {
const { x, y } = getPosition(element);
window.scrollTo(x + extraX, y + extraY);
}
export const layoutTypes = {
column: 'column',
row: 'row',
};
function* getLayoutBoxIterator(type, { defaultElementSize }) {
const [width, height] = defaultElementSize;
let offset = 0;
while (true) {
let left = 0;
let top = 0;
if (type === layoutTypes.column) {
top += offset;
offset += height;
} else if (type === layoutTypes.row) {
left += offset;
offset += width;
}
yield new DOMRect(left, top, width, height);
}
}
function getLayoutProps(element, layoutBox) {
return {
offsetX: layoutBox.x,
offsetY: layoutBox.y,
offsetWidth: layoutBox.width,
offsetHeight: layoutBox.height,
scrollWidth: layoutBox.width,
scrollHeight: layoutBox.height,
};
}
function defineReadonlyProperties(child, props) {
let readonlyProps = Object.entries(props).reduce((accumulator, [key, value]) => {
accumulator[key] = {
value,
writable: false,
}; return accumulator;
}, {});
Object.defineProperties(child, readonlyProps);
}
export function mockLayout(parent, type, options = { defaultElementSize: [250, 250] }) {
const layoutBoxIterator = getLayoutBoxIterator(type, options);
const parentLayoutBox = new DOMRect(parent.offsetX, parent.offsetY, parent.offsetWidth, parent.offsetHeight);
let maxBottom = 0;
let maxRight = 0;
Array.prototype.slice.call(parent.children).forEach((child) => {
let layoutBox = layoutBoxIterator.next().value;
// eslint-disable-next-line no-return-assign
defineReadonlyProperties(child, getLayoutProps(child, layoutBox));
maxBottom = Math.max(maxBottom, layoutBox.bottom);
maxRight = Math.max(maxRight, layoutBox.right);
});
parentLayoutBox.width = Math.max(parentLayoutBox.width, maxRight);
parentLayoutBox.height = Math.max(parentLayoutBox.height, maxBottom);
defineReadonlyProperties(parent, getLayoutProps(parent, parentLayoutBox));
}
With those two in place, I would write my tests like this:
// given
mockLayout(/* put the common, direct parent of the siblings here */, layoutTypes.column);
// when
Simulate.click(document.querySelector('#nextStepButton')); // trigger the event that causes programmatic scroll
const scrolledElementPosition = ...; // get offsetX of the component that was scrolled programmatically
// then
expect(window.scrollX).toEqual(scrolledElementPosition.x); // verify that the programmatically scrolled element is now at the top of the page, or some other desired outcome
The idea here is that you give all siblings at a given level sensible, uniform widths and heights, as if they were rendered as a column / row, thus imposing a simple layout structure that the table
component will 'see' when calculating which children to show / hide.
Note that in your scenario, the common parent of the sibling elements might not be the root HTML element rendered by table
, but some element nested inside. Check the generated HTML to see how to best obtain a handle.
Your use case is a little different, in that you're triggering the event yourself, rather than having it bound to a specific action (a button click, for instance). Therefore, you might not need the first part in its entirety.
fireEvent.scroll(window, { target: { scrollY: 100 } });
I'm not sure if your table container has an overflow or not. – Barely