Playwright with Python, Java & .NET

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Playwright is one implementation with four language bindings. The browser automation, the locators, auto-waiting and web-first assertions behave the same in TypeScript, Python, Java and .NET, but the test runner around them does not: Node.js has Playwright Test, which bundles a runner, a config file, projects, sharding and reporters, while the other three languages plug Playwright into the runner their ecosystem already uses (pytest, JUnit or TestNG, NUnit, MSTest or xUnit). This page writes the same Bookshelf scenario in all three languages, explains the library mode versus test-runner mode split, and lists what you give up or do differently outside Node.js.

The pages of this section were written against Playwright 1.64.1 for Node.js and checked on 25 October 2026. The Python, Java and .NET examples on this page were run with the 1.63.0 bindings (Python playwright 1.63.0 with pytest-playwright 0.10.0, Maven com.microsoft.playwright:playwright 1.63.0, NuGet Microsoft.Playwright.* 1.63.0), because the language documentation at the check date still lists 1.63 as its newest release. Newer bindings were already published at that date: PyPI playwright 1.64.1, Maven Central 1.64.0 and NuGet 1.64.1. Everything below applies to them as well, but it was not re-run on 1.64. Every example ran against the Bookshelf application on a local port, with the reader’s password read from the BOOKSHELF_READER_PASSWORD environment variable. The page-object and fixture ideas used here are explained in Test Design & Page Objects; the Node.js equivalent of every example is in Playwright: Getting Started.

The scenario every language runs

Two tests, the same in every binding:

  1. Search finds a book. Open /books, type quiet into the search box, press Search, and expect exactly one book card whose heading is "The Quiet Compiler".

  2. A reader adds a review. Sign in as reader (password from BOOKSHELF_READER_PASSWORD), expect to land on /account, open /books/3, choose a rating of 4, write a review, submit it, and expect the new review row to reach data-state="saved".

Each test starts from known data by calling POST /api/test/reset through Playwright’s own HTTP client before the test runs, so no test depends on what the previous one left behind. In the reviews test this matters: without the reset, a second run finds two matching review rows (the saved one and the optimistic pending one) and the locator violates strict mode.

Packages, runners and what each one gives you

Language Packages Recommended test runner What the runner integration provides

Python

playwright (the library) and pytest-playwright (the pytest plugin); pytest-playwright-asyncio for async tests

pytest

page, context, browser fixtures; command line flags for browser, headed mode, tracing, video and screenshots; parallel runs with pytest-xdist; one browser configuration per --browser flag.

Java

com.microsoft.playwright:playwright (a single Maven module that downloads the driver and the browsers on first use)

Any: JUnit 5 and TestNG are documented; JUnit also has the experimental @UsePlaywright fixtures

Plain library objects you create yourself, or (JUnit only) injected Page, BrowserContext, Browser, Playwright and APIRequestContext parameters configured through an OptionsFactory.

.NET

Microsoft.Playwright (library), Microsoft.Playwright.NUnit, Microsoft.Playwright.MSTest, Microsoft.Playwright.MSTest.v4, Microsoft.Playwright.Xunit, Microsoft.Playwright.Xunit.v3

NUnit, MSTest and xUnit, through base classes

PageTest, ContextTest, BrowserTest and PlaywrightTest base classes, .runsettings options, the BROWSER, HEADED and PWDEBUG environment variables.

Node.js (for comparison)

@playwright/test (runner) or playwright (library)

Playwright Test

Everything: config file, projects, workers, sharding, retries, reporters, UI Mode, snapshots.

The official language overview states that all core features for automating the browser are supported in all languages, while the testing ecosystem integration differs, and recommends choosing the language by your team’s experience and project constraints, with the runner recommended for that language.

Library mode and test-runner mode

The same split exists in Node.js and is the key to reading the other languages' documentation. Library mode means you call the automation API directly: you pick a browser type, launch it, create a context and a page, run your steps and close everything yourself. Test-runner mode means a runner owns that lifecycle and hands your test a ready page.

Concern Library (any language) Test runner

Installation

pip install playwright, one Maven dependency, or dotnet add package Microsoft.Playwright; browsers installed separately

Runner package (pytest-playwright, Microsoft.Playwright.NUnit, …​) that depends on the library

Browser, context, page

You launch the browser, call new_context and new_page (or the Java and .NET equivalents) and pass context options such as the base URL and device yourself

The runner creates an isolated context and page for each test, lazily, and applies the options from flags, configuration or overridable methods

Assertions

The language’s own assertions, or the retrying web-first helpers that the Python, Java and .NET libraries also ship (expect, assertThat and Expect); the plain Node.js library has no web-first assertions

The same retrying web-first assertions, plus the runner’s assertion reporting

Timeouts

Actions default to 30 s

Actions follow the runner’s settings and every test also has its own timeout (Playwright Test, 30 s by default)

Cleanup

You close contexts and browsers (in Python and Java with context managers or try-with-resources)

The runner closes the fixtures

Running

You run a script (python script.py, a main method)

You run the runner (pytest, mvn test, dotnet test)

Use test-runner mode for end-to-end testing. Library mode is the right tool for scripts that are not tests: scraping, generating screenshots or PDFs, warming a cache, or a custom harness. The Python examples below include a library-mode script in both the sync and the async flavor; the Java TracingTest and the Java plain-library test show that in Java the line between the two is thin, because the Java documentation recommends building your own @BeforeAll and @BeforeEach fixtures around the library when you do not use the experimental JUnit integration.

Python with pytest-playwright

Installing

Python needs a supported interpreter. The Python documentation lists "Python 3.8 or higher", but the PyPI metadata of the 1.6x releases requires Python 3.10 or later, and the macOS system Python 3.9 of the machine used here was too old. The runs below used Python 3.13 in a virtual environment.

python3 -m venv .venv
.venv/bin/pip install pytest-playwright==0.10.0 playwright==1.63.0
.venv/bin/playwright install chromium webkit   # download the browsers
.venv/bin/playwright --version
# Version 1.63.0

pip install pytest-playwright pulls in playwright and python-slugify; the plugin then provides the --base-url option itself in the 0.10 release. playwright install with no arguments downloads Chromium, Firefox and WebKit; name the engines you need to save time and disk space, add --with-deps on a fresh Linux image to install the operating-system libraries as well, and use --dry-run to print what would be downloaded and where. Browsers are cached per user (~/Library/Caches/ms-playwright on macOS) and shared between projects that use the same Playwright version. Environment variables change the behaviour: PLAYWRIGHT_BROWSERS_PATH moves the cache, PLAYWRIGHT_DOWNLOAD_HOST downloads from an internal mirror, HTTPS_PROXY sends downloads through a proxy and PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD skips the download.

$ .venv/bin/playwright install --dry-run chromium | head -4
Chrome for Testing 153.0.8010.12 (playwright chromium v1243)
  Install location:    /Users/me/Library/Caches/ms-playwright/chromium-1243
  Download url:        https://cdn.playwright.dev/builds/cft/153.0.8010.12/mac-arm64/chrome-mac-arm64.zip

The Bookshelf test

The plugin needs no configuration to run: a test function that asks for the page fixture gets a new page in a new context. The base URL is the only setting the tests need, and it goes in pytest.ini so that a plain pytest works (the command line can override it with --base-url).

# pytest.ini
[pytest]
addopts = --base-url=http://localhost:3920 --output=test-results
testpaths = tests
# tests/conftest.py
import pytest
from playwright.sync_api import APIRequestContext, Playwright, expect


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    # Extends the options the plugin builds from the CLI flags (--device, --base-url, ...).
    return {**browser_context_args, "locale": "en-GB"}


@pytest.fixture(autouse=True)
def reset_data(playwright: Playwright, base_url: str):
    # Arrange through the HTTP API instead of the UI, as in the other examples.
    request: APIRequestContext = playwright.request.new_context(base_url=base_url)
    request.post("/api/test/reset")
    request.dispose()


def pytest_configure():
    expect.set_options(timeout=5_000)
# tests/test_bookshelf.py
import os
import re

import pytest
from playwright.sync_api import Page, expect

READER_PASSWORD = os.environ["BOOKSHELF_READER_PASSWORD"]


def test_search_finds_a_book(page: Page):
    page.goto("/books")
    page.get_by_test_id("search-input").fill("quiet")
    page.get_by_role("button", name="Search").click()
    expect(page.get_by_test_id("book-card")).to_have_count(1)
    expect(page.get_by_role("heading", name="The Quiet Compiler")).to_be_visible()


def test_reader_adds_a_review(page: Page):
    page.goto("/signin")
    page.get_by_label("Username").fill("reader")
    page.get_by_label("Password").fill(READER_PASSWORD)
    page.get_by_test_id("signin-submit").click()
    expect(page).to_have_url(re.compile(r"/account$"))

    page.goto("/books/3")
    page.get_by_label("Rating").select_option("4")
    page.get_by_label("Your review").fill("A lovely, detailed field guide.")
    page.get_by_role("button", name="Submit review").click()

    review = page.get_by_test_id("review").filter(has_text="lovely")
    expect(review).to_have_attribute("data-state", "saved")


@pytest.mark.browser_context_args(timezone_id="Europe/Berlin", locale="de-DE")
def test_context_args_marker(page: Page):
    page.goto("/")
    assert page.evaluate("navigator.language") == "de-DE"
    assert page.evaluate("Intl.DateTimeFormat().resolvedOptions().timeZone") == "Europe/Berlin"


def test_two_users_with_new_context(page: Page, new_context):
    other = new_context()
    other_page = other.new_page()
    page.goto("/signin")
    other_page.goto("/signin")
    assert page.context is not other_page.context
$ export BOOKSHELF_READER_PASSWORD=...   # the reader's password; never commit it
$ .venv/bin/pytest -v
collected 4 items

tests/test_bookshelf.py::test_search_finds_a_book[chromium] PASSED       [ 25%]
tests/test_bookshelf.py::test_reader_adds_a_review[chromium] PASSED      [ 50%]
tests/test_bookshelf.py::test_context_args_marker[chromium] PASSED       [ 75%]
tests/test_bookshelf.py::test_two_users_with_new_context[chromium] PASSED [100%]

============================== 4 passed in 1.06s ===============================

Notes on the code:

  • The [chromium] suffix is added by the plugin: the browser is a test parameter, so one test function becomes one test per --browser value.

  • browser_context_args is a session-scoped fixture that returns the keyword arguments of browser.new_context(). Override it in conftest.py and extend the dictionary you receive ({**browser_context_args, ...}) so the options the plugin derived from the command line, such as the base URL or --device, survive.

  • The browser_context_args marker overrides the same options for a single test. The official snippet for this marker asserts the time zone against navigator.userAgent; the test above checks navigator.language and the resolved time zone instead.

  • new_context is a factory fixture for extra contexts (a second user, for example). Contexts it creates are closed by the plugin at the end of the test.

  • playwright.request.new_context(...) is the API-testing client, used here to reset the data without a browser.

  • expect.set_options(timeout=...) sets the default timeout of the retrying assertions for the whole run (5 s is the default). Hooking it into pytest_configure keeps it out of the individual tests.

Command line flags

The plugin adds these options to pytest. They configure the built-in browser, context and page fixtures only: a browser or context you create yourself with browser.new_context() is not affected.

Flag Effect Default

--browser

Engine to run: chromium, firefox or webkit. Repeat the flag to run every test on several engines.

chromium

--headed

Show the browser window.

headless

--browser-channel

Branded browser to use, for example chrome or msedge.

none (bundled engine)

--slowmo

Slow every Playwright operation down by this many milliseconds.

0

--device

Emulate a device from Playwright’s device list, for example "iPhone 11 Pro".

none

--output

Directory for artifacts produced by tests.

test-results

--tracing

Record a trace per test: on, off or retain-on-failure.

off

--video

Record a video per test: on, off or retain-on-failure.

off

--screenshot

Take a screenshot after each test: on, off or only-on-failure.

off

--full-page-screenshot

Capture the full page instead of the viewport; needs --screenshot.

off

--base-url

Base URL of the application; relative goto calls resolve against it.

none

--playwright-debug

Appears in pytest --help of the 0.10 plugin (value cli): enables Playwright CLI debugging and requires -s. It is not described in the Python documentation, so treat it as experimental.

off

Putting flags in addopts of pytest.ini applies them to every run, as the documentation shows (addopts = --headed --browser firefox). The following run used Chromium and WebKit together with all artifacts on:

$ .venv/bin/pytest --browser chromium --browser webkit --tracing on --video on --screenshot on -v
...
tests/test_bookshelf.py::test_search_finds_a_book[chromium] PASSED       [ 12%]
...
tests/test_bookshelf.py::test_two_users_with_new_context[webkit] PASSED  [100%]

============================== 8 passed in 5.95s ===============================

$ find test-results/tests-test-bookshelf-py-test-reader-adds-a-review-chromium -type f
.../video.webm
.../test-finished-1.png
.../trace-1.zip
.../trace-2.zip

With --tracing on the plugin writes one numbered trace per browser context the test used (the second one here comes from the API-request context of the reset fixture). The failure-only modes keep a single file for a failing test. This run used the deliberately failing test expect(page.get_by_test_id("book-card")).to_have_count(7, timeout=1000):

$ .venv/bin/pytest failing --tracing retain-on-failure --screenshot only-on-failure \
    --video retain-on-failure --slowmo 100
