simplify tests

This commit is contained in:
a.krasnov
2020-12-01 23:41:11 +03:00
parent 3b5714d6ef
commit 44662ae0a4
61 changed files with 235 additions and 1591 deletions
@@ -1,22 +1,36 @@
from PIL import ImageDraw, Image
from io import BytesIO
from typing import Tuple, List
import logging
from io import BytesIO
from typing import Union, List
from PIL import ImageDraw, Image
class ImageProcessor(object):
"""Class for image comparison."""
"""Класс для обработки изображений (нарезки и сравнения)"""
RED = "red"
GREEN = "green"
BLUE = "blue"
ALPHA = "alpha"
# https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L121
# https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L981
tolerance = {
RED: 32,
GREEN: 32,
BLUE: 32,
ALPHA: 32,
}
def __init__(self):
self._block_width = 20 # default
self._block_height = 20
self._accuracy = 0.0001 # less better
self._block_width = 40 # default
self._block_height = 40
def _slice_image(self, image: Image.Image) -> List[dict]:
"""Slice image on small blocks."""
"""Нарезать картинки на блоки"""
max_width, max_height = image.size
# нижний правый угол для кропа
width_change = self._block_width
height_change = self._block_height
@@ -44,20 +58,53 @@ class ImageProcessor(object):
return result
def _get_image_pixel_sum(self, image: Image.Image) -> int:
"""Get pixel sum for image."""
image_total = 0
max_width, max_height = image.size
def _is_color_similar(self, a, b, color):
"""Проверить похожесть цветов. Для того, чтобы тесты не тригеррились на антиалиазинг допуски
в self.tolerance.
"""
if a is None and b is None:
return True
diff = abs(a - b)
if diff == 0:
return True
elif diff < self.tolerance[color]:
return True
return False
def _compare_images(self, image_one: Image.Image, image_two: Image.Image) -> bool:
"""Сравнить два изображения попиксельно"""
assert image_one.size == image_two.size, \
f"Картинки должны быть одинакового размера, {image_one.size} {image_two.size}"
max_width, max_height = image_one.size
for coord_y in range(0, max_height):
for coord_x in range(0, max_width):
pixel = image.getpixel((coord_x, coord_y))
image_total += sum(pixel)
pixel_one = image_one.getpixel((coord_x, coord_y))
pixel_two = image_two.getpixel((coord_x, coord_y))
equal = self._compare_pixels(pixel_one, pixel_two)
if not equal:
return False
return image_total
return True
def get_images_diff(self, first_image: Image.Image, second_image: Image.Image) -> Tuple[int, bytes, bytes, bytes]:
"""Compare two images."""
def _compare_pixels(self, pixel_one, pixel_two) -> bool:
"""Сравнить каждый цвет, каждого писклея."""
assert len(pixel_one) == len(pixel_two), f"В одном из пикселей не хватает цветов: {pixel_one} {pixel_two}"
for item in zip(pixel_one, pixel_two, (self.RED, self.GREEN, self.BLUE, self.ALPHA)):
color_one, color_two, color = item
if not self._is_color_similar(color_one, color_two, color):
return False
return True
def get_images_diff(self, first_image: Image.Image, second_image: Image.Image) -> List[Union[int, bytes]]:
"""Поблочно сравнить два изображения и вернуть количество блоков с несовпавшими пикселями"""
result_image = first_image.copy()
first_image_blocks = self._slice_image(first_image)
@@ -68,45 +115,60 @@ class ImageProcessor(object):
mistaken_blocks = abs(len(first_image_blocks) - len(second_image_blocks))
for index in range(min(len(first_image_blocks), len(second_image_blocks))):
first_pixels = self._get_image_pixel_sum(first_image_blocks[index]["image"])
second_pixels = self._get_image_pixel_sum(second_image_blocks[index]["image"])
image_equal = self._compare_images(first_image_blocks[index]["image"], second_image_blocks[index]["image"])
# если пиксели отличаются больше чем на self.accuracy -- помечаем блок как битый
if (first_pixels != 0 and second_pixels != 0) and abs(1 - (first_pixels / second_pixels)) >= self._accuracy:
if not image_equal:
draw = ImageDraw.Draw(result_image)
draw.rectangle(first_image_blocks[index]["box"], outline="red")
mistaken_blocks += 1
result = BytesIO()
first = BytesIO()
second = BytesIO()
return [mistaken_blocks, self.image_to_bytes(result_image)]
result_image.save(result, 'PNG')
first_image.save(first, 'PNG')
second_image.save(second, 'PNG')
return mistaken_blocks, result.getvalue(), first.getvalue(), second.getvalue()
def paste(self, screenshots: List[bytes]) -> Image.Image:
"""Concatenate few images into one."""
def paste(self, screenshots: List[bytes], over_height: int) -> Image.Image:
"""Склеить массив скриншотов в одно изображение"""
max_width = 0
max_height = 0
images = []
for screenshot in screenshots:
image = self.load_image_from_bytes(screenshot)
images.append(image)
max_width = image.size[0] if image.size[0] > max_width else max_width
max_height += image.size[1]
# Склейка работает так: сначала создаем одно "пустое" изображение равное размеру всех скелееных, и вставляем в
# в него по одному все скриншоты.
# Чтобы в финальном скрине не получилось что скриншоты заняли меньше места, чем картинка, снизу отрезаем over_height
max_height = max_height - over_height
result = Image.new('RGB', (max_width, max_height))
logging.info(f'Screen size: ({max_width}, {max_height})')
offset = 0
for image in images:
last_image_index = len(images) - 1
for index, image in enumerate(images):
# Расскоментить если нужно посмотреть какие скрины склеиваются в один
# with open(f"screen-{index}.png", "wb") as fp:
# image.save(fp)
if last_image_index == index and over_height != 0:
# с последнего скриншота срезаем ту часть, в которой он дублирует предпоследний
logging.info(f"Crop over height: {over_height}")
image = image.crop((0, over_height, image.size[0], image.size[1]))
result.paste(image, (0, offset))
logging.info(f"Image added, offset is {offset}")
offset += image.size[1]
return result
def load_image_from_bytes(self, data: bytes):
def load_image_from_bytes(self, data: bytes) -> Image.Image:
"""Загрузить изображение из байтовой строки."""
return Image.open(BytesIO(data))
with BytesIO(data) as fp:
image: Image.Image = Image.open(fp)
image.load()
return image
def image_to_bytes(self, image: Image.Image) -> bytes:
with BytesIO() as fp:
image.save(fp, "PNG")
return fp.getvalue()
@@ -1,46 +0,0 @@
import allure
from selenium.webdriver.remote.webelement import WebElement
class CustomWebElement:
"""Custom web element with allure logging."""
def __init__(self, by: str, locator: str, element: WebElement, description: str = None):
self.by = by
self.locator = locator
self.element = element
self.description = f"«{description}»" if description else "element"
def _execute_action(self, action, step):
"""Execute action with allure logging.
:param action: Function to execute. Click, send_keys, etc
:param step: Step description.
"""
@allure.step(step)
def execute_action(locator_type=self.by, locator=self.locator):
"""All arguments will be available in report."""
return action()
return execute_action()
def click(self):
self._execute_action(self.element.click,
f"Click at {self.description}")
def send_keys(self, *value):
self._execute_action(lambda: self.element.send_keys(*value),
f"Send text {[v for v in value]} to {self.description}")
def __eq__(self, element):
return self.element.__eq__(element)
def __ne__(self, element):
return self.element.__ne__(element)
def __hash__(self):
return self.element.__hash__()
def __getattr__(self, item):
"""Missing methods will be executed from WebElement."""
return getattr(self.element, item)
-42
View File
@@ -1,42 +0,0 @@
from selenium.webdriver import Remote
from selenium.webdriver.common import by as selenium_by
from screenshot_tests.page_objects.custom_web_element import CustomWebElement
from typing import Union, TypeVar, Type
Locators = selenium_by.By
class Page:
"""Base page for all pages in PO."""
path = None
def __init__(self, driver: Remote):
self.driver = driver
PageBoundGeneric = TypeVar("PageBoundGeneric", bound=Page)
class Element:
"""Element descriptor for WebElement lazy init."""
def __init__(self, by: str, locator: str, description: str):
self.by = by
self.locator = locator
self.description = description
def __get__(self,
instance: PageBoundGeneric,
owner: Type[PageBoundGeneric]) -> Union[CustomWebElement, 'Element']:
"""
https://docs.python.org/3/howto/descriptor.html
:param instance: instance of owner
:param owner: type of owner
:return: self or CustomWebElement instance
"""
if isinstance(instance, Element):
return self
return CustomWebElement(self.by, self.locator, instance.driver.find_element(self.by, self.locator),
self.description)
@@ -1,11 +0,0 @@
from screenshot_tests.page_objects.elements import Page, Element, Locators
class YandexMainPage(Page):
"""https://yandex.ru"""
path = ""
news_header = Element(Locators.CSS_SELECTOR, ".news__header", "Хэдер с новостями")
search_field = Element(Locators.CSS_SELECTOR, ".search2", "Поисковый блок")
search_input = Element(Locators.CSS_SELECTOR, ".input__control", "Поисковый инпут")
+20
View File
@@ -0,0 +1,20 @@
from screenshot_tests.utils.screenshots import TestCase
class TestExample(TestCase):
"""Tests for https://go.mail.ru"""
def test_main_page(self):
self.driver.get("https://go.mail.ru/")
def action():
# Убираем фокус с инпута, чтобы тест не флакал из-за курсора
self.driver.find_element_by_xpath("//*[text()='найти']").click()
self.check_by_screenshot(None, action=action, full_page=True)
def test_main_page_flaky(self):
self.driver.get("https://go.mail.ru/")
# Чтобы посмотреть как выглядит сломанный тест в отчетеы
self.driver.find_element_by_xpath("//input[not(@type='hidden')]").send_keys("foo")
self.check_by_screenshot(None, full_page=True)
@@ -1,21 +0,0 @@
from screenshot_tests.utils.screenshots import TestCase
from screenshot_tests.page_objects.pages.yandex_main_page import YandexMainPage
import random
class TestYandexMainPage(TestCase):
"""Tests for https://yandex.ru"""
def test_news_widget(self):
"""Test for news widget."""
page = self.get_page(YandexMainPage)
self.check_by_screenshot(page.news_header)
def test_search_field(self):
words = ["foo", "bar", "lol", "kek", "cheburek", "otus", "yandex", "google"]
page = self.get_page(YandexMainPage)
def action():
page.search_input.send_keys(random.choice(words))
self.check_by_screenshot(page.search_field, action)
-12
View File
@@ -1,6 +1,5 @@
import pytest
import os
from screenshot_tests.page_objects.elements import PageBoundGeneric
from conftest import Config
from typing import Type
@@ -16,14 +15,3 @@ class TestCase:
def configure(self, request):
self.base_url = request.config.getoption(Config.BASE_URL)
self.staging = request.config.getoption(Config.STAGING)
def get_page(self, page_class: Type[PageBoundGeneric]) -> PageBoundGeneric:
"""Create instance of web page and return it."""
path = page_class.path
if path is None:
raise TypeError(f"Path in {page_class} is None!")
page = page_class(self.driver)
self.driver.get(os.path.join(self.base_url, path))
return page
+114 -64
View File
@@ -1,29 +1,31 @@
import pytest
import time
"""Screenshot TestCase."""
import logging
import allure
import pytest
import time
from screenshot_tests.page_objects.custom_web_element import CustomWebElement
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from urllib.parse import urlparse
from screenshot_tests.page_objects.elements import Locators
from screenshot_tests.utils import common
from screenshot_tests.image_proccessing.image_processor import ImageProcessor
from typing import Tuple
from PIL import Image
# noinspection PyAttributeOutsideInit
# аннотируем все классы всех скриншот тестов для работы плагина
# https://github.com/allure-framework/allure2/tree/master/plugins/screen-diff-plugin
@allure.label('testType', 'screenshotDiff')
class TestCase(common.TestCase):
"""Base class for all screenshot tests."""
"""Screenshot TestCase."""
BODY_LOCATOR = (Locators.CSS_SELECTOR, "body")
# Для мобильных устройств и хрома в режиме эмуляции плотность пикселей будет отличаться.
pixel_ratio = 1
@pytest.fixture(autouse=True)
def screenshot_prepare(self):
self.image_processor = ImageProcessor()
# количество попыток для снятия скриншота
self.attempts = 5
def _scroll(self, x: int, y: int):
scroll_string = f"window.scrollTo({x}, {y})"
@@ -31,9 +33,26 @@ class TestCase(common.TestCase):
time.sleep(0.2)
logging.info(f"Scroll to «{scroll_string}»")
def _make_screenshot_whole_page(self):
total_width = self.driver.execute_script("return document.body.offsetWidth")
def _make_screenshot_whole_page(self, locator_type, query_string):
scroll_time = 0.2
# Нужно заставить отработать все что есть с автоподгрузкой, чтобы получить настоящую длину страницы
x, y, width, height = self._get_raw_coords_by_locator(locator_type, query_string)
total_height = self.driver.execute_script("return document.body.parentNode.scrollHeight")
logging.info(f"total height: {total_height}")
while True:
old_total_height = total_height
self._scroll(0, total_height + 9999)
time.sleep(scroll_time)
total_height = self.driver.execute_script("return document.body.parentNode.scrollHeight")
logging.info(f"new total height: {total_height}")
# Если высота перестала изменяться, или элемент уже попал на скриншот.
# Второе условие позволяет не скролить до конца на стрницах с "бесконечной" длинной (выдача видео, картинок)
if (old_total_height == total_height) or (total_height > y):
break
total_width = self.driver.execute_script("return document.body.offsetWidth")
viewport_width = self.driver.execute_script("return document.body.clientWidth")
viewport_height = self.driver.execute_script("return window.innerHeight")
screenshots = []
@@ -42,16 +61,31 @@ class TestCase(common.TestCase):
self._scroll(0, 0)
while offset <= total_height:
logging.info(f"offset: {offset}, total height: {total_height}")
screenshots.append(self.driver.get_screenshot_as_png())
offset += viewport_height
self._scroll(0, offset)
return self.image_processor.paste(screenshots)
# эта часть последнего скриншота, которая дублирует предпоследний скриншот
# так просходит потому что не всегда страница делится на целое количество вьюпортов
over_height = offset - total_height
logging.info(f"offset: {offset}, total height: {total_height}, over height: {over_height}, pixel density: {self.pixel_ratio}")
return self.image_processor.paste(screenshots, over_height * self.pixel_ratio)
def _get_coords_by_locator(self, by, locator) -> Tuple[int, int, int, int]:
def _use_full_screen(self):
# хак чтобы снять целиком элемент который не помещается на страницу
# https://stackoverflow.com/questions/44085722/how-to-get-screenshot-of-full-webpage-using-selenium-and-java
# https://gist.github.com/elcamino/5f562564ecd2fb86f559
self.driver.set_window_size(1425, 2900)
def _get_raw_coords_by_locator(self, locator_type, query_string):
"""Без учета плотности пикселей."""
wait = WebDriverWait(self.driver, timeout=10, ignored_exceptions=Exception)
wait.until(lambda _: self.driver.find_element(locator_type, query_string).is_displayed(),
message="Невозможно получить размеры элемента, элемент не отображается")
# После того, как дождались видимости элемента, ждем еще 2 секунды, чтобы точно завершились разные анимации
time.sleep(2)
el = self.driver.find_element(by, locator)
el = self.driver.find_element(locator_type, query_string)
location = el.location
size = el.size
x = location["x"]
@@ -60,76 +94,93 @@ class TestCase(common.TestCase):
height = location["y"] + size['height']
return x, y, width, height
def _get_element_screenshot(self, by, locator, action, finalize) \
-> Tuple[Image.Image, Tuple[int, int, int, int]]:
"""Get screenshot of element.
def _get_coords_by_locator(self, locator_type, query_string) -> Tuple[int, int, int, int]:
x, y, width, height = self._get_raw_coords_by_locator(locator_type, query_string)
return x * self.pixel_ratio, y * self.pixel_ratio, width * self.pixel_ratio, height * self.pixel_ratio
Can't use session/{sessionId}/element/{elementId}/screenshot because it's available only in Edge
def _get_element_screenshot(self,
locator_type,
query_string,
action,
finalize,
scroll_and_screen) \
-> Tuple[Image.Image, Tuple[int, int, int, int]]:
"""Сделать скриншот страницы и кропнуть до скриншота элемента.
Не получится использовать метод session/{sessionId}/element/{elementId}/screenshot
Потому что он имплементирован только в эдж.
https://stackoverflow.com/questions/36084257/im-trying-to-take-a-screenshot-of-an-element-with-selenium-webdriver-but-unsup
"""
if not scroll_and_screen:
# Иногда страница по дефолту открыта посередине, чтобы не ползли координаты
# элемента с scroll_and_screen=False, скролим до начала. Это нужно делать до вызова action, на случай если
# в action страницу нужно проскролить до определенной точки.
self._scroll(0, 0)
# Тут готовим страницу к снятию скриншота
if callable(action):
action()
coordinates = self._get_coords_by_locator(by, locator)
screen = self._make_screenshot_whole_page()
logging.info(f"element: {locator}, coordinates: {coordinates}")
if scroll_and_screen:
screen = self._make_screenshot_whole_page(locator_type, query_string)
else:
screen = self.image_processor.load_image_from_bytes(self.driver.get_screenshot_as_png())
coordinates = self._get_coords_by_locator(locator_type, query_string)
logging.info(f"element: {query_string}, coordinates: {coordinates}")
# Тут можно выполнить дополнительные проверки после снятия скрина
if callable(finalize):
finalize()
return screen.crop(coordinates), coordinates
def _get_diff(self, element: CustomWebElement, action=None, full_page=False, finalize=None):
"""Get screenshot from test environment and compare with production.
def _get_diff(self, element, action=None, full_screen=True, full_page=False, finalize=None, scroll_and_screen=True):
"""Получит скриншоты с текущей страницы, и с эталонной.
:param element: element for check (instance of CustomWebElement)
:param action: callback executed before making screenshot. Use it when need prepare page for screenshot.
:param full_page: ignore element, and compare whole page.
:param finalize: callback executed after screenshot.
Поблочно сравнит их, и вернет количество отличающихся блоков.
:param element: любой объект у которого есть свойства locator_type, и query_string (по ним будет найден элемент)
:param action: функция которая подготовит страницу к снятию скриншота
:param full_screen: ресайзить ли браузер до максимума
:param full_page: скринить всю страницу, а не только переданный элемент
:param finalize: финализация после сравнения скриншотов
:param scroll_and_screen: скролить страницу (сверху к низу) и склеивать участки в один скриншот
"""
if full_screen:
self._use_full_screen()
if full_page:
by, locator = self.BODY_LOCATOR
locator_type, query_string = (By.XPATH, "//body")
scroll_and_screen = False
else:
by, locator = element.by, element.locator
locator_type, query_string = element.locator_type, element.query_string
saved_url = urlparse(self.driver.current_url)
# noinspection PyProtectedMember
prod_url = saved_url._replace(netloc=urlparse(self.staging).netloc)
prod_url = saved_url._replace(netloc=self.staging)
# Открываем странички пока размеры элементов на них не совпадут
coords_equal = False
attempts = 0
while not coords_equal and attempts < self.attempts:
logging.info(f'Try make screenshots. Attempts: {attempts}')
# На текущей странице делаем первый скриншот
first_image, coords_test = self._get_element_screenshot(locator_type, query_string, action, finalize,
scroll_and_screen)
logging.info('Done screen on test stand')
# Теперь делаем скриншот в проде
self.driver.get(prod_url.geturl())
second_image, coords_prod = self._get_element_screenshot(locator_type, query_string, action, finalize,
scroll_and_screen)
logging.info('Done screen on stage stand')
# На текущей странице делаем первый скриншот
first_image, coords_test = self._get_element_screenshot(by, locator, action, finalize)
# Теперь делаем скриншот в проде
self.driver.get(prod_url.geturl())
second_image, coords_prod = self._get_element_screenshot(by, locator, action, finalize)
# Возращаемся на тестовый стенд. Всегда нужно возвращаться на тестовый стенд. На это завязаны тесты и отчеты
self.driver.get(saved_url.geturl())
# Если размеры элементов на странице не совпали, и выбраны не все попытки, пробуем снова
attempts += 1
# По размеру блока, оставим на случай, если по координатам способ будет не работать не очень
x1, y1, x2, y2 = coords_test
x_1, y_1, x_2, y_2 = coords_prod
size_test = round(((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5)
size_prod = round(((x_2 - x_1) ** 2 + (y_2 - y_1) ** 2) ** 0.5)
logging.info(f"Coords test: {coords_test}, coords prod: {coords_prod}")
logging.info(f"Size test: {size_test}, size prod: {size_prod}")
coords_equal = size_prod == size_test
# noinspection PyUnboundLocalVariable
diff, result, first, second = self.image_processor.get_images_diff(first_image, second_image)
# Возращаемся на тестовый стенд. Всегда нужно возвращаться на тестовый стенд. На это завязаны тесты и отчеты
self.driver.get(saved_url.geturl())
# Для добавления в отчет (https://github.com/allure-framework/allure2/tree/master/plugins/screen-diff-plugin)
allure.attach(result, "diff", allure.attachment_type.PNG)
allure.attach(first, "actual", allure.attachment_type.PNG)
allure.attach(second, "expected", allure.attachment_type.PNG)
# noinspection PyUnboundLocalVariable
allure.attach(self.image_processor.image_to_bytes(first_image), 'actual', allure.attachment_type.PNG)
# noinspection PyUnboundLocalVariable
allure.attach(self.image_processor.image_to_bytes(second_image), 'expected', allure.attachment_type.PNG)
# noinspection PyUnboundLocalVariable
diff, result = self.image_processor.get_images_diff(first_image, second_image)
allure.attach(result, 'diff', allure.attachment_type.PNG)
return diff, saved_url, prod_url
@@ -137,7 +188,6 @@ class TestCase(common.TestCase):
diff, _, _ = self._get_diff(*args, **kwargs)
return diff
def check_by_screenshot(self, element: CustomWebElement, *args, **kwargs):
def check_by_screenshot(self, element, *args, **kwargs):
diff, saved_url, prod_url = self._get_diff(element, *args, **kwargs)
info = element.description
assert diff == 0, f"{info} отличается на страницах:\n{saved_url.geturl()}\nи\n{prod_url.geturl()}"
assert diff == 0, f"Элемент отличается на страницах:\n{saved_url.geturl()}\nи\n{prod_url.geturl()}"