Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Test scrolling in a react-window list with react-testing-library

I am stuck here with a test where I want to verify that after scrolling through a list component, imported by react-window, different items are being rendered. The list is inside a table component that saves the scrolling position in React context, which is why I need to test the whole table component.

Unfortunately, the scrolling event seems to have no effect and the list still shows the same items.

The test looks something like this:

render(
  <SomeProvider>
    <Table />
  </SomeProvider>
)

describe('Table', () => {
  it('scrolls and renders different items', () => {
    const table = screen.getByTestId('table')

    expect(table.textContent?.includes('item_A')).toBeTruthy() // --> true
    expect(table.textContent?.includes('item_Z')).toBeFalsy() // --> true

    // getting the list which is a child component of table
    const list = table.children[0]

    fireEvent.scroll(list, {target: {scrollY: 100}})

    expect(table.textContent?.includes('item_A')).toBeFalsy() // --> false
    expect(table.textContent?.includes('item_Z')).toBeTruthy() // --> false
  })
})

Any help would be much appreciated.

like image 801
Albert Schilling Avatar asked Mar 18 '26 13:03

Albert Schilling


2 Answers

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.

like image 108
crizzis Avatar answered Mar 20 '26 07:03

crizzis


react-testing-library by default renders your components in a jsdom environment, not in a browser. Basically, it just generates the html markup, but doesn't know where components are positioned, what are their scroll offsets, etc.

See for example this issue.

Possible solutions are :

  • use Cypress
  • or override whatever native attribute react-window is using to measure scroll offset in your container (hacky). For example, let's say react-window is using container.scrollHeight :
// artificially setting container scroll height to 200
Object.defineProperty(containerRef, 'scrollHeight', { value: 200 })
like image 39
Emmanuel Meric de Bellefon Avatar answered Mar 20 '26 06:03

Emmanuel Meric de Bellefon



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!