Test scrolling in a react-window list with react-testing-library
Asked Answered
R

2

12

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.

Ritornello answered 20/7, 2020 at 10:12 Comment(2)
Have you tried scrolling the window instead of the table ? fireEvent.scroll(window, { target: { scrollY: 100 } }); I'm not sure if your table container has an overflow or not.Barely
@David Kerr I did not, but it might be worth a try. In the end, the test became obsolete because we rewrote most tests with browser automation (Playwright). In a real browser environment, the issue did no longer occur.Ritornello
S
1

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 })
Sublimation answered 26/7, 2022 at 12:15 Comment(3)
what is it - containerRef ? because containerRef is not defined.Yeoman
This is just an idea of what kind of thing needs to be done, containerRef would be typically a ref on the scrollable element (that could be window if the whole page scrolls). But if you want to implement this, you need to take time reading react-window source code to know what properties on what elements to mock like this. It's not an easy task. Otherwise there's no way around with react-testing-library.Sublimation
I agree, using react-testing-library for such a task is a stupid idea. may be later I will give an answer how to use cypressYeoman
L
1

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.

Lucchesi answered 26/7, 2022 at 12:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.