Привет, разработчик!

Частая проблема верстальщика - поправил стили в одном месте, а изменилось в нескольких местах. И верстальщик замечает это слишком поздно. В данном посте я расскажу о регрессивном тестировании. Суть метода заключается в том, что вы делаете скриншоты сайта(не вручную конечно), а затем после внесения каких-либо правок вы делаете новые скриншоты и сравниваете их с предыдущими. Если есть какие-либо отличия - тесты об этом говорят и показывают. Можно делать скриншоты как сайтов, так и отдельных блоков.

Для данного метода нам потребуется:

  • Gulp - система сборки.
  • puppeteer - node.js библиотека, которая позволяет управлять браузером Chromium без пользовательского интерфейса.
  • pixelmatch - библиотека сравнения изображений на уровне пикселей, созданная для сравнения снимков экрана в тестах.
  • pngjs - простой PNG кодер/декодер для node.js
  • fs - модуль предоставляет API для взаимодействия с файловой системой
  • path - предоставляет утилиты для работы с путями файлов и каталогов

Также для удобства отображения всех результатов на одной странице в браузере нам понадобятся дополнительно пакеты:

Установка

Устанавливаем gulp:

npm install gulp -g

В корне вашего проекта инициализируем npm:

npm init

Устанавливаем остальные пакеты:

npm i chrome-launcher fs http node-static path pixelmatch pngjs puppeteer

Установка может занять некоторое время, так как в пакет puppeteer входит установка браузера chromium

Настройка gulp.js

Прописываем зависимости для установленных пакетов

const puppeteer = require('puppeteer');
const fs = require('fs');
const PNG = require('pngjs').PNG;
const pixelmatch = require('pixelmatch');
const chromeLauncher = require('chrome-launcher');
const http = require('http');
const staticN = require('node-static');
const path = require('path');

Конфигурация под конкретный проект:

Указываем ширину страницу при тестировании

var initialPageWidth = 1920;

И список тестируемых страниц вносим в массив pageList:

var pageList = [
	'index',
	'gallery',
	'faq'
];

Будущая структура результатов теста

└── test/                 
	  ├── before/           
	  │   ├── index.png
		│		├── ...
	  │   └── pageXX.png  
		├── after/  
	  │   ├── index.png
		│		├── ...
	  │   └── pageXX.png  
		├── difference/
	  │		├── index.png
		│		├── ...
	  │   └── pageXX.png  
		└── index_test.html

Все результаты будут храниться в папке test, котороая будет поделена еще на 3 подкаталога:

  • before - для хранения эталонных скриншотов
  • after - для хранения новых скриншотов, которые впоследствии будут сравниваться
  • difference - для хранения результата сравнения новых скриншотов с эталонными

Создаем переменные для сохранения путей:

var beforeDir = 'test/before/',
		afterDir = 'test/after/',
		diffDir = 'test/difference/';

Создание тасков

1.Такс создания эталонных скриншотов

Изначально с помощью модуля fs создаем каталоги в которых будут храниться результаты тестирования, если они отсутствуют. А так же очищаем от старых скриншотов, если такие имеются.

if (!fs.existsSync('test')){
		fs.mkdirSync('test');
	}

	if (!fs.existsSync(beforeDir)){
		fs.mkdirSync(beforeDir);
	}

	if (fs.existsSync(beforeDir)){
		fs.readdir(beforeDir, (err, files) => {
			for (const file of files) {
				fs.unlink(path.join(beforeDir, file), err => {});
			}
		});
	}

После проходим по массиву страниц pageList, который мы заполнили ранее. В page.goto указываем адрес страниц, в данном случае это http://localhost:1337/ В page.screenshot - каталог для сохраниния скриншотов, а так же указываем fullPage: true для сохранения страницы целиком, а не определенной высоты