...
E       AssertionError: Locator expected to have count '7'
E       Actual value: 6
E       Call log:
E         - Expect "to_have_count" get_by_test_id("book-card") with timeout 1000ms
E         - waiting for get_by_test_id("book-card")
E           11 × locator resolved to 6 elements
E              - unexpected value "6"
...
FAILED failing/test_fail.py::test_wrong_count[chromium]

$ find test-results -type f
test-results/failing-test-fail-py-test-wrong-count-chromium/test-failed-1.png
test-results/failing-test-fail-py-test-wrong-count-chromium/video.webm
test-results/failing-test-fail-py-test-wrong-count-chromium/trace.zip

Open a trace with playwright show-trace test-results/.../trace.zip or upload it to the hosted viewer at https://trace.playwright.dev. The viewer itself is explained in Debugging, Tracing & Codegen. The command was not run here, because it opens a window. --headed was not run for the same reason; it behaves as in the other languages.

Fixtures

Fixture Scope What you get

page

function

A new page in its own new context. The workhorse of almost every test.

context

function

A new browser context; the page fixture belongs to this context.

new_context

function

A factory returning more contexts, with the same arguments as browser.new_context(); useful for multi-user tests.

playwright

session

The Playwright instance (here it also provides playwright.request for API calls and playwright.devices).

browser_type, browser

session

The BrowserType of the current parameter and the browser launched from it, shared across tests.

browser_name, browser_channel

session

The engine and channel as strings.

is_chromium, is_firefox, is_webkit

session

Booleans for conditional logic in a test or fixture.

browser_type_launch_args

session

Override to change the arguments of browser_type.launch(); return a dictionary.

browser_context_args

session

Override to change the arguments of browser.new_context().

connect_options

session

Return the options of browser_type.connect() to run against a remote browser server (for example a wsEndpoint).

Two markers help with browser matrices: @pytest.mark.skip_browser("firefox") skips a test on one engine and @pytest.mark.only_browser("chromium") restricts it to one. Plugin-supplied --base-url comes from the base_url fixture, so other fixtures (like reset_data above) can depend on it.

Parallel runs, unittest and debugging

Tests run one after the other in a single process by default. Install pytest-xdist and pass --numprocesses (-n) to run them in several processes; the Python documentation suggests a number between 2 and the number of CPUs, and half the logical cores for the running-tests guide. Each worker launches its own browser:

$ .venv/bin/pip install pytest-xdist
$ .venv/bin/pytest tests -n 2
2 workers [4 items]

....                                                                     [100%]
============================== 4 passed in 1.51s ===============================

The data-reset fixture above runs once per test in every worker, so two workers that reset the same Bookshelf can overwrite each other’s data. For parallel runs, give each worker its own data (for example with different books, users or a per-worker server) rather than one global reset; the isolation techniques are covered in Authentication & Isolation and Parallelism, Sharding & Retries.

Further points from the plugin reference, documented but not run here: unittest.TestCase classes can use the fixtures through an autouse pytest fixture that stores the page on the instance, with the limitation that only one browser can be specified; --browser-channel chrome runs against an installed Chrome; breakpoint() in a test opens a pdb prompt; and PWDEBUG=1 pytest -s -k test_name opens the Playwright Inspector.

Async tests

The default plugin uses the sync API (playwright.sync_api), which looks like ordinary blocking code. If your tests already live in an asyncio application, use the async API (playwright.async_api), install pytest-playwright-asyncio (which replaces the sync fixtures with async ones) and pytest-asyncio 0.26 or later, and set the session loop scope. The sync API cannot run inside a running event loop, so keep the two flavors in separate test suites (the plugin documentation shows pytest-playwright-asyncio as the way to get async fixtures). The async variant was run in its own virtual environment (pytest-asyncio 1.4.0):

# async_tests/pytest.ini
[pytest]
asyncio_default_test_loop_scope = session
asyncio_default_fixture_loop_scope = session
addopts = --base-url=http://localhost:3920 --output=test-results
# async_tests/conftest.py
import pytest_asyncio
from playwright.async_api import Playwright


@pytest_asyncio.fixture(autouse=True, loop_scope="session")
async def reset_data(playwright: Playwright, base_url: str):
    request = await playwright.request.new_context(base_url=base_url)
    await request.post("/api/test/reset")
    await request.dispose()
# async_tests/test_async.py
import os
import re

import pytest
from playwright.async_api import Page, expect


@pytest.mark.asyncio(loop_scope="session")
async def test_reader_adds_a_review(page: Page):
    await page.goto("/signin")
    await page.get_by_label("Username").fill("reader")
    await page.get_by_label("Password").fill(os.environ["BOOKSHELF_READER_PASSWORD"])
    await page.get_by_test_id("signin-submit").click()
    await expect(page).to_have_url(re.compile(r"/account$"))

    await page.goto("/books/3")
    await page.get_by_label("Rating").select_option("4")
    await page.get_by_label("Your review").fill("A lovely, detailed field guide.")
    await page.get_by_role("button", name="Submit review").click()
    await expect(page.get_by_test_id("review").filter(has_text="lovely")).to_have_attribute("data-state", "saved")
$ ../.venv-async/bin/pytest -v
test_async.py::test_reader_adds_a_review[chromium] PASSED                [100%]
============================== 1 passed in 0.64s ===============================

Library mode in Python

Without pytest, use the sync context manager for scripts, or the async one inside an event loop. Both were run, and both printed the same line:

# library_sync.py
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(base_url="http://localhost:3920")
    page = context.new_page()
    page.goto("/books")
    page.get_by_test_id("search-input").fill("quiet")
    page.get_by_role("button", name="Search").click()
    page.wait_for_selector("[data-testid=book-card]")
    print(page.get_by_test_id("book-card").count(), "book found:", page.get_by_test_id("book-title").first.text_content())
    context.close()
    browser.close()
# library_async.py
import asyncio

from playwright.async_api import async_playwright


async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page(base_url="http://localhost:3920")
        await page.goto("/books")
        await page.get_by_test_id("search-input").fill("quiet")
        await page.get_by_role("button", name="Search").click()
        await page.get_by_test_id("book-card").first.wait_for()
        print(await page.get_by_test_id("book-card").count(), "book found:", await page.get_by_test_id("book-title").first.text_content())
        await browser.close()


asyncio.run(main())
1 book found: The Quiet Compiler

In library mode you control tracing yourself. Since 1.63 tracing.start() also accepts aria_snapshots and screen_snapshots (see the section on recent changes below). This script ran and produced a 238 KB trace:

context.tracing.start(snapshots=True, aria_snapshots=True, screen_snapshots=True, sources=True)
page = context.new_page()
page.goto("/books")
page.get_by_test_id("book-card").first.click()
context.tracing.stop(path="test-results/library-trace.zip")

