Холст для тестировщика: Функциональные автотесты с Canvas

от автора

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

Хотите узнать, как автоматизировать тестирование canvas без лишней боли? Давайте разберёмся на простом примере.

Пример графика созданного в canvas

Пример графика созданного в canvas
html код canvas элемента

html код canvas элемента

📸 В первую очередь: визуальное сравнение/скриншотное тестирование

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

🤔 Что делать, когда визуальных сравнений недостаточно

Для начала, я покажу пример функционала, связанного с наведением/кликами по элементам внутри графика:

Взаимодействием с элементами внутри canvas

Взаимодействием с элементами внутри canvas

Тултип и контекстное меню — обычные веб элементы, которые доступны в 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, что вполне приемлемый вариант.

🧪 Проверим данный подход в автотестах

findColorCoordinates против canvas

findColorCoordinates против canvas

В левой части экрана — тест шаги, а справа веб страница и результаты тест шагов, так же можно увидеть красные точки — ими отмечается курсор, при взаимодействии с элементами или перемещениях мышки. На представленых кадрах видно, что автотесты без труда находят элементы на графике.

Разберемся, как это работает, на примере 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *