Если вы когда-либо писали автотесты для веб-приложений с элементом 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. В моем же случае среднее время, для поиска данных на изображении 800×400, требуется 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.
ссылка на оригинал статьи https://habr.com/ru/articles/939206/
Добавить комментарий