Визуальное тестирование на playwright без эталонных скриншотов
Доброго времени суток! Сегодня хочу рассказать о том, как можно реализовать визуальные тесты без использования эталонных скриншотов. Сначала разберем идею, а потом перейдем к реализации.
Вместо эталонных скриншотов мы будем использовать эталонное окружение. Это означает, что в качестве референса будем использовать полноценно поднятый проект с продакшн кодом (тот, который находится в гите в master/main). Конечно, здесь должен быть хорошо настроенный CI, который будет давать возможность тестам обращаться к тестируемому экземпляру приложения и к референсу. Не будем углубляться в детали, это тема для отдельного разговора. Расскажу только кратко, как это может выглядеть.
Тесты должны запускаться на каждом Pull Request. На CI поднимается проект с кодом из тестируемой ветки, и там же на CI должно быть отдельно развернутое приложение с кодом из master, который мы считаем за эталон и куда тесты могут обращаться, предположим, на отдельный субдомен приложения (например, тестируемый код находится на test.work, а референс на reference.test.work). Также важна автоматизация обновления референса — после каждого мержа в master, окружение должно автоматически пересобираться. Перед каждым запуском тестов в тестируемую ветку обязательно должен подтягиваться свежий мастер. Результатом тестов на выходе будет являться разница между скриншотом на тестируемом приложении и на референсе.
Такой подход избавит от необходимости поддерживать и обновлять эталонные скриншоты. Если тесты находят разницу — это либо баг, и мы его репортим, либо валидное изменение. Если изменение валидное, мы просто мержим пулл реквест. Далее референс автоматически пересобирается с новыми изменениями, а при запуске тестов на других ветках подтягивается мастер, и тесты продолжают работать с новым кодом. Однако есть и недостаток — если у вас недостаточно точные селекторы элементов, и они часто меняются, то даже при обновлении селекторов на тестируемой ветке на reference ветке они останутся неизменными. Это не слишком страшно, если делать скриншоты страниц целиком, а не компонентов. В таком случае сложности могут возникнуть только при маскировке элементов или при выполнении подготовительных шагов, и даже в таком случае такие тесты становятся хорошим дополнением в функциональным, учитывая минимальную поддержку.
Давайте перейдем к реализации. Писать тесты будем на TypeScript с использованием Playwright и библиотеки Jimp. Реализовать тесты можно разными способами, в зависимости от архитектуры. Здесь приведу только пример.
Тестировать будем https://www.candlapp.com/. Сначала давайте расширим тест Playwright и реализуем фикстуру visualService, с помощью которой будем писать наши тесты, а также простую страницу для описания главной страницы:
import { test as base, expect } from 'playwright/test';
type ServicesType = {
visualService: VisualService;
};
type Pages = {
indexPage: IndexPage;
};
const test = base
.extend({
visualService: async ({ context }, use) => {
await use(new VisualService(context));
},
})
.extend({
indexPage: async ({ page }, use) => {
await use(new IndexPage(page));
},
});
Описание страницы IndexPage упростим до следующего вида:
class IndexPage {
private page: Page;
private url = 'https://www.candlapp.com/';
readonly loginButton: Locator;
readonly signupButton: Locator;
readonly demoGif: Locator;
constructor(page: Page) {
this.page = page;
this.loginButton = this.page.locator('#login');
this.signupButton = this.page.locator('#signup');
this.demoGif = this.page.locator('#demo');
}
goto = async () => {
await this.page.goto(this.url);
};
}
VisualService будет отвечать за сбор скриншотов на разных окружениях. Скриншоты будут храниться в сложной структуре ComponentScreenshots:
export type Env = 'pr' | 'master';
export type EnvScreenshots = Record;
export type ComponentsScreenshots = Record;
Получится примерно такое:
{
«Index page»: {
pr: Jimp
master: Jimp
}
«Index page with expanded tariffs»: {
pr: Jimp
master: Jimp
}
….
}
То есть визуальный тест на одну страницу может проверять несколько скриншотов, например если вы хотите раскрыть какие-то компоненты и заснепшотить страницу в таком состоянии.
Расcмотрим теперь код VisualService класса:
const defaultScreenshotOptions = { caret: 'hide', animations: 'disabled', maskColor: '#62003b' } as LocatorScreenshotOptions;
export class VisualService {
private readonly _componentsScreenshots: ComponentsScreenshots = {};
private page: Page;
private currentEnv: Env = 'pr';
constructor(page: Page) {
this.page = page;
}
compare = async (captureActions: (env: Env) => Promise) => {
await test.step(`Actions for PR environment`, async () => {
await captureActions(this.currentEnv);
});
await this.changeEnvironment();
await test.step(`Actions for Master environment`, async () => {
await captureActions(this.currentEnv);
});
Object.keys(this._componentsScreenshots).map(k => {
expect.soft(this._componentsScreenshots[k], `${k} looks good`).looksGood();
});
};
addComponentScreenshot = async (
name: string,
locator: Locator,
actionsBefore?: () => Promise,
actionsAfter?: () => Promise,
options?: LocatorScreenshotOptions,
) => {
if (actionsBefore) await test.step(`Before actions`, async () => await actionsBefore());
await test.step(`Make screenshot of ${name} for ${this.currentEnv} environment`, async () => {
this._componentsScreenshots[name] =
this._componentsScreenshots[name] || (JSON.parse(JSON.stringify(initScreenshots)) as EnvScreenshots);
this._componentsScreenshots[name]![this.currentEnv] = await Jimp.read(
await locator.screenshot({
...defaultScreenshotOptions,
...options,
}),
);
});
if (actionsAfter) await test.step(`After actions`, async () => await actionsAfter());
};
addPageScreenshot = async (
name: string,
page: IndexPage,
actionsBefore?: () => Promise,
actionsAfter?: () => Promise,
options?: LocatorScreenshotOptions,
) => {
if (actionsBefore) await test.step(`Before actions`, async () => await actionsBefore());
await test.step(`Make screenshot of ${name} for ${this.currentEnv} environment`, async () => {
this._componentsScreenshots[name] =
this._componentsScreenshots[name] || (JSON.parse(JSON.stringify(initScreenshots)) as EnvScreenshots);
this._componentsScreenshots[name]![this.currentEnv] = await Jimp.read(
await this.page.screenshot({
...defaultScreenshotOptions,
...{ fullPage: true },
...options,
}),
);
});
if (actionsAfter) await test.step(`After actions`, async () => await actionsAfter());
};
private changeEnvironment = async () => {
await test.step(`Change environment`, () => {
// Тут логика по смене окружения. Можно реализовать через смену домена или через куки
this.currentEnv = 'master';
});
};
}
Главная функция в VisualService — compare. Она принимает на вход функцию captureActions. compare работает следующим образом: выполняем действия (функцию captureActions) на одном окружении, затем меняем окружение на референс, выполняем те же действия на втором окружении, после чего проходим по собранным скриншотам ComponentsScreenshots и делаем soft expect (expect у нас будет кастомный, об этом чуть позже). Кроме того, VisualService предоставляет методы для скриншотинга — addComponentScreenshot и addPageScreenshot. Задача этих методов состоит в создании скриншота компонента/страницы и добавлении его в ComponentsScreenshots. Скриншоты хранятся в виде объекта Jimp. Это позволяет сравнивать скриншоты в base64 на лету, без необходимости сохранять их в .png. Формат .png будет использоваться только в случае неудачного теста. Последний приватный метод — changeEnvironment, в котором должна быть реализована логика смены окружения (это можно при желании сделать через подстановку куки, если это позволит обращаться к референс окружению, или иным способом). Реализация, возможно, не самая простая, но это существенно упрощает написание тестов. Давайте рассмотрим пример:
test.describe('Index page', () => {
test('Index page looks good', async ({ indexPage, visualService }) => {
const captureActions = async () => {
await indexPage.goto();
await visualService.addPageScreenshot('Index page', indexPage);
};
await visualService.compare(captureActions);
});
});
Как видите, достаточно реализовать функцию captureActions по сбору скриншотов. Поскольку VisualService сравнивает несколько состояний страниц, скриншотов можно добавлять сколько угодно (для этого мы делали soft expect). Чтобы менять состояния страницы, можно передавать в addComponentScreenshot и addPageScreenshot функции actionsBefore и actionsAfter (чтобы привести страницу к изначальному состоянию).Так же можно маскировать динамические элементы, если они мешают тестированию, передав options, который принимает метод screenshot playwright-a
test.describe('Index page', () => {
test('Index page looks good', async ({ indexPage, visualService }) => {
const captureActions = async () => {
await indexPage.goto();
await visualService.addPageScreenshot('Index page', indexPage);
await visualService.addPageScreenshot(
'Index page with opened login dialog',
indexPage,
async () => await indexPage.loginButton.click(),
async () => await indexPage.loginButton.click(),
);
await visualService.addPageScreenshot(
'Index page with opened signup dialog',
indexPage,
async () => await indexPage.loginButton.click(),
async () => await indexPage.loginButton.click(),
{ mask: [indexPage.demoGif] },
);
};
await visualService.compare(captureActions);
});
});
Давайте теперь рассмотрим, как может выглядеть кастомный expect для сравнения скриншотов
const looksGood = async (screenshots: Screenshots) => {
if (!screenshots.pr || !screenshots.master) throw new Error("Couldn't read screenshots");
const diffThreshold = 0.1;
const diff = +Jimp.diff(screenshots.pr, screenshots.master, 0.1).percent.toFixed(4) * 100;
if (diff > diffThreshold) {
const res = await printDiff(screenshots.pr, screenshots.master, diff, diffThreshold);
await test.info().attach('Diff screenshot', { body: await res.getBufferAsync('image/png'), contentType: 'image/png' });
return {
message: () => `Screenshots doesn't look same. \n Diff = ${diff.toFixed(2)}% \n Threshold = ${diffThreshold.toFixed(2)}%`,
pass: false,
};
} else {
return {
message: () => `Screenshots looks same`,
pass: true,
};
}
};
Expect будет работать с объектами Screenshots
export type Screenshots = {
pr: Jimp | null;
master: Jimp | null;
};
Средствами Jimp высчитываем на лету diff между скриншотами pr и master. Если diff > diffThreshold, то печатаем наглядное представление в .png и прикрепляем к отчету. В противном случае, считаем, что тест успешно пройден. DiffThreshold можно высчитывать динамически, в зависимости от размеров тестируемого изображения. Метод printDiff можно реализовать как угодно средствами Jimp. Приведу 1 пример реализации, в котором слева будет выводиться скриншот pr, справа — master, а между ними разница между скриншотами
async function printDiff(pr: Jimp, master: Jimp, diff: number, threshold: number) {
const prScr = Jimp.diff(pr, master).image;
const headerHeight = 50;
const bottomHeight = 50;
const gapWidth = 30;
const resulHeight = prScr.getHeight() + headerHeight + bottomHeight;
const resultWidth = prScr.getWidth() * 3 + 2 * gapWidth;
const result = new Jimp(resultWidth, resulHeight, 0x0);
// composite
result.composite(pr, 0, headerHeight);
result.composite(prScr, pr.getWidth() + gapWidth, headerHeight);
result.composite(master, pr.getWidth() + prScr.getWidth() + 2 * gapWidth, headerHeight);
// text
const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);
result.print(font, 10, 10, `PR`);
result.print(font, pr.getWidth() + gapWidth + 10, 10, `Diff: ${diff.toFixed(2)}%. Threshold: ${threshold.toFixed(2)}%`);
result.print(font, pr.getWidth() + prScr.getWidth() + master.getWidth() - 100, 10, `Master`);
return result.resize(result.getWidth() / 1.5, result.getHeight() / 1.5);
}
Примерно так может выглядеть вывод упавшего теста, если, скажем, у нас пропадут кнопки из хидера