Tutorials / Selenium WebDriver series

Part 1: One Selenium WebDriver per thread

In this series we build a small but complete UI test framework with Test Junkie and Selenium WebDriver, and test a real website with it: this one. Part 1 is the base everything else stands on: one place to get a browser, with a separate browser for every thread so tests can run in parallel.

Set up the project

Install the two packages. Selenium 4.6 and later find (and if needed download) the right browser driver by themselves, so there's nothing else to install:

terminalbash
pip install test-junkie selenium

Create this layout; every folder with an __init__.py is a package, so the files can import each other with from src.pages.Browser import Browser. Each file is filled in by the part listed next to it:

project layouttext
selenium_demo/
    tj.cfg                        part 4
    src/
        __init__.py
        utils/
            __init__.py
            Settings.py           part 1
        pages/
            __init__.py
            Browser.py            part 1
            UiObject.py           part 2
            BasePage.py           part 3
            HomePage.py           part 3 (and the other pages)
            DocumentationPage.py  part 3
            components/
                __init__.py
                PageHeader.py     part 3
                PageFooter.py     part 3
    tests/
        __init__.py
        TestRules.py              part 5
        suites/
            __init__.py
            NavigationSuite.py    part 4
            DocumentationSuite.py part 4

Settings

The site under test comes from an environment variable, so the same tests run against production, staging or a local server:

src/utils/Settings.pypython
import os


class Settings:

    # the site under test - point it at staging, a local server or production without touching the tests
    DOMAIN = os.environ.get("SITE_URL", "https://www.test-junkie.com").rstrip("/")
    HEADLESS = os.environ.get("HEADLESS", "1") == "1"

The Browser class

From now on Browser.get_driver() is the only way any test or page object gets a driver. The first call in a thread starts a browser; later calls in the same thread get the same one. A different thread gets its own. That is what makes the framework safe for Test Junkie's parallel test threads: page objects simply ask for “the driver” and always get the one that belongs to the test running in their thread.

src/pages/Browser.pypython
import threading

from selenium import webdriver

from src.utils.Settings import Settings


class Browser:
    """
    The only way tests and page objects get a driver. Each thread gets its own browser, so the same page
    objects work when Test Junkie runs tests in parallel threads.
    """

    __local = threading.local()

    @staticmethod
    def __chrome(**kwargs):
        options = webdriver.ChromeOptions()
        if kwargs.get("headless", Settings.HEADLESS):
            options.add_argument("--headless=new")
        options.add_argument("--window-size={}".format(kwargs.get("window_size", "1400,1000")))
        if kwargs.get("no_sandbox"):  # needed in most Docker containers
            options.add_argument("--no-sandbox")
            options.add_argument("--disable-dev-shm-usage")
        # any other options you want to support
        options.set_capability("goog:loggingPrefs", {"browser": "ALL"})  # for the JS error check in part 5
        # Selenium 4.6+ finds (and downloads, if needed) the right chromedriver itself
        driver = webdriver.Chrome(options=options)
        driver.set_page_load_timeout(kwargs.get("page_load_timeout", 20))
        return driver

    @staticmethod
    def get_driver(**kwargs):
        # the first call in a thread starts a browser, later calls in that thread get the same one
        driver = getattr(Browser.__local, "driver", None)
        if driver is None:
            driver = Browser.__local.driver = Browser.__chrome(**kwargs)
        return driver

    @staticmethod
    def is_open():
        return getattr(Browser.__local, "driver", None) is not None

    @staticmethod
    def shutdown():
        # closes this thread's browser; the next get_driver() call in the thread starts a new one
        driver = getattr(Browser.__local, "driver", None)
        Browser.__local.driver = None
        if driver is not None:
            driver.quit()

    @staticmethod
    def back():
        Browser.get_driver().back()

    @staticmethod
    def forward():
        Browser.get_driver().forward()
    # everything else that controls the browser (tabs, cookies, navigation) goes into this class too
  • threading.local() holds one attribute per thread, so Browser.__local.driver is a different driver in every thread.
  • shutdown() quits the thread's browser. The next get_driver() call in that thread starts a new one, which is exactly what we want between tests.
  • The logging preference keeps the browser console readable for part 5, where we fail tests on JavaScript errors.

Try it

Three threads, each visiting two pages, without ever passing a driver around:

demo_part1.pypython
import threading

from src.pages.Browser import Browser
from src.utils.Settings import Settings


def visit(pages):
    driver = Browser.get_driver()  # this thread's own browser
    for page in pages:
        driver.get(Settings.DOMAIN + page)
        print("{:<10} {}".format(threading.current_thread().name, driver.title))
    Browser.shutdown()


page_sets = [["/", "/get-started/"],
             ["/documentation/", "/tutorials/"],
             ["/about/", "/"]]

threads = [threading.Thread(target=visit, args=(pages,), name="thread-{}".format(i + 1))
           for i, pages in enumerate(page_sets)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
python demo_part1.pytext
thread-3   About Test Junkie, the Python Test Runner
thread-1   Test Junkie: the Python test runner built for precision
thread-2   Test Junkie Documentation: Decorators, Runner, CLI, Reports
thread-1   Get Started with Test Junkie: Install and Run a Suite
thread-2   Test Junkie Tutorials: Parallel Tests, Retries, Selenium, CI
thread-3   Test Junkie: the Python test runner built for precision

Each thread only ever sees its own pages, in its own browser. Set HEADLESS=0 to watch three browser windows work side by side.