Java

Installing

Playwright for Java is a set of Maven modules; adding the playwright artifact to the project is enough, because the first Playwright.create() call extracts the driver and downloads the browsers it does not find (the Java documentation: "Running it downloads the Playwright package and installs browser binaries for Chromium, Firefox and WebKit"). The documented requirement is Java 8 or later; the runs below used JDK 17, JUnit Jupiter 5.14.4 and Surefire 3.5.4.

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>demo</groupId>
  <artifactId>bookshelf-playwright</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <playwright.version>1.63.0</playwright.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.microsoft.playwright</groupId>
      <artifactId>playwright</artifactId>
      <version>${playwright.version}</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.14.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
      </plugin>
    </plugins>
  </build>
</project>

To install the browsers (and system dependencies) explicitly, for example in a CI image build step, run the bundled CLI class. With the documented dependency in the default (compile) scope the command is the one from the official guide. Because the pom above uses test scope, Maven’s exec:java also needs -D exec.classpathScope=test; without it the run fails because the CLI class is not on the classpath:

mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install"
# test-scoped dependency:
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.classpathScope=test \
  -D exec.args="install --with-deps chromium"
# the same CLI prints what it would do:
mvn -q exec:java -D exec.mainClass=com.microsoft.playwright.CLI -D exec.classpathScope=test \
  -D exec.args="install --dry-run chromium"

The install --dry-run chromium form was run and printed the same download plan as the Python CLI. The other two commands were not run, because the browsers were already installed. With Gradle, the documentation adds a JavaExec task for the CLI (./gradlew playwright --args="help"):

// build.gradle
plugins {
  application
  id 'java'
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'com.microsoft.playwright:playwright:1.63.0'
}

application {
  mainClass = 'org.example.App'
}

// Usage: ./gradlew playwright --args="help"
task playwright(type: JavaExec) {
  classpath sourceSets.test.runtimeClasspath
  mainClass = 'com.microsoft.playwright.CLI'
}

test {
  useJUnitPlatform()
}

The Gradle build was not run here (the documented build file is shown as published). Environment variables work as in the other languages: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 stops the automatic download, and Playwright.create(new Playwright.CreateOptions().setEnv(...)) passes variables to the driver for a single instance.

Plain library with JUnit 5 lifecycle methods

The Java documentation does not ship a runner of its own; the recommended structure for JUnit 5 is one Playwright and one Browser for the class, created in @BeforeAll and closed in @AfterAll, and one BrowserContext with its Page per test, created in @BeforeEach and closed in @AfterEach. This is the Bookshelf test in that shape:

package demo;

import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;

import com.microsoft.playwright.*;
import com.microsoft.playwright.options.AriaRole;
import java.util.regex.Pattern;
import org.junit.jupiter.api.*;

class BookshelfPlainTest {
  static final String BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:3000");
  static final String READER_PASSWORD = System.getenv("BOOKSHELF_READER_PASSWORD");

  // Shared by every test in the class.
  static Playwright playwright;
  static Browser browser;
  // One new instance per test.
  BrowserContext context;
  Page page;

  @BeforeAll
  static void launchBrowser() {
    playwright = Playwright.create();
    browser = playwright.chromium().launch();
    APIRequestContext request = playwright.request().newContext(new APIRequest.NewContextOptions().setBaseURL(BASE_URL));
    request.post("/api/test/reset");
    request.dispose();
  }

  @AfterAll
  static void closeBrowser() {
    playwright.close();
  }

  @BeforeEach
  void createContextAndPage() {
    context = browser.newContext(new Browser.NewContextOptions().setBaseURL(BASE_URL));
    page = context.newPage();
  }

  @AfterEach
  void closeContext() {
    context.close();
  }

  @Test
  void searchFindsABook() {
    page.navigate("/books");
    page.getByTestId("search-input").fill("quiet");
    page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Search")).click();
    assertThat(page.getByTestId("book-card")).hasCount(1);
    assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("The Quiet Compiler"))).isVisible();
  }

  @Test
  void readerAddsAReview() {
    page.navigate("/signin");
    page.getByLabel("Username").fill("reader");
    page.getByLabel("Password").fill(READER_PASSWORD);
    page.getByTestId("signin-submit").click();
    assertThat(page).hasURL(Pattern.compile("/account$"));

    page.navigate("/books/3");
    page.getByLabel("Rating").selectOption("4");
    page.getByLabel("Your review").fill("A lovely, detailed field guide.");
    page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit review")).click();

    Locator review = page.getByTestId("review").filter(new Locator.FilterOptions().setHasText("lovely"));
    assertThat(review).hasAttribute("data-state", "saved");
  }
}

The Java API has no default parameters, so optional arguments are builder objects: Page.GetByRoleOptions, Locator.FilterOptions, Browser.NewContextOptions. Enums are upper case (AriaRole.BUTTON). Assertions come from PlaywrightAssertions.assertThat(...), whose overloads cover Page, Locator and APIResponse; they retry until the condition holds or the timeout (5 s by default) expires, and a failure raises JUnit’s AssertionFailedError with the call log. PlaywrightAssertions.setDefaultAssertionTimeout(...) changes the default for the process. The class was run together with the one below:

$ export BOOKSHELF_READER_PASSWORD=... BASE_URL=http://localhost:3920
$ mvn -o test
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.059 s -- in demo.BookshelfPlainTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.030 s -- in demo.BookshelfJUnitTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.425 s -- in demo.TracingTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

A failing assertion (hasCount(7, new LocatorAssertions.HasCountOptions().setTimeout(1000)) on a page with six cards) produced:

org.opentest4j.AssertionFailedError:
Locator expected to have count
Expected: 7
Received: 6

Call log:
  - Assert "hasCount" getByTestId("book-card") with timeout 1000ms
  - waiting for getByTestId("book-card")
    11 × locator resolved to 6 elements
       - unexpected value "6"

JUnit 5 with the experimental @UsePlaywright fixtures

Since Playwright for Java 1.42 an experimental JUnit extension injects the objects. Annotate the class with @UsePlaywright and declare Page, BrowserContext, Browser, Playwright or APIRequestContext as test-method parameters. The page and browserContext fixtures are isolated per test, the browser is shared across tests, and the playwright instance is shared between tests that run on the same thread. To customize them, implement OptionsFactory and pass the class to the annotation.

package demo;

import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;

import com.microsoft.playwright.*;
import com.microsoft.playwright.junit.Options;
import com.microsoft.playwright.junit.OptionsFactory;
import com.microsoft.playwright.junit.UsePlaywright;
import com.microsoft.playwright.options.AriaRole;
import java.nio.file.Paths;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;

@UsePlaywright(BookshelfJUnitTest.BookshelfOptions.class)
class BookshelfJUnitTest {
  static final String BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:3000");

