How to write UI auto tests in Python


I’m tired of looking at how many QA Automation write their absolutely crutch solutions using the Page Object, Page Factory patterns. This happens because in the field of QA Automation there are no specific frameworks and patterns according to which it is worth writing auto tests. Yes, there is a well-known Page Object, but even it is often used very crookedly. For example, there are many patterns in back-end development, one of them is MVC, which clearly says where to put routing, where models, and where business logic. But there are no specific patterns in automation that will tell you where to write allure.step, where to write checks, how to dynamically format the locator. From this arise opinions, and each supposedly correct, each one knows better what is best, in fact it is not. There are many “correct” decisions, but only according to the creator of these decisions.

Therefore, I decided to write an article on how to write UI auto tests correctly and describe the approaches that I came to after years of practice. Everything described below has a specific purpose for writing UI auto tests in real, commercial projects. The main task of this article is to make sure that the business logic of the product is tested, while everything looks beautiful in the code and in the auto testing report.


For an example of writing UI auto tests, we will use:

  • pip install pytest

  • playwright – pip install pytest-playwright

  • allure – pip install allure-pytest, not required dependency, you can use any other reporter

Why not Selenium? Probably because playwright is more convenient and modern. playwright has a lot of cool features with which he rips Selenium to pieces, below we will figure out how and why. Everything that will be described below using playwright is also applicable to any other framework.

But again, a framework is just a tool. An inept QA Automation using the coolest framework will write much worse code than an experienced QA Automation using regular Selenium without any wrappers.

In all the examples below, we will use the synchronous API for playwright.

We will write auto tests on this page The test case itself will be simple, but it will show the whole concept of working correctly with Page Object, Page Factory.

Test case:

  1. Open the page

  2. Click on search

  3. Checking if the search modal opened successfully

  4. We enter the language into the search, in our case it will be python

  5. Choose the first one from the results

  6. Checking that the Python page has opened

I note that the locators in the examples below are not reference ones, and the site for testing is the playwright documentation, on the front-end of which I can not influence in any way. In your projects, I advise you to use custom data-qa-id, which you can put in the React / Vue / Angular front-end application, or ask developers to do it.

Base Page

In essence, the Base Page is the main page that does not describe any particular page or component. By itself, the Base Page should not be used in tests, we inherit our pages or components from it.

import allure
from playwright.sync_api import Page, Response

from components.navigation.navbar import Navbar

class BasePage:
    def __init__(self, page: Page) -> None: = page
        self.navbar = Navbar(page)

    def visit(self, url: str) -> Response | None:
        with allure.step(f'Opening the url "{url}"'):
            return, wait_until="networkidle")

    def reload(self) -> Response | None:
        with allure.step(f'Reloading page with url "{}"'):

Inside BasePage we describe the basic methods. This is just a sample of how you can do BasePage

Page Factory

Now the most interesting. We will define a few basic components to make the pattern work.

Base Component. In python, there are no interfaces and the concept of implementation, so let’s make Component an abstract class and inherit it from ABC

from abc import ABC

import allure
from playwright.sync_api import Locator, Page, expect

class Component(ABC):
    def __init__(self, page: Page, locator: str, name: str) -> None: = page = name
        self.locator = locator

    def type_of(self) -> str:
        return 'component'

    def get_locator(self, **kwargs) -> Locator:
        locator = self.locator.format(**kwargs)

    def click(self, **kwargs) -> None:
        with allure.step(f'Clicking {self.type_of} with name "{}"'):
            locator = self.get_locator(**kwargs)

    def should_be_visible(self, **kwargs) -> None:
        with allure.step(f'Checking that {self.type_of} "{}" is visible'):
            locator = self.get_locator(**kwargs)

    def should_have_text(self, text: str, **kwargs) -> None:
        with allure.step(f'Checking that {self.type_of} "{}" has text "{text}"'):
            locator = self.get_locator(**kwargs)

Above is a very simplified implementation of Component. In your project, you can add more methods, more settings to them, replace allure.steps with others.

Let’s make some more components

