Если вы когда-либо писали автотесты для веб-приложений с элементом canvas, то наверняка знаете, как это может быть непросто. Canvas — это "чёрный ящик", где привычные инструменты UI-тестирования бессильны: внутри нет DOM-структуры, за которую можно зацепиться. При этом на экране canvas может отображать что угодно — от графиков с осями X и Y до сложных анимаций.
Хотите узнать, как автоматизировать тестирование canvas без лишней боли? Давайте разберёмся на простом примере.


? В первую очередь: визуальное сравнение/скриншотное тестирование
Так как canvas используется для отображения какой-либо графики, очень логично будет в первую очередь воспользоваться скриншотным тестированием.
Если вы, как и я, используете TS и Playwright, то вот документация на эту тему.
Так же, вероятно, вам потребуется подменить ответы бека, для того, что бы иметь одинаковые и стабильные данные на ваших скриншотах.
На случай, если данные для графика получаете по web socket.
? Что делать, когда визуальных сравнений недостаточно
Для начала, я покажу пример функционала, связанного с наведением/кликами по элементам внутри графика:

Тултип и контекстное меню - обычные веб элементы, которые доступны в DOM, но только для того, что бы их вызвать, требуется взаимодействие с элементами внутри canvas.
Из коробки Playwright, как впрочем, и любой другой инструмент для UI тестирования - не сможет вам ничего предложить, кроме наведения курсора по координатам.
Сразу отмечу, что решение хардкодно выставить координаты звучит максимально ненадежно, и я мысленно уже представил, как на моем проекте ложно упали 50-100 тестов, из-за незначительных изменений в графике и теперь нужно вручную корректировать координаты.
А ввод через API canvas напрямую через JavaScript не похоже на реальное поведение пользователя.
✅ Есть решение!
Вот мы плавно и подошли к тому, как я предлагаю решить проблему - а именно при помощи функции findColorCoordinates , которая на вход принимает опции, такие как локатор, уникальный цвет для поиска внутри canvas и ряд настроек, например паттерн поиска или погрешность при поиске цвета, а на выходе дает координаты "x" и "y":
import { expect } from '@playwright/test'
import sharp from 'sharp'
import { test } from '../../fixtures'
import { ColorSearchPatternOptions, FindColorCoordinatesOptions, RgbaColor, RgbColor } from './types'
export function findColorCoordinates(
options: FindColorCoordinatesOptions,
): Promise<{ x: number; y: number } | null> {
const {
targetColorRgb,
paddingPercent = {
top: 2,
bottom: 2,
left: 2,
right: 2,
},
locator,
colorDiffThreshold = 6,
searchPattern = 'adaptiveStep',
stepSize
} = options ?? {}
return test.step(`Find color: "${targetColorRgb}" coords`, async () => {
const targetColor = stringToRgb(targetColorRgb)
// Get bounding box of the element
const boundingBox = (await locator.boundingBox())!
expect(boundingBox, `Locator bounding box not to be null`).not.toBeNull()
const dataURL = await locator.evaluate((element) => {
const canvas = element as HTMLCanvasElement
return canvas.toDataURL('image/png') // PNG as Data URL
})
// Data URL -> Buffer
const buffer = Buffer.from(dataURL.split(',')[1], 'base64')
const { data: imageData, info } = await sharp(buffer)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
const width = info.width
const height = info.height
const paddingXLeft = Math.floor(width * ((paddingPercent.left ?? 0) / 100))
const paddingXRight = Math.floor(width * ((paddingPercent.right ?? 0) / 100))
const paddingYTop = Math.floor(height * ((paddingPercent.top ?? 0) / 100))
const paddingYBottom = Math.floor(height * ((paddingPercent.bottom ?? 0) / 100))
const searchPatternOptions: ColorSearchPatternOptions = {
imageData,
targetColor,
width,
height,
colorDiffThreshold,
startX: Math.max(paddingXLeft, 0),
startY: Math.max(paddingYTop, 0),
endX: width - paddingXRight,
endY: height - paddingYBottom,
offsetX: boundingBox.x,
offsetY: boundingBox.y,
}
switch (searchPattern) {
case 'spiral':
return spiralSearchPattern(searchPatternOptions)
case 'vertical':
return verticalSearchPattern(searchPatternOptions)
case 'horizontal':
return horizontalSearchPattern(searchPatternOptions)
case 'adaptiveStep':
return adaptiveStepSearchPattern({...searchPatternOptions, stepSize})
default:
return null
}
})
}
И в качестве примера функция одного из паттернов поиска:
function horizontalSearchPattern(options: ColorSearchPatternOptions) {
const {
imageData, targetColor, width, startX, endX, startY, endY, offsetX, offsetY, colorDiffThreshold
} = options
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const index = (y * width + x) * 4 // 4 bytes per pixel (RGBA)
const color = {
r: imageData[index],
g: imageData[index + 1],
b: imageData[index + 2],
a: imageData[index + 3],
}
const diff = deltaE(color, targetColor)
if (diff < colorDiffThreshold) {
return { x: x + offsetX, y: y + offsetY }
}
}
}
return null
}
Для того, что бы сделать процесс наиболее эффективным, пришлось использовать API canvas для извлечения данных изображения, а так же стороннюю библиотеку sharp, к слову, она довольно популярна, для конвертации данных в imageData: Buffer<ArrayBufferLike> , содержащий информацию о цветах каждого пикселя на canvas.
Я протестировал разные инструменты для работы с изображение (в том числе и извлечение image data из API Canvas) и sharp зарекомендовал себя, как самый быстрый и стабильный.
Если вдруг вы интересуетесь, почему я просто не делаю скриншот элемента, то ответ опять же в скорости исполнения - только один скриншот элемента может занимать порядка 80ms. В моем же случае среднее время, для поиска данных на изображении 800x400, требуется 20-40ms, что вполне приемлемый вариант.
? Проверим данный подход в автотестах