pageList.map(async function(element, index) {
	const browser = await puppeteer.launch();
	const page = await browser.newPage();

	await page.setViewport({ width: initialPageWidth, height: 0 });

	await page.goto('http://localhost:1337/' + element + '.html');

	await page.screenshot({path: beforeDir + element + '.png', fullPage: true});
	console.log(element + ' page +');

	await browser.close();	
})

Полность таск создания эталонных скриншотов выглядит так:

gulp.task('test-init', function() {
	if (!fs.existsSync('test')){
		fs.mkdirSync('test');
	}

	if (!fs.existsSync(beforeDir)){
		fs.mkdirSync(beforeDir);
	}

	if (fs.existsSync(beforeDir)){
		fs.readdir(beforeDir, (err, files) => {
			for (const file of files) {
				fs.unlink(path.join(beforeDir, file), err => {});
			}
		});
	}

	pageList.map(async function(element, index) {
		const browser = await puppeteer.launch();
		const page = await browser.newPage();
	
		await page.setViewport({ width: initialPageWidth, height: 0 });
	
		await page.goto('http://localhost:1337/' + element + '.html');

		await page.screenshot({path: beforeDir + element + '.png', fullPage: true});
		console.log(element + ' page +');
	
		await browser.close();	
	})
})

Запукается таск с помощью команды:

gulp test-init

2.Такс создания новых скриншотов и их сравнения с эталонными

Как и в предыдущем таске, создаем необходимые каталоги или очищаем их от старых файлов:

var clearDir = [diffDir, afterDir, 'test/']

if (!fs.existsSync(afterDir)){
	fs.mkdirSync(afterDir);
}

if (!fs.existsSync(diffDir)){
	fs.mkdirSync(diffDir);
}

clearDir.map(function(element, index) {
	if (fs.existsSync(element)){
		fs.readdir(element, (err, files) => {
			for (const file of files) {
				fs.unlink(path.join(element, file), err => {});
			}
		});
	}
});

Далее так же проходим массив страниц для создания новых страниц, но теперь и сравнивая их с эталонными

pageList.map(async function(element, index) {
	const browser = await puppeteer.launch();
	const page = await browser.newPage();

	await page.setViewport({ width: initialPageWidth, height: 0 });

	await page.goto('http://localhost:1337/' + element + '.html');

	await page.screenshot({path: afterDir + element + '.png', fullPage: true});

	await browser.close();

	pageName = element;
	img1[index] = await fs.createReadStream(afterDir + element + '.png').pipe(new PNG()).on('parsed', function() { parse2(element, index)});
})

Код выше аналогичен созданию эталонных скриншотов, кроме дополнительного внесения каждого сделанного скриншота в массив img1 Далее с помощью функий parse2 мы вносим и эталонные скриншоты в массив img2, после сравниваем их с помощью фунции doneReading, полученный результат записывая в необходимый каталог:

function parse2(element, index, pageName) {
	img2[index] = fs.createReadStream(beforeDir + element + '.png').pipe(new PNG()).on('parsed', function() { doneReading(img1[index], img2[index], element)});
}

function doneReading(img1, img2, pageName) {
	var diff = new PNG({width: img1.width, height: img1.height});

	pixelmatch(img1.data, img2.data, diff.data, img1.width, img1.height, {threshold: 0.5});


	diff.pack().pipe(fs.createWriteStream(diffDir + pageName + timeMod + '.png'));
	console.log(pageName + ' ---- page compared');
}

Для дополнительного удобства, мы выводим все изображения сравненных страниц на одной странице и открываем ее в браузере.

Создадим переменную, которую будем использоват как модификатор в именах изображений и страниц для избежания кеширования в браузере:

var timeMod = new Date().getTime();

Далее создаем список всех страниц с именем и изображением

var imgList = pageList.map(function(file, i) {
	return '<li style="width: 49%; display: inline-block; list-style: none; background-color: #888;"><h2 style="font: 3vw sans-serif; margin: 0; padding: 1em; text-align: center;">' + pageList[i] + '</h2><img style="width: 100%; display: block;" src="difference/' + file + timeMod + '.png"/></li>'
})

