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:
- Setting up all of data the user expects to see in the actual table
- Parsing data from the actual table
- 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.