├── .gitignore ├── conftest.py ├── helpers ├── db.py └── web_service.py ├── page_objects ├── application.py ├── demo_pages.py └── test_cases.py ├── pytest.ini ├── readme.md ├── requirements.txt ├── secure.json ├── settings.py └── tests ├── test_dashboard.py ├── test_demo.py ├── test_location.py ├── test_mobile.py └── test_testcases.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | #folders 7 | .idea/ 8 | .pytest_chache/ 9 | 10 | # reporting 11 | report/ 12 | report.xml 13 | 14 | # logs 15 | *.log 16 | 17 | # secure info 18 | #secure.json -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import pytest 4 | import logging 5 | import allure 6 | from settings import * 7 | from pytest import fixture, hookimpl 8 | from playwright.sync_api import sync_playwright 9 | from page_objects.application import App 10 | from helpers.web_service import WebService 11 | from helpers.db import DataBase 12 | 13 | 14 | @fixture(autouse=True, scope='session') 15 | def preconditions(request): 16 | """ 17 | Global fixtures. Run automatically before all tests and executes part before yield statement as test precondition. 18 | Executes code after yield statement after last test executed as test post conditions 19 | 20 | Reports test results to TCP via API helper in post conditions 21 | 22 | :param request: pytest fixture 23 | https://docs.pytest.org/en/6.2.x/reference.html#std-fixture-request 24 | """ 25 | logging.info('preconditions started') 26 | base_url = request.config.getini('base_url') 27 | tcm = request.config.getini('tcm_report') 28 | secure = request.config.getoption('--secure') 29 | config = load_config(request.session.fspath.strpath, secure) 30 | yield 31 | logging.info('postconditions started') 32 | 33 | if tcm == 'True': 34 | web = WebService(base_url) 35 | web.login(**config['users']['userRole3']) 36 | for test in request.node.items: 37 | if len(test.own_markers) > 0: 38 | if test.own_markers[0].name == 'test_id': 39 | if test.result_call.passed: 40 | web.report_test(test.own_markers[0].args[0], 'PASS') 41 | if test.result_call.failed: 42 | web.report_test(test.own_markers[0].args[0], 'FAIL') 43 | 44 | 45 | @fixture(scope='session') 46 | def get_web_service(request): 47 | """ 48 | Fixture returns authenticated WebService object to work with tested app directly via web services 49 | 50 | :param request: pytest fixture 51 | https://docs.pytest.org/en/6.2.x/reference.html#std-fixture-request 52 | 53 | :return: WebService object 54 | """ 55 | base_url = request.config.getini('base_url') 56 | secure = request.config.getoption('--secure') 57 | config = load_config(request.session.fspath.strpath, secure) 58 | web = WebService(base_url) 59 | web.login(**config['users']['userRole1']) 60 | yield web 61 | web.close() 62 | 63 | 64 | @fixture(scope='session') 65 | def get_db(request): 66 | """ 67 | Fixture returns DataBase object to work with tested app directly via db 68 | 69 | :param request: pytest fixture 70 | https://docs.pytest.org/en/6.2.x/reference.html#std-fixture-request 71 | 72 | :return: WebService object 73 | """ 74 | path = request.config.getini('db_path') 75 | db = DataBase(path) 76 | yield db 77 | db.close() 78 | 79 | 80 | @fixture(scope='session') 81 | def get_playwright(): 82 | """ 83 | returns single instance of playwright itself 84 | :return: 85 | """ 86 | with sync_playwright() as playwright: 87 | yield playwright 88 | 89 | 90 | @fixture(scope='session', params=['chromium']) 91 | def get_browser(get_playwright, request): 92 | browser = request.param 93 | # save browser type to env variable so fixtures and tests can get current browser 94 | # Needed to skip unused browser-test combinations 95 | os.environ['PWBROWSER'] = browser 96 | headless = request.config.getini('headless') 97 | if headless == 'True': 98 | headless = True 99 | else: 100 | headless = False 101 | 102 | if browser == 'chromium': 103 | bro = get_playwright.chromium.launch(headless=headless) 104 | elif browser == 'firefox': 105 | bro = get_playwright.firefox.launch(headless=headless) 106 | elif browser == 'webkit': 107 | bro = get_playwright.webkit.launch(headless=headless) 108 | else: 109 | assert False, 'unsupported browser type' 110 | 111 | yield bro 112 | bro.close() 113 | del os.environ['PWBROWSER'] 114 | 115 | 116 | @fixture(scope='session') 117 | def desktop_app(get_browser, request): 118 | """ 119 | Fixture of playwright for non autorised tests 120 | """ 121 | base_url = request.config.getini('base_url') 122 | app = App(get_browser, base_url=base_url, **BROWSER_OPTIONS) 123 | app.goto('/') 124 | yield app 125 | app.close() 126 | 127 | 128 | @fixture(scope='session') 129 | def desktop_app_auth(desktop_app, request): 130 | secure = request.config.getoption('--secure') 131 | config = load_config(request.session.fspath.strpath, secure) 132 | app = desktop_app 133 | app.goto('/login') 134 | app.login(**config['users']['userRole1']) 135 | yield app 136 | 137 | 138 | @fixture(scope='session') 139 | def desktop_app_bob(get_browser, request): 140 | base_url = request.config.getini('base_url') 141 | secure = request.config.getoption('--secure') 142 | config = load_config(request.session.fspath.strpath, secure) 143 | app = App(get_browser, base_url=base_url, **BROWSER_OPTIONS) 144 | app.goto('/login') 145 | app.login(**config['users']['userRole2']) 146 | yield app 147 | app.close() 148 | 149 | 150 | @fixture(scope='session', params=['iPhone 11', 'Pixel 2']) 151 | def mobile_app(get_playwright, get_browser, request): 152 | if os.environ.get('PWBROWSER') == 'firefox': 153 | pytest.skip() 154 | base_url = request.config.getini('base_url') 155 | device = request.param 156 | device_config = get_playwright.devices.get(device) 157 | if device_config is not None: 158 | device_config.update(BROWSER_OPTIONS) 159 | else: 160 | device_config = BROWSER_OPTIONS 161 | app = App(get_browser, base_url=base_url, **device_config) 162 | app.goto('/') 163 | yield app 164 | app.close() 165 | 166 | 167 | @fixture(scope='session') 168 | def mobile_app_auth(mobile_app, request): 169 | secure = request.config.getoption('--secure') 170 | config = load_config(request.session.fspath.strpath, secure) 171 | app = mobile_app 172 | app.goto('/login') 173 | app.login(**config['users']['userRole1']) 174 | yield app 175 | 176 | 177 | @hookimpl(tryfirst=True, hookwrapper=True) 178 | def pytest_runtest_makereport(item, call): 179 | outcome = yield 180 | result = outcome.get_result() 181 | # result.when == "setup" >> "call" >> "teardown" 182 | setattr(item, f'result_{result.when}', result) 183 | 184 | 185 | @fixture(scope='function', autouse=True) 186 | def make_screenshots(request): 187 | yield 188 | if request.node.result_call.failed: 189 | for arg in request.node.funcargs.values(): 190 | if isinstance(arg, App): 191 | allure.attach(body=arg.page.screenshot(), 192 | name='screenshot.png', 193 | attachment_type=allure.attachment_type.PNG) 194 | 195 | 196 | def pytest_addoption(parser): 197 | parser.addoption('--secure', action='store', default='secure.json') 198 | parser.addini('base_url', help='base url of site under test', default='http://127.0.0.1:8000') 199 | parser.addini('db_path', help='path to sqlite db file', default='C:\\DEV\\TestMe-TCM\\db.sqlite3') 200 | parser.addini('headless', help='run browser in headless mode', default='True') 201 | parser.addini('tcm_report', help='report test results to tcm', default='False') 202 | 203 | 204 | # request.session.fspath.strpath - path to project root 205 | def load_config(project_path: str, file: str) -> dict: 206 | config_file = os.path.join(project_path, file) 207 | with open(config_file) as cfg: 208 | return json.loads(cfg.read()) 209 | -------------------------------------------------------------------------------- /helpers/db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | 4 | class DataBase: 5 | def __init__(self, path: str): 6 | self.connection = sqlite3.connect(path) 7 | 8 | def list_test_cases(self): 9 | c = self.connection.cursor() 10 | c.execute('SELECT * FROM tcm_testcase') 11 | return c.fetchall() 12 | 13 | def delete_test_case(self, test_name: str): 14 | c = self.connection.cursor() 15 | c.execute('DELETE FROM tcm_testcase WHERE name=?', (test_name,)) 16 | self.connection.commit() 17 | 18 | def close(self): 19 | self.connection.close() 20 | -------------------------------------------------------------------------------- /helpers/web_service.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import re 3 | 4 | 5 | class WebService: 6 | def __init__(self, base_url: str): 7 | self.session = requests.session() 8 | self.base_url = base_url 9 | 10 | def _get_token(self, url: str): 11 | rsp = self.session.get(self.base_url + url) 12 | match = re.search('', rsp.text) 13 | if match: 14 | return match.group(1) 15 | else: 16 | assert False, 'failed to get token' 17 | 18 | def login(self, login: str, password: str): 19 | token = self._get_token('/login/') 20 | data = { 21 | 'username': login, 22 | 'password': password, 23 | 'csrfmiddlewaretoken': token 24 | } 25 | self.session.post(self.base_url + '/login/', data=data) 26 | csrftoken = self.session.cookies.get('csrftoken') 27 | self.session.headers.update({'X-CSRFToken': csrftoken}) 28 | 29 | def create_test(self, test_name: str, test_description: str): 30 | token = self._get_token('/test/new') 31 | data = { 32 | 'name': test_name, 33 | 'description': test_description, 34 | 'csrfmiddlewaretoken': token 35 | } 36 | self.session.post(self.base_url + '/test/new', data=data) 37 | 38 | def report_test(self, test_id: int, status: str): 39 | self.session.post(self.base_url + f'/tests/{test_id}/status', json={'status': status}) 40 | 41 | def close(self): 42 | self.session.close() 43 | -------------------------------------------------------------------------------- /page_objects/application.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import allure 3 | from playwright.sync_api import Browser 4 | from playwright.sync_api import Request, Route, ConsoleMessage, Dialog 5 | from page_objects.test_cases import TestCases 6 | from page_objects.demo_pages import DemoPages 7 | from settings import DEFAULT_TIMEOUT 8 | from contextlib import contextmanager 9 | 10 | 11 | class App: 12 | def __init__(self, browser: Browser, base_url: str, **kwargs): 13 | self.browser = browser 14 | self.context = self.browser.new_context(**kwargs) 15 | self.context.set_default_timeout(DEFAULT_TIMEOUT) 16 | self.page = self.context.new_page() 17 | self.base_url = base_url 18 | self.test_cases = TestCases(self.page) 19 | self.demo_pages = DemoPages(self.page) 20 | 21 | def console_handler(message: ConsoleMessage): 22 | if message.type == 'error': 23 | logging.error(f'page: {self.page.url}, console error: {message.text}') 24 | 25 | def dialog_handler(dialog: Dialog): 26 | logging.warning(f'page: {self.page.url}, dialog text: {dialog.message}') 27 | dialog.accept() 28 | 29 | self.page.on('console', console_handler) 30 | self.page.on('dialog', dialog_handler) 31 | 32 | @allure.step 33 | def goto(self, endpoint: str, use_base_url=True): 34 | if use_base_url: 35 | self.page.goto(self.base_url + endpoint) 36 | else: 37 | self.page.goto(endpoint) 38 | 39 | @allure.step 40 | def navigate_to(self, menu: str): 41 | self.page.click(f"css=header >> text=\"{menu}\"") 42 | self.page.wait_for_load_state() 43 | 44 | @allure.step 45 | def login(self, login: str, password: str): 46 | self.page.fill("input[name=\"username\"]", login) 47 | self.page.fill("input[name=\"password\"]", password) 48 | self.page.press("input[name=\"password\"]", "Enter") 49 | 50 | @allure.step 51 | def create_test(self, test_name: str, test_description: str): 52 | self.page.fill("input[name=\"name\"]", test_name) 53 | self.page.fill("textarea[name=\"description\"]", test_description) 54 | self.page.click("input[type=\"submit\"]") 55 | 56 | @allure.step 57 | def click_menu_button(self): 58 | self.page.click('.menuBtn') 59 | 60 | @allure.step 61 | def is_menu_button_visible(self): 62 | return self.page.is_visible('.menuBtn') 63 | 64 | @allure.step 65 | def get_location(self): 66 | return self.page.text_content('.position') 67 | 68 | @allure.step 69 | @contextmanager 70 | def intercept_requests(self, url: str, payload: str): 71 | def handler(route: Route, request: Request): 72 | route.fulfill(status=200, body=payload) 73 | 74 | self.page.route(url, handler) 75 | yield 76 | self.page.unroute(url) 77 | 78 | @allure.step 79 | def refresh_dashboard(self): 80 | # with self.page.expect_response(lambda response: response.status_code == 200): 81 | self.page.click('input') 82 | self.page.wait_for_event('response') 83 | 84 | @allure.step 85 | def get_total_tests_stats(self): 86 | return self.page.text_content('.total >> span') 87 | 88 | @allure.step 89 | def close(self): 90 | self.page.close() 91 | self.context.close() 92 | -------------------------------------------------------------------------------- /page_objects/demo_pages.py: -------------------------------------------------------------------------------- 1 | import allure 2 | from playwright.sync_api import Page 3 | 4 | 5 | class DemoPages: 6 | def __init__(self, page: Page): 7 | self.page = page 8 | 9 | @allure.step 10 | def open_page_after_wait(self, wait_time: int): 11 | self.page.fill('.waitPageTime', str(wait_time)) 12 | with self.page.expect_navigation(wait_until='load', timeout=(wait_time + 1) * 1000): 13 | self.page.click('.waitPage', no_wait_after=True) 14 | 15 | @allure.step 16 | def check_wait_page(self): 17 | return 'Wait Page' == self.page.text_content('h3') 18 | 19 | @allure.step 20 | def open_page_and_wait_ajax(self, wait_time: int): 21 | self.page.fill('.waitAjaxRequests', str(wait_time)) 22 | self.page.click('.waitAjax') 23 | self.page.wait_for_load_state('networkidle') 24 | 25 | @allure.step 26 | def get_ajax_responses_count(self): 27 | return len(self.page.query_selector_all('css=.ajaxResponses > p')) 28 | 29 | @allure.step 30 | def click_new_page_button(self, ctrl_key=False): 31 | if ctrl_key: 32 | mod = ['Control'] 33 | else: 34 | mod = None 35 | self.page.click('.newPage', modifiers=mod) 36 | 37 | @allure.step 38 | def inject_js(self): 39 | js = ''' 40 | console.error('this is injected error'); 41 | alert('this is injected alert'); 42 | ''' 43 | self.page.evaluate(js) 44 | -------------------------------------------------------------------------------- /page_objects/test_cases.py: -------------------------------------------------------------------------------- 1 | import allure 2 | from playwright.sync_api import Page 3 | 4 | 5 | class TestCases: 6 | def __init__(self, page: Page): 7 | self.page = page 8 | 9 | @allure.step 10 | def check_test_exists(self, test_name: str): 11 | return self.page.query_selector(f'css=tr >> text=\"{test_name}\"') is not None 12 | 13 | @allure.step 14 | def delete_test_by_name(self, test_name: str): 15 | row = self.page.query_selector(f'*css=tr >> text=\"{test_name}\"') 16 | row.query_selector('.deleteBtn').click() 17 | self.page.wait_for_timeout(300) 18 | 19 | @allure.step 20 | def check_columns_hidden(self): 21 | description = self.page.is_hidden('.thDes') 22 | author = self.page.is_hidden('.thAuthor') 23 | executor = self.page.is_hidden('.thLast') 24 | return description and author and executor 25 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --secure secure.json --junitxml=report.xml --alluredir report/ 3 | db_path = C:\dev\TestMe-TCM\db.sqlite3 4 | base_url = http://127.0.0.1:8000 5 | headless = False 6 | tcm_report = False 7 | 8 | # logs 9 | log_cli = True 10 | log_file = test.log 11 | log_file_level = INFO -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Playwright automation with python 2 | 3 | Test project to show 4 | 5 | - features of MS Playwright on Python 6 | - automation project structure using pytest 7 | 8 | All tests designed to cover application [Test-Me](https://github.com/Ypurek/TestMe-TCM) 9 | 10 | Guide in Ukrainian about python playwright: [Our youtube channel](https://www.youtube.com/watch?v=024tZHVFiLA&list=PLGE9K4YL_ywj4F7cSA4oDptnqTmyS7hZp) 11 | 12 | 13 | Tools: 14 | 15 | - [Playwright](https://github.com/microsoft/playwright-python) 16 | - [Pytest](https://pytest.org/) 17 | - [PyCharm](https://www.jetbrains.com/ru-ru/pycharm/) 18 | 19 | ## Install guide 20 | 21 | 1. Install python 22 | 2. Install PyCharm 23 | 3. Install python dependencies 24 | `pip install -r requirements.txt` 25 | 4. Make sure playwright version 1.8+ installed 26 | 27 | ## Project structure 28 | 29 | - [conftest.py](conftest.py) file contains main fixtures to work 30 | - Page objects stored in page_object folder 31 | - Tests stored in tests folder 32 | - Settings are spread between: 33 | - pytest.ini 34 | - settings.py 35 | 36 | ## Run guide 37 | 1. Install software to test [Test-Me](https://github.com/Ypurek/TestMe-TCM) 38 | 2. Set correct path to DB in `pytest.ini` file 39 | 3. Run Test-Me (check guide in it's repo) 40 | 4. Run tests using command `pytest` 41 | 42 | 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | playwright 2 | pytest 3 | allure-pytest 4 | requests 5 | 6 | -------------------------------------------------------------------------------- /secure.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": { 3 | "userRole1": { 4 | "login": "alice", 5 | "password": "Qamania123" 6 | }, 7 | "userRole2": { 8 | "login": "bob ", 9 | "password": "Qamania123" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | BROWSER_OPTIONS = { 2 | "geolocation": {"latitude": 48.8, "longitude": 2.3}, 3 | "permissions": ["geolocation"] 4 | } 5 | 6 | DEFAULT_TIMEOUT = 10_000 7 | -------------------------------------------------------------------------------- /tests/test_dashboard.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pytest import mark 3 | 4 | 5 | @mark.test_id(214) 6 | def test_dashboard_data(desktop_app_auth): 7 | payload = json.dumps({"total": 0, "passed": 0, "failed": 0, "norun": 0}) 8 | with desktop_app_auth.intercept_requests('**/getstat*', payload): 9 | desktop_app_auth.refresh_dashboard() 10 | assert desktop_app_auth.get_total_tests_stats() == '0' 11 | 12 | 13 | @mark.test_id(215) 14 | def test_multiple_roles(desktop_app_auth, desktop_app_bob, get_db): 15 | alice = desktop_app_auth 16 | bob = desktop_app_bob 17 | alice.refresh_dashboard() 18 | before = alice.get_total_tests_stats() 19 | bob.navigate_to('Create new test') 20 | bob.create_test('test by bob', 'bob') 21 | alice.refresh_dashboard() 22 | after = alice.get_total_tests_stats() 23 | get_db.delete_test_case('test by bob') 24 | assert int(before) + 1 == int(after) 25 | -------------------------------------------------------------------------------- /tests/test_demo.py: -------------------------------------------------------------------------------- 1 | import allure 2 | 3 | 4 | @allure.title('test for wait more than 30 seconds') 5 | def test_wait_more_30_sec(desktop_app_auth): 6 | desktop_app_auth.navigate_to('Demo pages') 7 | desktop_app_auth.demo_pages.open_page_after_wait(3) 8 | assert desktop_app_auth.demo_pages.check_wait_page() 9 | 10 | 11 | def test_ajax(desktop_app_auth): 12 | desktop_app_auth.navigate_to('Demo pages') 13 | desktop_app_auth.demo_pages.open_page_and_wait_ajax(2) 14 | assert 2 == desktop_app_auth.demo_pages.get_ajax_responses_count() 15 | 16 | 17 | def test_handlers(desktop_app_auth): 18 | desktop_app_auth.navigate_to('Demo pages') 19 | desktop_app_auth.demo_pages.click_new_page_button() 20 | desktop_app_auth.demo_pages.inject_js() 21 | desktop_app_auth.navigate_to('Test Cases') 22 | assert desktop_app_auth.test_cases.check_test_exists('Check new test') 23 | -------------------------------------------------------------------------------- /tests/test_location.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | 4 | @mark.test_id(216) 5 | def test_location_ok(mobile_app_auth): 6 | location = mobile_app_auth.get_location() 7 | assert '48.8:2.3' == location 8 | -------------------------------------------------------------------------------- /tests/test_mobile.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | 4 | @mark.test_id(217) 5 | def test_columns_hidden(mobile_app_auth): 6 | mobile_app_auth.click_menu_button() 7 | mobile_app_auth.navigate_to('Test Cases') 8 | assert mobile_app_auth.test_cases.check_columns_hidden() 9 | 10 | 11 | def test_mobile_git(): 12 | assert True 13 | -------------------------------------------------------------------------------- /tests/test_testcases.py: -------------------------------------------------------------------------------- 1 | from pytest import mark, fixture 2 | 3 | ddt = { 4 | 'argnames': 'name,description', 5 | 'argvalues': [('hello', 'world'), 6 | ('hello', ''), 7 | ('123', 'world'), ], 8 | 'ids': ['general test', 'test with no description', 'test with digits in name'] 9 | } 10 | 11 | 12 | @fixture(scope='function') 13 | def clean_up(get_db): 14 | """ 15 | cleanup should be done separately from test 16 | 17 | :param get_db: 18 | :return: 19 | """ 20 | tests_to_delete = list() 21 | 22 | def cleaner(test_name): 23 | tests_to_delete.append(test_name) 24 | 25 | yield cleaner 26 | for test in tests_to_delete: 27 | get_db.delete_test_case(test) 28 | 29 | 30 | @mark.parametrize(**ddt) 31 | def test_new_testcase(desktop_app_auth, name, description, clean_up, get_db): 32 | tests = get_db.list_test_cases() 33 | desktop_app_auth.navigate_to('Create new test') 34 | desktop_app_auth.create_test(name, description) 35 | desktop_app_auth.navigate_to('Test Cases') 36 | test_exists = desktop_app_auth.test_cases.check_test_exists(name) 37 | clean_up(name) 38 | tests_new_count = get_db.list_test_cases() 39 | # desktop_app_auth.test_cases.delete_test_by_name(name) 40 | 41 | assert test_exists 42 | assert len(tests) + 1 == len(tests_new_count) 43 | 44 | 45 | def test_testcase_does_not_exist(desktop_app_auth): 46 | desktop_app_auth.navigate_to('Test Cases') 47 | assert not desktop_app_auth.test_cases.check_test_exists('fndsfidsnisdfnisdfdsf') 48 | assert False 49 | 50 | 51 | @fixture 52 | def delete_precondition(get_web_service): 53 | test_name = 'test for delete' 54 | get_web_service.create_test(test_name, 'delete me pls') 55 | yield 56 | 57 | 58 | def test_delete_test_case(desktop_app_auth, delete_precondition): 59 | test_name = 'test for delete' 60 | desktop_app_auth.navigate_to('Test Cases') 61 | test_exists = desktop_app_auth.test_cases.check_test_exists(test_name) 62 | desktop_app_auth.test_cases.delete_test_by_name(test_name) 63 | assert test_exists 64 | assert not desktop_app_auth.test_cases.check_test_exists(test_name) 65 | --------------------------------------------------------------------------------