И вносим созданный выше список в html файл

fs.writeFile('test/index_test' + timeMod + '.html', imgList, function (err) {});

И в завершение создаем локальный сервер с этой страницей и открываем ее в браузере Google Chrome:

var fileServer = new staticN.Server();

http.createServer(function (req, res) {
	req.addListener('end', function () {
			fileServer.serve(req, res);
	}).resume();
}).listen(8080);

chromeLauncher.launch({
	startingUrl: 'http://localhost:8080/test/index_test' + timeMod + '.html',
	userDataDir: false 
}).then(chrome => {
	console.log(`Chrome debugging port running on ${chrome.port}`);
});

Полность таск создания новых скриншотов скриншотов и сравнениях их с эталонными выглядит так:

gulp.task('test-compare', function() {
	var timeMod = new Date().getTime();
	var clearDir = [diffDir, afterDir, 'test/']

	if (!fs.existsSync(afterDir)){
		fs.mkdirSync(afterDir);
	}
	
	if (!fs.existsSync(diffDir)){
		fs.mkdirSync(diffDir);
	}
	
	clearDir.map(function(element, index) {
		if (fs.existsSync(element)){
			fs.readdir(element, (err, files) => {
				for (const file of files) {
					fs.unlink(path.join(element, file), err => {});
				}
			});
		}
	});

	function doneReading(img1, img2, pageName) {
		var diff = new PNG({width: img1.width, height: img1.height});
		pixelmatch(img1.data, img2.data, diff.data, img1.width, img1.height, {threshold: 0.5});
		diff.pack().pipe(fs.createWriteStream(diffDir + pageName + timeMod + '.png'));
		console.log(pageName + ' ---- page compared');
	}

	function parse2(element, index, pageName) {
		img2[index] = fs.createReadStream(beforeDir + element + '.png').pipe(new PNG()).on('parsed', function() { doneReading(img1[index], img2[index], element)});
	}

	pageList.map(async function(element, index) {
		const browser = await puppeteer.launch();
		const page = await browser.newPage();
		await page.setViewport({ width: initialPageWidth, height: 0 });
		await page.goto('http://localhost:1337/' + element + '.html');
		await page.screenshot({path: afterDir + element + '.png', fullPage: true});
		await browser.close();
		pageName = element;
		img1[index] = await fs.createReadStream(afterDir + element + '.png').pipe(new PNG()).on('parsed', function() { parse2(element, index)});
	})

	var imgList = pageList.map(function(file, i) {
		return '<li style="width: 49%; display: inline-block; list-style: none; background-color: #888;"><h2 style="font: 3vw sans-serif; margin: 0; padding: 1em; text-align: center;">' + pageList[i] + '</h2><img style="width: 100%; display: block;" src="difference/' + file + timeMod + '.png"/></li>'
	})

	fs.writeFile('test/index_test' + timeMod + '.html', imgList, function (err) {});

	var fileServer = new staticN.Server();

	http.createServer(function (req, res) {
		req.addListener('end', function () {
				fileServer.serve(req, res);
		}).resume();
	}).listen(8080);

	chromeLauncher.launch({
		startingUrl: 'http://localhost:8080/test/index_test' + timeMod + '.html',
		userDataDir: false 
	}).then(chrome => {
		console.log(`Chrome debugging port running on ${chrome.port}`);
	});
})

Запукается таск с помощью команды:

gulp test-init

Обзор результатов

При совпадении новых скриншотов с эталонными, результат будет выглядеть подобным образом:

При несовпадении изображений(теста у эмелентов на странице Catalogue) разница будет выделена красным цветом:

Также можно изменить чувствительность сравнения изображений с помощью параметра threshold, он задается при вызове pixelmatch, может иметь значения от 0 до 1, где меньшее значение соответствует большей строгости сравнения. В данном примере он вызывается внутри функции doneReading со значением threshold: 0.5