  public static class BookshelfOptions implements OptionsFactory {
    @Override
    public Options getOptions() {
      return new Options()
          .setHeadless(true)
          .setBaseUrl(BASE_URL)
          .setApiRequestOptions(new APIRequest.NewContextOptions().setBaseURL(BASE_URL))
          .setTestIdAttribute("data-testid")
          .setTrace(Options.Trace.RETAIN_ON_FAILURE)
          .setOutputDir(Paths.get("target/playwright-output"));
    }
  }

  @Test
  void readerAddsAReview(Page page, APIRequestContext request) {
    request.post("/api/test/reset");

    page.navigate("/signin");
    page.getByLabel("Username").fill("reader");
    page.getByLabel("Password").fill(System.getenv("BOOKSHELF_READER_PASSWORD"));
    page.getByTestId("signin-submit").click();
    assertThat(page).hasURL(Pattern.compile("/account$"));

    page.navigate("/books/3");
    page.getByLabel("Rating").selectOption("4");
    page.getByLabel("Your review").fill("A lovely, detailed field guide.");
    page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit review")).click();
    assertThat(page.getByTestId("review").filter(new Locator.FilterOptions().setHasText("lovely")))
        .hasAttribute("data-state", "saved");
  }

  @Test
  void searchFindsABook(Page page) {
    page.navigate("/books");
    page.getByTestId("search-input").fill("quiet");
    page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Search")).click();
    assertThat(page.getByTestId("book-card")).hasCount(1);
  }
}

The members of Options in the 1.63.0 jar, which were listed with javap:

Setter Effect

setHeadless, setChannel, setBrowserName

Run headed or headless, pick a branded channel, pick chromium, firefox or webkit.

setBaseUrl

Base URL of the injected browser contexts (and so of page.navigate("/path")).

setApiRequestOptions

Options for the injected APIRequestContext; set its own setBaseURL.

setContextOptions, setLaunchOptions

Full Browser.NewContextOptions and BrowserType.LaunchOptions objects for anything not covered above.

setDeviceName, setIgnoreHTTPSErrors, setTestIdAttribute

Emulate a device, ignore certificate errors, change the attribute used by getByTestId.

setPlaywrightCreateOptions, setWsEndpoint, setConnectOptions

Options for Playwright.create(), and the remote-browser connection settings.

setTrace, setOutputDir

Options.Trace.OFF, ON or RETAIN_ON_FAILURE, and the directory that receives the traces.

Three differences from the published documentation showed up when running this against 1.63.0:

  • The documentation example calls setContextOption(...). The class has setContextOptions(...) (plural), so the documented snippet does not compile as written.

  • setBaseUrl(...) applies to the injected browser contexts only. The injected APIRequestContext had no base URL until setApiRequestOptions(...) was added; request.post("/api/test/reset") otherwise failed with a TypeError: Invalid URL from the driver.

  • With Options.Trace.RETAIN_ON_FAILURE a passing test leaves nothing behind; a failing one left target/playwright-output/demo.BookshelfJUnitTest.readerAddsAReview-chromium/trace.zip.

The extension is still labelled experimental in the documentation at the check date, as it has been since its introduction in 1.42, so its API may change; the plain-library structure above does not have that risk.

Tracing in Java

Outside the experimental extension, tracing is explicit. This test ran:

@Test
void recordsATrace() {
  try (Playwright playwright = Playwright.create()) {
    Browser browser = playwright.chromium().launch();
    BrowserContext context = browser.newContext(new Browser.NewContextOptions().setBaseURL(BASE_URL));
    // Tracing is explicit in library mode: start before the actions, stop with a path to keep it.
    context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true).setSources(true));
    Page page = context.newPage();
    page.navigate("/books");
    assertThat(page.getByTestId("book-card")).hasCount(6);
    context.tracing().stop(new Tracing.StopOptions().setPath(Paths.get("target/traces/books.zip")));
    browser.close();
  }
}

Open the trace with the bundled CLI: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace target/traces/books.zip". That command opens a window and was not run.

Running tests in parallel with JUnit 5

JUnit runs tests sequentially on one thread by default. Playwright objects are not thread-safe, so the Java documentation recommends one Playwright instance per thread, used only on that thread. With @UsePlaywright the extension does this for you. With the plain structure, annotate the base class @TestInstance(TestInstance.Lifecycle.PER_CLASS) and keep Playwright and Browser in instance fields so that every test class has its own copy. Then let classes run concurrently but keep the methods of one class sequential:

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=0.5

This configuration was not run (the Bookshelf data-reset caveat from the Python section applies equally). TestNG is documented with the same structure using @BeforeClass, @AfterClass, @BeforeMethod and @AfterMethod; it was not run here.

Headed mode and debugging in Java

Pass new BrowserType.LaunchOptions().setHeadless(false) to launch(...) (or setHeadless(false) in the JUnit Options), optionally with .setSlowMo(50). PWDEBUG=1 mvn test opens the Inspector, and PLAYWRIGHT_JAVA_SRC lets the Inspector show the Java sources. Neither was run, because they open windows.

.NET

Installing

Playwright for .NET is a .NET Standard 2.0 library; the documentation recommends .NET 8, and the runs here used the .NET 10 SDK. Create a test project, add the package for your runner, build once so that the installation script is generated, then install the browsers:

dotnet new nunit -n PlaywrightTests          # or: mstest, xunit, xunit3
cd PlaywrightTests
dotnet add package Microsoft.Playwright.NUnit # or MSTest, MSTest.v4, Xunit, Xunit.v3
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install

Adjust net8.0 to the target framework of your project (net10.0 here). The packages and their namespaces:

Runner NuGet package Namespace of PageTest

NUnit

Microsoft.Playwright.NUnit

Microsoft.Playwright.NUnit

MSTest 3

Microsoft.Playwright.MSTest (with MSTest 3.x)

Microsoft.Playwright.MSTest

MSTest 4

Microsoft.Playwright.MSTest.v4 (with MSTest 4.x)

Microsoft.Playwright.MSTest (the same namespace; Microsoft.Playwright.MSTest.v4 does not exist)

xUnit 2.x

Microsoft.Playwright.Xunit

Microsoft.Playwright.Xunit

xUnit v3

Microsoft.Playwright.Xunit.v3

Microsoft.Playwright.Xunit.v3

The MSTest v4 and xUnit v3 packages were added in 1.55. Two things were learned by running all four runners with the 1.63.0 packages:

  • Microsoft.Playwright.MSTest 1.63.0 together with the current MSTest 4.0.2 meta-package built, but dotnet test discovered no tests. With MSTest 3.11.1 it ran normally, and Microsoft.Playwright.MSTest.v4 works with MSTest 4.0.2. Match the Playwright package to the major version of MSTest you use.

  • A project cannot be named like one of its dependencies: a project called Nunit failed to restore with a dependency cycle against the NUnit package (NU1108). The projects here are called NUnitTests, MSTestTests and so on.

