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:
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:
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:
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.
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, soBrowser.__local.driveris a different driver in every thread.shutdown()quits the thread's browser. The nextget_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:
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()
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.