initial commit

This commit is contained in:
a.krasnov
2019-09-22 21:26:13 +03:00
commit 5392fa46c2
62 changed files with 1835 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
from PIL import ImageDraw, Image
from io import BytesIO
from typing import Tuple, List
import logging
class ImageProcessor(object):
"""Class for image comparison."""
def __init__(self):
self._block_width = 20 # default
self._block_height = 20
self._accuracy = 0.0001 # less better
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
result = []
for row in range(0, max_height, self._block_height):
for col in range(0, max_width, self._block_width):
# Если дошли до края изображения
if width_change > max_width:
width_change = max_width
# отрезаем по краю
if height_change > max_height:
height_change = max_height
# col, row -- верхний левый угол
box = (col, row, width_change, height_change)
cropped = image.crop(box)
result.append({"image": cropped, "box": box})
# сдвигаем вправо по ширине
width_change += self._block_width
# сдвигаем вниз по высоте
height_change += self._block_height
# возвращаем указатель на ширину в стартовую позицию
width_change = self._block_width
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
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)
return image_total
def get_images_diff(self, first_image: Image.Image, second_image: Image.Image) -> Tuple[int, bytes, bytes, bytes]:
"""Compare two images."""
result_image = first_image.copy()
first_image_blocks = self._slice_image(first_image)
second_image_blocks = self._slice_image(second_image)
# если скриншоты разных размеров, то все блоки из большего скриншота, которые не попали в меньший нужно добавить
# к битым
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"])
# если пиксели отличаются больше чем на self.accuracy -- помечаем блок как битый
if (first_pixels != 0 and second_pixels != 0) and abs(1 - (first_pixels / second_pixels)) >= self._accuracy:
draw = ImageDraw.Draw(result_image)
draw.rectangle(first_image_blocks[index]["box"], outline="red")
mistaken_blocks += 1
result = BytesIO()
first = BytesIO()
second = BytesIO()
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."""
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]
result = Image.new('RGB', (max_width, max_height))
logging.info(f'Screen size: ({max_width}, {max_height})')
offset = 0
for image in images:
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):
"""Загрузить изображение из байтовой строки."""
return Image.open(BytesIO(data))

View File

@@ -0,0 +1,46 @@
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)

View File

@@ -0,0 +1,42 @@
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 WebElement instance
"""
if isinstance(instance, Element):
return self
return CustomWebElement(self.by, self.locator, instance.driver.find_element(self.by, self.locator),
self.description)

View File

@@ -0,0 +1,11 @@
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", "Поисковый инпут")

View File

@@ -0,0 +1,21 @@
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)

View File

@@ -0,0 +1,29 @@
import pytest
import os
from screenshot_tests.page_objects.elements import PageBoundGeneric
from conftest import Config
from typing import Type
class TestCase:
"""Base class for all tests."""
@pytest.fixture(autouse=True)
def set_driver(self, driver):
self.driver = driver
@pytest.fixture(autouse=True)
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

View File

@@ -0,0 +1,143 @@
import pytest
import time
import logging
import allure
from screenshot_tests.page_objects.custom_web_element import CustomWebElement
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
# 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."""
BODY_LOCATOR = (Locators.CSS_SELECTOR, "body")
@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})"
self.driver.execute_script(scroll_string)
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")
total_height = self.driver.execute_script("return document.body.parentNode.scrollHeight")
viewport_width = self.driver.execute_script("return document.body.clientWidth")
viewport_height = self.driver.execute_script("return window.innerHeight")
screenshots = []
offset = 0
assert viewport_width == total_width, "Ширина вьюпорта, и ширина экрана должны совпадать"
self._scroll(0, 0)
while offset <= total_height:
screenshots.append(self.driver.get_screenshot_as_png())
offset += viewport_height
self._scroll(0, offset)
return self.image_processor.paste(screenshots)
def _get_coords_by_locator(self, by, locator) -> Tuple[int, int, int, int]:
# После того, как дождались видимости элемента, ждем еще 2 секунды, чтобы точно завершились разные анимации
time.sleep(2)
el = self.driver.find_element(by, locator)
location = el.location
size = el.size
x = location["x"]
y = location["y"]
width = location["x"] + size['width']
height = location["y"] + size['height']
return x * 2, y * 2, width * 2, height * 2
def _get_element_screenshot(self, by, locator, action, finalize) \
-> Tuple[Image.Image, Tuple[int, int, int, int]]:
"""Get screenshot of element.
Can't use session/{sessionId}/element/{elementId}/screenshot because it's available only in Edge
https://stackoverflow.com/questions/36084257/im-trying-to-take-a-screenshot-of-an-element-with-selenium-webdriver-but-unsup
"""
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 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.
: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.
"""
if full_page:
by, locator = self.BODY_LOCATOR
else:
by, locator = element.by, element.locator
saved_url = urlparse(self.driver.current_url)
# noinspection PyProtectedMember
prod_url = saved_url._replace(netloc=urlparse(self.staging).netloc)
# Открываем странички пока размеры элементов на них не совпадут
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(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)
# Для добавления в отчет (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)
return diff, saved_url, prod_url
def get_diff(self, *args, **kwargs):
diff, _, _ = self._get_diff(*args, **kwargs)
return diff
def check_by_screenshot(self, element: CustomWebElement, *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()}"