Button – button. This component will contain basic methods for working with buttons.

import allure

from page_factory.component import Component

class Button(Component):
    def type_of(self) -> str:
        return 'button'

    def hover(self, **kwargs) -> None:
        with allure.step(f'Hovering over {self.type_of} with name "{}"'):
            locator = self.get_locator(**kwargs)

    def double_click(self, **kwargs):
        with allure.step(f'Double clicking {self.type_of} with name "{}"'):
            locator = self.get_locator(**kwargs)

Input – input field. This component will have basic methods for working with inputs.

import allure
from playwright.sync_api import expect

from page_factory.component import Component

class Input(Component):
    def type_of(self) -> str:
        return 'input'

    def fill(self, value: str, validate_value=False, **kwargs):
        with allure.step(f'Fill {self.type_of} "{}" to value "{value}"'):
            locator = self.get_locator(**kwargs)

            if validate_value:
                self.should_have_value(value, **kwargs)

    def should_have_value(self, value: str, **kwargs):
        with allure.step(f'Checking that {self.type_of} "{}" has a value "{value}"'):
            locator = self.get_locator(**kwargs)

Link – input field. This component will contain basic methods for working with links.

from page_factory.component import Component

class Link(Component):
    def type_of(self) -> str:
        return 'link'

ListItem – any element of the list.

from page_factory.component import Component

class ListItem(Component):
    def type_of(self) -> str:
        return 'list item'

Title – title. You can use just Text, but I prefer to strip everything for clarity. Title, Text, Label, Subtitle…

from page_factory.component import Component

class Title(Component):
    def type_of(self) -> str:
        return 'title'

Now let’s talk about why all this? This approach immediately solves a ton of problems and questions that arise for any QA Automation that has ever written UI auto tests.

  • Provides a convenient and intuitive interface for working with objects on the page. That is, we are not working with some kind of locator, but with a specific object, for example, a Button.

  • Universalizes all interactions and component checks. Very good for teams where two or more QA Automations work on auto tests, or if developers write auto tests too. With this approach, you will not have disputes and problems, that is, one QA Automation can write like this expect(locator).to_be_visible() , which is the only correct one from playwright’s point of view. The second QA Automation can write like this assert locator.is_visible(), which is also essentially correct, but crutch. On this basis, useless disputes can arise, or even worse, everyone writes as he wants, as a result we get a project in which the same checks are written differently. With this approach, we once set how the check is done and forget about it, everything works fine.

  • Provides the ability to universalize all steps for a report. In the example above, I used allure, but it doesn’t really matter, you can use any reporter. When declaring a new component, we do not need to rewrite all the steps, they are dynamically formed based on the parameters, name, type_of. Of course, you can change and expand them according to your requirements. It is enough to redefine type_of and we get a new component with completely unique steps.

  • Dynamic locators are a pain, but not with this approach. The locator formatting mechanism is ugly simple. We are transmitting **kwargs into each method, then it all goes to the locator itself self.locator.format(**kwargs). That is, it allows us to write locators span#some-element-id-{user_id}then transmit user_id across **kwargs straight to the locator. The mechanism is simple to the point of disgrace, but it saves us from locators in methods, from duplication or even worse than the hardcode of locators.

  • There is an opportunity to create components, work with which is specific. For example, you have some kind of tricky auto-complete in your product that needs to be somehow cunningly clicked, perhaps typed through the keyboard. You can create a component MyCustomAutocompleteinherit it from Input and override the method fill. Further, such a component can be used throughout the project without duplicating the input logic.

  • If you have several QA Automation teams working on auto tests at once. For example, one is testing the admin panel, the other is testing the site, then you can take the entire Page Factory inside the library. The library can be pushed to pypi or to your private nexus server. Further, all QA Automation teams can use the library, you will receive a common entry point for writing UI auto tests, common steps and checks.

  • Last but not least. The Page Factory approach I proposed is as simple as possible, and this is very important for scaling in the future. It is worse when there is “magic” in the code and this “magic” is clear only to the one who created it. In the solution above, that “magic” is missing, everything is as transparent as possible and within the framework of the usual OOP.