If PowerShell is not available

playwright.ps1 is a PowerShell script, and the documentation says to install PowerShell if pwsh is missing. The script is a thin wrapper: the build also copies the Playwright driver, a Node.js runtime and the package into bin/Debug/net10.0/.playwright, so the same installation can be done by calling the bundled Node.js on the bundled cli.js. This is what was done on a macOS machine without pwsh:

$ bin/Debug/net10.0/.playwright/node/darwin-arm64/node \
    bin/Debug/net10.0/.playwright/package/cli.js install chromium
$ bin/Debug/net10.0/.playwright/node/darwin-arm64/node \
    bin/Debug/net10.0/.playwright/package/cli.js install --dry-run chromium | head -3
Chrome for Testing 153.0.8010.12 (playwright chromium v1243)
  Install location:    /Users/me/Library/Caches/ms-playwright/chromium-1243
  Download url:        https://cdn.playwright.dev/builds/cft/153.0.8010.12/mac-arm64/chrome-mac-arm64.zip

The folder under .playwright/node is named after the operating system and CPU of the machine (darwin-arm64 here), so adapt it on Linux and Windows. Every argument accepted by playwright.ps1 (install --with-deps, codegen, show-trace) can be passed to cli.js the same way. This is not an officially documented procedure, but it is the mechanism the script uses.

The Bookshelf test with NUnit

The base class gives every test a fresh page in its own browser context; override ContextOptions() to set the base URL and other context options. The project file is the generated NUnit one with the package set to Microsoft.Playwright.NUnit 1.63.0.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
    <PackageReference Include="Microsoft.Playwright.NUnit" Version="1.63.0" />
    <PackageReference Include="NUnit" Version="4.3.2" />
    <PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
    <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="NUnit.Framework" />
  </ItemGroup>
</Project>
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;

namespace Bookshelf.Tests;

[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class BookshelfTests : PageTest
{
    private static readonly string BaseUrl =
        Environment.GetEnvironmentVariable("BASE_URL") ?? "http://localhost:3000";

    private static readonly string ReaderPassword =
        Environment.GetEnvironmentVariable("BOOKSHELF_READER_PASSWORD")
        ?? throw new InvalidOperationException("Set BOOKSHELF_READER_PASSWORD");

    public override BrowserNewContextOptions ContextOptions() => new()
    {
        BaseURL = BaseUrl,
        Locale = "en-GB",
    };

    [SetUp]
    public async Task ResetData()
    {
        // Arrange through the HTTP API, not the UI.
        var request = await Playwright.APIRequest.NewContextAsync(new() { BaseURL = BaseUrl });
        await request.PostAsync("/api/test/reset");
        await request.DisposeAsync();
    }

    [Test]
    public async Task SearchFindsABook()
    {
        await Page.GotoAsync("/books");
        await Page.GetByTestId("search-input").FillAsync("quiet");
        await Page.GetByRole(AriaRole.Button, new() { Name = "Search" }).ClickAsync();
        await Expect(Page.GetByTestId("book-card")).ToHaveCountAsync(1);
        await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "The Quiet Compiler" })).ToBeVisibleAsync();
    }

    [Test]
    public async Task ReaderAddsAReview()
    {
        await Page.GotoAsync("/signin");
        await Page.GetByLabel("Username").FillAsync("reader");
        await Page.GetByLabel("Password").FillAsync(ReaderPassword);
        await Page.GetByTestId("signin-submit").ClickAsync();
        await Expect(Page).ToHaveURLAsync(new Regex("/account$"));

        await Page.GotoAsync("/books/3");
        await Page.GetByLabel("Rating").SelectOptionAsync("4");
        await Page.GetByLabel("Your review").FillAsync("A lovely, detailed field guide.");
        await Page.GetByRole(AriaRole.Button, new() { Name = "Submit review" }).ClickAsync();

        var review = Page.GetByTestId("review").Filter(new() { HasText = "lovely" });
        await Expect(review).ToHaveAttributeAsync("data-state", "saved");
    }
}
$ export BOOKSHELF_READER_PASSWORD=... BASE_URL=http://localhost:3920
$ dotnet test
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 2 s - NUnitTests.dll (net10.0)

Every I/O call is async and carries the Async suffix; options are passed as target-typed new() { ... } objects; locator methods such as GetByRole take the AriaRole enum. Expect(...) is a method of the base class and returns the assertion object (Expect(Page), Expect(locator)); assertion methods end in Async. The base classes are:

Base class What each test gets

PageTest

A fresh Page in its own BrowserContext; the simplest option and the one used above.

ContextTest

A fresh BrowserContext; create as many pages as the test needs (multi-tab scenarios).

BrowserTest

A Browser; the test creates and cleans up its own contexts.

PlaywrightTest

A Playwright object, so the test can start and stop as many browsers as it likes.

MSTest and xUnit

The scenario is the same. Only the attributes and the using for the base class change, so the projects that were run kept the test bodies in shared private helper methods and put the runner attributes on thin test methods. The differences:

Runner using for the base class Class attribute Test attribute Result

NUnit

Microsoft.Playwright.NUnit

[Parallelizable(ParallelScope.Self)], [TestFixture]

[Test], [SetUp] for per-test arrange code

2 passed

MSTest 3 (MSTest 3.11.1)

Microsoft.Playwright.MSTest

[TestClass]

[TestMethod]

2 passed

MSTest 4 (.v4 package, MSTest 4.0.2)

Microsoft.Playwright.MSTest

[TestClass]

[TestMethod]

2 passed

xUnit 2.9.3

Microsoft.Playwright.Xunit

none

[Fact]

2 passed

xUnit v3 (xunit.v3 3.2.2)

Microsoft.Playwright.Xunit.v3

none

[Fact]

2 passed

An xUnit v3 test project builds an executable, so its project file needs <OutputType>Exe</OutputType> and the xunit.v3 package:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="Microsoft.Playwright.Xunit.v3" Version="1.63.0" />
    <PackageReference Include="xunit.v3" Version="3.2.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
</Project>

An MSTest class looks like this (the xUnit ones differ in the attributes shown in the table):

using System.Text.RegularExpressions;
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;

namespace Bookshelf.Tests;

[TestClass]
public class BookshelfTests : PageTest
{
    public override BrowserNewContextOptions ContextOptions() => new()
    {
        BaseURL = Environment.GetEnvironmentVariable("BASE_URL") ?? "http://localhost:3000",
        Locale = "en-GB",
    };

    [TestMethod]
    public Task SearchFindsABook() => SearchFindsABookBody();

    [TestMethod]
    public Task ReaderAddsAReview() => ReaderAddsAReviewBody();

