Thoughts on Testing Components

5 minute read Published: 2020-07-22

Testing components is a large undertaking. It can be difficult to decide the scope of testing or if something even needs tesing.

I recently came upon a problem with an occlusion culled list. Occlusion culling is a technique used to increase performance of long lists and tables. It does so by not rendering the parts of the component that aren't visible on the user's screen. This also means that fewer DOM nodes are needed.

Occlusion Culling

A simple occlusion culled list might look like this in code: {% raw %}

const itemHeight = 31
const tableHeight = 312
const items = Array.from({ length: 5000 }).map((_, i) => ({
  id: i + 1,
  utilization: (i + 1) / 100,
}))

export default function OcclusionList() {
  const [scrollTop, setScrollTop] = useState(0)
  const begin = (scrollTop / itemHeight) | 0
  // Add 2 so that the top and bottom of the page are filled with
  // next/prev item, not just whitespace if item not in full view
  const end = begin + ((tableHeight / itemHeight) | 0) + 2

  const offset = scrollTop % itemHeight

  const visibleItems = items.slice(begin, end)
  return (
    <div
      className="table"
      onScroll={e => setScrollTop((e.target as HTMLDivElement).scrollTop)}
      style={{
        maxHeight: `${tableHeight}px`,
        overflowY: 'scroll',
      }}
    >
      <div
        className="fake-height"
        style={{ height: `${items.length * itemHeight}px` }}
      >
        <ul className="body" style={{ top: `${scrollTop - offset}px` }}>
          {visibleItems.map(item => (
            <li className="row" key={item.id}>
              <span className="id">{item.id}</span>
              <span className="utilization">
                {(item.utilization * 100).toFixed(0) + '%'}
              </span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

{% endraw %}

If you inspect the list above using browser devtools, you will see that as you scroll the component replaces the li's in the DOM with new ones as you scroll. A constant number of list items remains in the DOM.

This increases performance even to the point of hitting the 1000ms mobile benchmark by deferring the rendering of below the fold content.

However, this presents a problem for your friendly QA staff.

The Problem

A common pattern in end-to-end testing is:

  1. Setting up all of data the user expects to see in the actual table
  2. Parsing data from the actual table
  3. Asserting the equivalency of the two

Or in code:

function Test() {
  const expectedData = Array.from({ length: 5000 }).map((_, i) => ({
    id: i + 1,
    utilization: (i + 1) / 100,
  }))
  const actualData = parseDataFromTable(
    document.querySelector('.table') as HTMLDivElement,
  )
  assertEqual(expectedData.length, actualData.length)
  assertEqual(expectedData, actualData)
  console.log('Success!')
}

function parseDataFromTable(table: HTMLDivElement) {
  return Array.from(table.querySelectorAll('.body > .row')).map(rowElement => ({
    id: Number(rowElement.children[0].textContent),
    utilization:
      Number(rowElement.children[1].textContent.replace('%', '')) / 100,
  }))
}

Why does the above test fail?

It fails because we assume that all the data we want to assert against is in the DOM; however, because we are using occlusion culling, the data simply isn't there.

The Solution

In order to properly test this table, we're going to have to emulate a user scrolling through it. This means our parsing function has to run asynchronously to gather all the data.

How do we scroll through the table programatically?

Let's start by looking at the table markup. In this example, we're using a list of the following form


<div class='table'>
  <div class='fake-height'>
    <ul class='body'>
      <li class='row'>
        <span class='id'>4983</span><span class='utilization'>4983%</span>
      </li>
      <!-- More li's -->
    </ul>
  </div>
</div>

The .table is the scroll container. In code, we set its height to a static value of 312, and set overflow-y: scroll to allow the user to scroll inside the element.

The .fake-height element takes care of telling the browser how big the list is. In the example a row is 31px so for 5000 elements we'll see <div class="fake-height" style="height: 15500px"></div>

The .body element will move down as we scroll using the top attribute, so if we've scrolled down 500px, it will show <ul class="body" style="top: 500px"></ul>

This means in code we'll see:

table.scrollHeight === 15500
table.offsetHeight === 312

to scroll an element in code we can use its scrollTop property, so for our table we'll use

table.scrollTop += table.offsetHeight - 31

we subtract the 31 to make sure we don't miss any elements.

Calling this method will also trigger the onScroll listener on the element.

Putting it to use

const wait = (ms: number) => new Promise(res => setTimeout(res, ms))

async function Test() {
  const expectedData = Array.from({ length: 5000 }).map((_, i) => ({
    id: i + 1,
    utilization: (i + 1) / 100,
  }))
  const actualData = await parseDataFromTable(
    document.querySelector('.table') as HTMLDivElement,
  )
  assertEqual(expectedData.length, actualData.length)
  assertEqual(expectedData, actualData)
  console.log('Success!')
}

async function parseDataFromTable(table: HTMLDivElement) {
  const scrollToNextPage = () => {
    table.scrollTop += table.offsetHeight - 31
  }
  let data: { id: number; utilization: number }[] = []
  while (table.scrollTop < table.scrollHeight - table.offsetHeight) {
    data = data.concat(getCurrentDataFromTable(table))
    scrollToNextPage()
    // give the main thread a chance to render
    await wait(32)
  }
  data = data.concat(getCurrentDataFromTable(table))
  // Helper from ramda to remove duplicates from the array
  return uniqBy(x => x.id, data)
}

function getCurrentDataFromTable(table: HTMLDivElement) {
  return Array.from(table.querySelectorAll('.body > .row')).map(rowElement => ({
    id: Number(rowElement.children[0].textContent),
    utilization:
      Number(rowElement.children[1]?.textContent?.replace('%', '')) / 100,
  }))
}

This has proven to be an effective solution.

It scrolls through the element just like a user would collecting the data from the list as it scrolls.