Share the browser between Playwright and Selenium tests
Launching a browser for every test is slow. Use pytest-playwright's session-scoped browser with a new context per test, or a session-scoped Selenium driver, and a separate CI job.
Browser tests are the slowest tests most suites have. Some of that is unavoidable: the browser has to load and render pages. But a large part is often overhead the test doesn't need: launching a new browser process for every test, and logging in through the UI before every test.
Launching Chromium or Firefox takes from a few hundred milliseconds to seconds, depending on the machine. A suite of 200 browser tests that each launch their own browser pays that 200 times. A fresh context (Playwright) or a cleared session (Selenium) gives the same isolation for a fraction of the cost.
Why it's slow
- A browser per test. A function-scoped fixture that calls
playwright.chromium.launch()orwebdriver.Chrome()starts and stops a whole browser process for every test. - Logging in through the UI in every test: loading the login page, filling the form, waiting for the redirect.
- Fixed waits, like
time.sleep(2)orpage.wait_for_timeout(2000), instead of waiting for the element or state the test needs. See tests are sleeping. - Testing logic through the browser that a unit or view test could check in milliseconds.
Playwright: one browser, a new context per test
pytest-playwright already does the right thing if you use its fixtures. browser (along with playwright and browser_type) is session-scoped: one browser for the whole run. context and page are function-scoped: each test gets a new browser context, with its own cookies, local storage and cache, and a new page in it.
# Before: a new browser for every test
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture
def page():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
yield page
browser.close()
# After: delete the fixture above and use pytest-playwright's `page`
from playwright.sync_api import Page, expect
def test_signup(page: Page):
page.goto("/signup/") # relative to --base-url
page.get_by_label("Email").fill("[email protected]")
page.get_by_role("button", name="Sign up").click()
expect(page.get_by_text("Check your inbox")).to_be_visible()
Configure the browser with fixtures and command-line options instead of launching it yourself: browser_type_launch_args for launch options, browser_context_args for context options (viewport, locale, base_url), and --browser, --headed, --base-url and --tracing retain-on-failure on the command line.
Log in once, reuse the storage state
Save the cookies and local storage of a logged-in session once, and start every context from it:
import pytest
@pytest.fixture(scope="session")
def auth_state(browser, tmp_path_factory, base_url):
path = tmp_path_factory.mktemp("auth") / "state.json"
context = browser.new_context(base_url=base_url)
page = context.new_page()
page.goto("/login/")
page.get_by_label("Email").fill("[email protected]")
page.get_by_label("Password").fill("correct horse battery staple")
page.get_by_role("button", name="Log in").click()
page.wait_for_url("**/dashboard/")
context.storage_state(path=path)
context.close()
return path
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, auth_state):
return {**browser_context_args, "storage_state": auth_state}
This only works if the session stays valid for the whole run: the user and its session must not be deleted between tests. Keep the tests that are about logging in on a context without the stored state.
Selenium: a session-scoped driver
Selenium has no contexts, so share the driver and clean up between tests:
import pytest
from selenium import webdriver
@pytest.fixture(scope="session")
def browser():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
@pytest.fixture
def driver(browser, live_server):
browser.get(live_server.url)
yield browser
browser.delete_all_cookies()
browser.execute_script("window.localStorage.clear(); window.sessionStorage.clear();")
delete_all_cookies() and the storage calls only reach the domain of the page that's currently open, which is why the fixture opens the site first. If tests visit several domains, clear each one, or fall back to a new driver for those tests. For locating elements the way users do, see Selenium Testing Library.
Run browser tests in their own CI job
Even with a shared browser, browser tests are much slower than the rest. Mark them and give them their own job, so the fast tests report back first:
# conftest.py
import pytest
def pytest_collection_modifyitems(items):
for item in items:
if "page" in item.fixturenames or "driver" in item.fixturenames:
item.add_marker(pytest.mark.e2e)
$ pytest -m "not e2e" # the fast job
$ pytest -m e2e --numprocesses auto # the browser job, in parallel
Register the marker under markers in your pytest configuration. With pytest-xdist, each worker launches one browser and keeps it for its share of the tests. And move checks that don't need a browser (validation rules, permissions, calculations) into unit or view tests; more on that in end-to-end testing best practices.
Keep the tests correct
- A fresh browser context per test keeps cookies and storage isolated. Never share a
contextorpagebetween tests unless you reset them. - With a shared Selenium driver, state you don't clear (cookies on other domains, IndexedDB, open windows, service workers) leaks into the next test. Run the browser tests in random order now and then to catch it.
- A stored login state skips the login flow: keep at least one test that logs in through the UI.
How flakytest finds it
flakytest puts time spent in Selenium, Playwright, splinter and pyppeteer in the browser category. When browser automation takes more than 2 seconds and 5% of test time, it suggests Share the browser between tests, with the tests that spend the most time in the browser and the lines of your code behind it. The browser's own processes aren't profiled, so their work shows up as time the test spends waiting on the driver. flakytest report --test <test id> shows how much of one test is setup (launching, logging in) and how much is the test itself.
Further reading
- Playwright: pytest plugin reference
- Playwright: authentication and storage state
- Selenium: working with cookies
Related guides
- End-to-end testing best practices: Fast, reliable end-to-end tests: give every test a fresh tenant instead of cleaning the database, set up state through the API, use Playwright locators and auto-waiting.
- Expensive function-scoped pytest fixtures: A pytest fixture without a scope runs again for every test that uses it. Give expensive fixtures module or session scope, or split them into a shared part and a cheap reset.
- Run tests in parallel with pytest-xdist: Run pytest in parallel on every CPU with pytest-xdist and -n auto. Per-worker databases with pytest-django, what breaks in parallel, pytest-randomly, and the --dist modes.
- Tests are sleeping: replace time.sleep: Fixed time.sleep and asyncio.sleep calls make pytest suites slow and flaky. Poll for the condition, patch the sleep in retry and backoff code, and move the clock with time-machine.
Updated October 18, 2026 · Suggest a change