    // SearchFindsABookBody() and ReaderAddsAReviewBody() hold the steps shown for NUnit.
}

dotnet test flags, environment variables and .runsettings

Playwright’s .NET integration reads its configuration from environment variables, from Playwright.* parameters passed after --, and from a .runsettings file (used by the dotnet test --settings option and by Visual Studio).

Command or variable Effect

dotnet test

Run all tests on Chromium, headless.

BROWSER=webkit dotnet test

Choose chromium, firefox or webkit (any other value fails the tests with "Invalid browser name from 'BROWSER' environment variable").

HEADED=1 dotnet test

Run headed (PowerShell: $env:HEADED="1").

PWDEBUG=1 dotnet test

Open the Playwright Inspector.

dotnet test — Playwright.BrowserName=webkit

Same as BROWSER, through the launch configuration.

dotnet test — Playwright.LaunchOptions.Headless=false Playwright.LaunchOptions.Channel=msedge

Launch options from the command line.

dotnet test --settings:webkit.runsettings

Take every setting from a file; the documented way to run several browser configurations is one dotnet test per file.

dotnet test --filter "Name~GetStartedLink"

Run tests by name; --filter "ExampleTest1|ExampleTest2" runs several classes.

dotnet test — NUnit.NumberOfTestWorkers=5

Number of parallel workers (NUnit). Only ParallelScope.Self is supported.

dotnet test — MSTest.Parallelize.Workers=4

Workers for MSTest; parallelism is at class level (ExecutionScope.ClassLevel), not method level.

dotnet test — xUnit.MaxParallelThreads=5

Threads for xUnit and xUnit v3; the conservative algorithm is the default in xUnit 2.8 and later.

DEBUG=pw:api dotnet test

Verbose API log on standard error, shown in the test log of each test.

<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
  <NUnit>
    <NumberOfTestWorkers>2</NumberOfTestWorkers>
  </NUnit>
  <RunConfiguration>
    <EnvironmentVariables>
      <DEBUG>pw:api</DEBUG>
    </EnvironmentVariables>
  </RunConfiguration>
  <Playwright>
    <BrowserName>webkit</BrowserName>
    <ExpectTimeout>5000</ExpectTimeout>
    <LaunchOptions>
      <Headless>true</Headless>
    </LaunchOptions>
  </Playwright>
</RunSettings>
$ BROWSER=webkit dotnet test --no-build
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 1 s - NUnitTests.dll (net10.0)

$ dotnet test --no-build -- Playwright.BrowserName=webkit Playwright.LaunchOptions.Headless=true NUnit.NumberOfTestWorkers=2
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 1 s - NUnitTests.dll (net10.0)

$ dotnet test --no-build --settings bookshelf.runsettings --logger "console;verbosity=detailed"
2026-10-25T19:58:18.017Z pw:api → POST http://localhost:3920/api/test/reset
...
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 2 s - NUnitTests.dll (net10.0)

The .runsettings element names depend on the runner: NUnit with NumberOfTestWorkers, MSTest with Parallelize (Workers and Scope), xUnit with MaxParallelThreads. Browser selection, ExpectTimeout (the default timeout of Expect assertions) and LaunchOptions are shared by all of them. Per-test context options, in contrast, are set in code by overriding ContextOptions(), as above. HEADED=1, PWDEBUG=1 and a headed .runsettings were not run, because they open windows.

Tracing in .NET

The .NET base classes do not have a tracing switch (the Python plugin has --tracing, and Java’s @UsePlaywright has Options.setTrace). Start and stop it in [SetUp] and [TearDown] and keep the file only for failing tests. This class ran; the intentionally failing test is marked [Explicit] so a normal dotnet test skips it:

using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;

namespace Bookshelf.Tests;

[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class TracedTests : PageTest
{
    public override BrowserNewContextOptions ContextOptions() => new()
    {
        BaseURL = Environment.GetEnvironmentVariable("BASE_URL") ?? "http://localhost:3000",
    };

    [SetUp]
    public async Task StartTracing()
    {
        // The NUnit base classes have no tracing switch: start it per test.
        await Context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true, Sources = true });
    }

    [TearDown]
    public async Task StopTracing()
    {
        var failed = TestContext.CurrentContext.Result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed;
        await Context.Tracing.StopAsync(new()
        {
            // Keep the trace only for failing tests.
            Path = failed ? Path.Combine(TestContext.CurrentContext.WorkDirectory, "traces",
                $"{TestContext.CurrentContext.Test.Name}.zip") : null,
        });
    }

    [Test]
    public async Task CatalogueShowsSixBooksPerPage()
    {
        await Page.GotoAsync("/books");
        await Expect(Page.GetByTestId("book-card")).ToHaveCountAsync(6);
    }

    [Test, Explicit("Fails on purpose to show the kept trace")]
    public async Task FailingTestKeepsItsTrace()
    {
        await Page.GotoAsync("/books");
        await Expect(Page.GetByTestId("book-card")).ToHaveCountAsync(7, new() { Timeout = 1000 });
    }
}
$ dotnet test
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 957 ms - NUnitTests.dll (net10.0)
$ dotnet test --filter "Name=FailingTestKeepsItsTrace"
Failed!  - Failed:     1, Passed:     0, Skipped:     0, Total:     1
  Microsoft.Playwright.PlaywrightException : Locator expected to have count '7'
Call log:
  - waiting for GetByTestId("book-card")
    11 × locator resolved to 6 elements
       - unexpected value "6"
$ ls bin/Debug/net10.0/traces
FailingTestKeepsItsTrace.zip

A failing assertion raises PlaywrightException (not the framework’s assertion exception), with the same call log as the other languages. Open the trace with pwsh bin/Debug/net10.0/playwright.ps1 show-trace traces/FailingTestKeepsItsTrace.zip or the cli.js form above; that was not run because it opens a window.

What differs between the languages

The browser engine, auto-waiting, actionability checks, locators and retrying assertions are shared. What differs is the tooling around them:

Capability Node.js (Playwright Test) Python (pytest-playwright) Java .NET

Configuration file

playwright.config.ts

pytest.ini / conftest.py fixtures and CLI flags

Code: OptionsFactory, Browser.NewContextOptions, JUnit properties

.runsettings, ContextOptions() override, environment variables

Projects (browser or device matrix)

projects in the config

Repeat --browser; --device

Your own parameterization; no built-in matrix

One dotnet test per browser or settings file

Parallelism

workers, fullyParallel

pytest-xdist (-n)

JUnit parallel properties, one Playwright per thread

NUnit, MSTest and xUnit worker settings

Sharding across machines

--shard and the blob reporter

None built in; split the test list with pytest selection or a CI matrix

None built in; split with Surefire filters or a CI matrix

None built in; split with dotnet test --filter or a CI matrix