В левой части экрана - тест шаги, а справа веб страница и результаты тест шагов, так же можно увидеть красные точки - ими отмечается курсор, при взаимодействии с элементами или перемещениях мышки. На представленых кадрах видно, что автотесты без труда находят элементы на графике.
Разберемся, как это работает, на примере Playwright тест спеки chart.spec.ts. Ниже представлен код теста:
const defaultItem = 'Item1'
const allItems = [defaultItem, 'Item2', 'Item3']
const screenshotName = 'empty.png'
test(`Disable all items via context menu`, async ({ chartPage }) => {
for (const item of allItems) {
await chartPage.locators.legendItemCheckbox(item).check()
await chartPage.validateItemIsVisible(item)
await chartPage.openItemContextMenu(item)
await chartPage.locators.hideItemContexMenuOption().click()
await chartPage.validateItemIsHidden(item)
}
await expect(chartPage.locators.chart()).toHaveScreenshot(screenshotName)
})
chartPage - фикстура, которая отрывает страницу с графиком.
Логика по поиску цвета, лежит внутри самого класса страницы ChartPage.
Т.к. зачастую у графика есть легенда, а в легенде представлены цвета, то так же сделано и в нашем примере и именно при помощи легенды, мы извлекаем цвет и понимаем, что нужно искать на графике:
export class ChartPage extends BasePage {
// ...
private getItemColor(name: string): Promise<string> {
return test.step(`Get "${name}" legend item color`, ()=> {
return this.locators.legendItem()
.filter({hasText: name})
.evaluate((element) => {
const styles = window.getComputedStyle(element)
return styles.color
})
})
}
private async getItemCoordinates(name: string): Promise<{ x: number; y: number } | null> {
const targetColorRgb = await this.getItemColor(name)
return findColorCoordinates({
targetColorRgb,
locator: this.locators.chart()
})
}
// ...
}

Остальные методы класса ChartPage так или иначе завязаны, за поиск координатов при помощи getItemCoordinates
, и какие-то действия или валидацию с ними, например наведение курсора или клик правой кнопки мыши:
export class ChartPage extends BasePage {
// ...
async openItemContextMenu(name: string): Promise<void> {
await test.step(`Open "${name}" item's context menu on the chart`, async ()=> {
const coords = await this.getItemCoordinates(name)
expect(
coords,
`To find ${name} coordinates`
).not.toBeNull()
await this.page.mouse.click(coords!.x, coords!.y, { button: 'right'})
})
}
// ...
}
? Подведем итоги
Описанный подход к тестированию canvas не заменяет визуальное сравнение/скриншотные тесты , а дополняет их.
Используйте его в следующих случаях:
Требуется взаимодействие с элементами графика (наведение, клики, drag-and-drop).
Элементы на canvas имеют уникальные цвета.
Доступны данные о цветах (например, из легенды, конфига или палитры).
Этот метод позволяет автоматизировать тестирование там, где стандартные UI-инструменты бессильны, при этом, выполняя задачу быстро и точно, а так же не нуждается в постоянной поддержке.
? Ваше мнение?
Сталкивались ли вы с тестированием canvas? Какие подходы используете? Делитесь опытом в комментариях!
? Попробуйте сами!
Для закрепления материала я подготовил пример веб-страницы с графиком на canvas. Исходный код, инструкции по запуску сайта и автотестов доступны на GitHub.