Perhaps this approach is not a classic Page Factory implementation, but this is the only working and adequate solution that I managed to develop. The solution I proposed is able to close all the questions and problems of working with components.

For me, there are mainly two tasks:

  • Focus on testing business logic, no code puzzles

  • A beautiful and understandable report that can easily be read by Manual QA, managers and developers


Now we will describe the pages that we need, already using the Page Factory.

main playwright page

from playwright.sync_api import Page

from pages.base_page import BasePage

class PlaywrightHomePage(BasePage):
    def __init__(self, page: Page) -> None:

Page with languages

from playwright.sync_api import Page

from page_factory.title import Title
from pages.base_page import BasePage

class PlaywrightLanguagesPage(BasePage):
    def __init__(self, page: Page) -> None:

        self.language_title = Title(
            page, locator="h2#{language}", name="Language title"

    def language_present(self, language: str):
            language.capitalize(), language=language


Now let’s describe the components that we will need.


from playwright.sync_api import Page

from components.modals.search_modal import SearchModal
from page_factory.button import Button
from import Link

class Navbar:
    def __init__(self, page: Page) -> None: = page

        self.search_modal = SearchModal(page)

        self.api_link = Link(page, locator="//a[text()='API']", name="API")
        self.docs_link = Link(page, locator="//a[text()='Docs']", name="Docs")
        self.search_button = Button(
            page, locator="button.DocSearch-Button", name="Search"

    def visit_docs(self):

    def visit_api(self):

    def open_search(self):




from playwright.sync_api import Page

from page_factory.input import Input
from page_factory.list_item import ListItem
from page_factory.title import Title

class SearchModal:
    def __init__(self, page: Page) -> None: = page

        self.empty_results_title = Title(
            page, locator="p.DocSearch-Help", name="Empty results"
        self.search_input = Input(
            page, locator="#docsearch-input", name="Search docs"
        self.search_result = ListItem(
            page, locator="#docsearch-item-{result_number}", name="Result item"

    def modal_is_opened(self):

    def find_result(self, keyword: str, result_number: int) -> None:
        self.search_input.fill(keyword, validate_value=True)


Now it’s time for the test. Everything is simple here, we have ready-made pages, we will assemble the test as a constructor, but before these we will write fixtures.

import pytest
from playwright.sync_api import Browser, Page, sync_playwright

from pages.playwright_home_page import PlaywrightHomePage
from pages.playwright_languages_page import PlaywrightLanguagesPage

def chromium_page() -> Page:
    with sync_playwright() as playwright:
        chromium = playwright.chromium.launch(headless=False)
        yield chromium.new_page()

def playwright_home_page(chromium_page: Page) -> PlaywrightHomePage:
    return PlaywrightHomePage(chromium_page)

def playwright_languages_page(chromium_page: Page) -> PlaywrightLanguagesPage:
    return PlaywrightLanguagesPage(chromium_page)

I will not explain how fixtures work and how to write fixtures in pytest, there is already a lot of information for this. I will only say that it is better to place the initialization of page objects inside fixtures in order to avoid duplication within the test.

import pytest

from pages.playwright_home_page import PlaywrightHomePage
from pages.playwright_languages_page import PlaywrightLanguagesPage
from settings import BASE_URL

class TestSearch:
    @pytest.mark.parametrize('keyword', ['python'])
    def test_search(
        keyword: str,
        playwright_home_page: PlaywrightHomePage,
        playwright_languages_page: PlaywrightLanguagesPage
            keyword, result_number=0


When using Page Object, Page Factory, as I described above, tests are written easily and clearly. And most importantly, it allows us to focus on testing the business logic of the product, and not on the nonsense of the type, how to write allure.step, how to write a check, or how do I dynamically substitute a parameter into the locator.


You can see the entire source code of the project on my github

The approach described above can be used not only with Playwright. It can be used with Selenium, Pylenium, PyPOM, Selene, whatever. The framework is just a tool and can be used in many different ways.

Similar Posts

Leave a Reply