Если вы когда-либо писали автотесты для веб-приложений с элементом 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. В моем же случае среднее время, для поиска данных на изображении 800x400, требуется 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.

Комментарии (0)