Retries

retries in config

Not part of the plugin; use a pytest plugin for reruns

Not part of Playwright; use the runner’s rerun support

Not part of Playwright; use the runner’s retry feature

Reporters

HTML, JSON, JUnit, blob, list, custom

pytest’s own reporting and plugins

Surefire and JUnit XML reports

dotnet test loggers (console, TRX, JUnit)

Tracing

trace option

--tracing

Options.setTrace in @UsePlaywright, otherwise context.tracing()

Context.Tracing in set-up and tear-down code

Video and screenshots

video, screenshot options

--video, --screenshot

setRecordVideoDir, page.screenshot(...)

RecordVideoDir, Page.ScreenshotAsync(...)

Assertions

await expect(locator).toHaveCount(1)

expect(locator).to_have_count(1)

assertThat(locator).hasCount(1)

await Expect(locator).ToHaveCountAsync(1)

Visual comparison (toHaveScreenshot)

Yes

No equivalent in expect

No equivalent in assertThat

No equivalent in Expect

Aria snapshot assertion

toMatchAriaSnapshot

to_match_aria_snapshot

matchesAriaSnapshot

ToMatchAriaSnapshotAsync

UI Mode, component testing

Yes

Documented only for Node.js

Documented only for Node.js

Documented only for Node.js

Codegen, trace viewer, Inspector

Yes

Yes

Yes

Yes

Cells that say "none built in" or "no equivalent" mean that the language’s Playwright documentation and API reference list no such feature at the check date (for example, the Python, Java and .NET PageAssertions and LocatorAssertions classes have no screenshot assertion). They do not claim that it is impossible: screenshots can be taken in every language and compared with an image-diff library or a hosted service, as explained in Visual Regression Testing. The rows about sharding and retries describe what Playwright itself provides; each runner has its own rerun and splitting ecosystem.

Other differences that matter every day:

  • Naming. The same API is get_by_role, getByRole and GetByRole; Python and C# add async flavors or the Async suffix, and Python options are keyword arguments where Java uses builders (new Page.GetByRoleOptions().setName(...)) and C# uses new() { Name = ... }.

  • Enums. Roles are strings in Python ("button"), AriaRole.BUTTON in Java and AriaRole.Button in .NET.

  • Errors. A failed assertion is a Python AssertionError, a JUnit AssertionFailedError or a .NET PlaywrightException; the call log is identical.

  • Default timeouts. Actions wait up to 30 s everywhere, retrying assertions wait 5 s, and each runner has its own test timeout (pytest has none by default; Playwright Test has 30 s; NUnit, MSTest and xUnit have none unless configured).

  • Thread safety. A Java Playwright instance and everything created from it must stay on one thread. Python’s sync API runs a greenlet-based event loop of its own, so it cannot be used inside a running asyncio loop; use the async API there.

  • Version lag. The Node.js package is the first to receive a release; the Python, Java and .NET release-notes pages at the check date list 1.63 as their newest entry, while the registries already carried 1.64.x.

Choosing between them

Situation Suggested choice

A team that already writes Node.js or TypeScript, or wants the most complete tooling

Playwright Test in Node.js: config, projects, sharding, UI Mode, reporters and visual comparisons come built in.

A Python codebase (Django, FastAPI, data science)

pytest-playwright: the closest experience to Playwright Test, with --tracing, --video and --screenshot flags and pytest’s fixtures and markers. See the stack sections for Django and FastAPI for the application side.

A Java or Spring Boot team

The plain library with JUnit 5 lifecycle methods, or @UsePlaywright when you accept its experimental status. Wire the browser install into your CI image build, not into the first test run.

A .NET team

The NUnit, MSTest or xUnit base class matching your existing runner. PageTest covers most cases.

Mixed teams

Test the UI in one language and put API checks in each service’s own runner. Playwright’s APIRequestContext is available in all four languages, as used in the data-reset fixtures above.

Running in CI

The official Docker images exist per language and release: mcr.microsoft.com/playwright/python:v1.63.0-noble, mcr.microsoft.com/playwright/java:v1.63.0-noble and mcr.microsoft.com/playwright/dotnet:v1.63.0-noble, next to the Node.js image mcr.microsoft.com/playwright:v1.64.1-noble used elsewhere in this section. The image tag must match the Playwright version of your project, because each version expects the browser builds it was tested with. They bundle the browsers and operating-system dependencies, so no install step is needed in the job. The images were not pulled or run for this page; the pipeline patterns, the trace artifact upload and flakiness handling are in Running in CI/CD.

Common pitfalls

  • Forgetting the browser install. The library does not download browsers at pip install time. In Python run playwright install; in .NET run the installation script after the first build; Java downloads on first Playwright.create(), which makes a first CI run slow and network-dependent.

  • Mismatched runner and package majors in .NET. See the MSTest finding above.

  • exec:java with a test-scoped dependency. Add -D exec.classpathScope=test or move the dependency to compile scope.

  • Baking the base URL into tests. Use --base-url, Options.setBaseUrl or ContextOptions() so the same tests run against local, staging and CI hosts.

  • Sharing state across tests. Contexts are cheap and isolated; browsers are shared. Do not reuse a page between tests, and do not run a global reset from every parallel worker.

  • Mixing the sync and async Python APIs. The sync API fails inside a running event loop. Keep sync and async suites in separate directories, as in the example above where the async tests had their own environment.

  • Using one Playwright object from several Java threads. Create one per thread.

  • Expecting runner flags to affect objects you create. The pytest-playwright flags apply to the built-in fixtures only; contexts created with browser.new_context() need their own arguments.

What changed recently

Version Change

1.64 (Python 1.64.1, Java 1.64.0, .NET 1.64.1 published)

Bindings published after the 1.63 documentation. Not exercised on this page.

1.63

tracing.start() gains aria_snapshots and screen_snapshots (setAriaSnapshots / setScreenSnapshots in Java, AriaSnapshots / ScreenSnapshots in .NET) and the trace viewer a "Display Aria" mode. locator.visible replaces the :visible CSS pseudo-class. frame_locator() without a selector searches all frames. playwright install --no-remove keeps browsers from other Playwright installations. The Java API is annotated with JSpecify nullability annotations. Ubuntu 20.04 is no longer supported.

1.62

WebP screenshots (.webp extension). Debian 11 is no longer supported. Headless clipboard is isolated from the operating system.

1.55

Microsoft.Playwright.Xunit.v3 and Microsoft.Playwright.MSTest.v4 packages added for .NET.

1.42

Experimental JUnit integration (@UsePlaywright) for Java.

Node-only releases

Features such as test locks (1.63), the new component-testing model (1.62), WebMCP (1.64) and getByRef() (1.64) are described in the Node.js pages of this section; check the language’s API reference before relying on them elsewhere.