MotusHow-to

Moving a Playwright for .NET suite to Motus

Most of it is search and replace, so here are the steps in the order you would do them, the four places the APIs really differ, and the parts that do not come across at all.

If you already have a Playwright for .NET suite, most of it moves to Motus with search and replace. The locator vocabulary is the same, the assertion names are the same, and the actions are the same. What actually changes is what sits between your process and the browser, plus four API shapes that need a real edit.

The steps below are in the order you would do them. There is a section near the end that says what does not come across, because a migration guide that hides the losses is not worth following.

Before you start

You need the .NET 8 SDK or later, a suite that builds today, and a browser. Two questions decide whether the rest of this is for you.

Does any part of your suite run outside Chromium and Firefox? Motus drives Chromium, including Chrome and Edge, over the Chrome DevTools Protocol, and Firefox over WebDriver BiDi. That is the whole list.

Do you live in Playwright's trace viewer? Motus writes its own trace zip and it opens in motus trace show, nowhere else. The two formats do not interoperate, and they were never meant to.

If both answers are no, everything below is mechanical.

What sits between you and the browser

Here is the starting position, in the migration guide's own words: Playwright for .NET "ships a bundled Node.js process (playwright.ps1 / playwright) that acts as the automation server; your .NET process talks to it over a named-pipe IPC channel". Motus has no sidecar. Your process holds the browser's WebSocket and speaks the protocol itself.

Playwright for .NET Your .NET process IPC Node.js server Browser Motus Your .NET process WebSocket, CDP or BiDi Browser
The top chain is the migration guide's own description of Playwright for .NET. The bottom one is what takes its place.

That takes the playwright install step out of your build and the Node.js runtime off your machine image. It also means the wire is something you can read: every command is a CDP or BiDi message, not a translation of one.

Step 1: swap the packages

Nothing clever here.

dotnet remove package Microsoft.Playwright
dotnet remove package Microsoft.Playwright.MSTest
dotnet add package Motus
dotnet add package Motus.Testing.MSTest
dotnet add package Motus.Analyzers

Then delete any playwright install or playwright.ps1 install line from your build scripts, and install a browser the Motus way, once per machine.

dotnet tool install --global Motus.Cli
motus install

motus install pulls a Chrome for Testing build into ~/.motus/browsers. In CI, pin it with a dotted version, motus install --revision 149.0.7827.156, so a moving stable channel cannot quietly change what your suite ran against.

Step 2: fix the using directives

Four namespaces where you had two.

// Before
using Microsoft.Playwright;
using Microsoft.Playwright.MSTest;

// After
using Motus;
using Motus.Abstractions;
using Motus.Assertions;
using Motus.Testing.MSTest;

Step 3: replace the launch call

There is no object sitting between startup and the browser, so Playwright.CreateAsync() has nothing to map onto. It just goes away.

// Before
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });

// After
var browser = await MotusLauncher.LaunchAsync(new LaunchOptions { Headless = true });

Firefox is a channel rather than its own entry point: new LaunchOptions { Channel = BrowserChannel.Firefox }.

Step 4: the assertions

This is the one project-wide replacement, Expect( to Expect.That(. The method names and what they mean are unchanged, so nothing else on an assertion line moves.

await Expect.That(page).ToHaveTitleAsync("Example Domain");
await Expect.That(page.GetByRole("button", "Submit")).ToBeVisibleAsync();

Step 5: the test base class

Swap PageTest for MotusTestBase, then add the assembly hooks that launch the shared browser.

[TestClass]
public static class AssemblySetup
{
    [AssemblyInitialize]
    public static async Task Init(TestContext _) => await MotusTestBase.LaunchBrowserAsync();

    [AssemblyCleanup]
    public static async Task Cleanup() => await MotusTestBase.CloseBrowserAsync();
}

[TestClass]
public class HomePageTests : MotusTestBase
{
    [TestMethod]
    public async Task TitleIsCorrectAsync()
    {
        await Page.GotoAsync("https://example.com");
        await Expect.That(Page).ToHaveTitleAsync("Example Domain");
    }
}

Motus shares one browser process across the whole test assembly and hands each test its own context and page, so you can leave [Parallelize] alone. NUnit uses the same MotusTestBase, with one BrowserFixture per fixture class. xUnit is put together differently: decorate the class with [Collection(nameof(MotusCollection))] and take SharedBrowserFixture through the constructor.

The mapping table

These rows come from the migration guide, which maps every surface in full. These are the ones you will hit most.

Playwright for .NET Motus
var playwright = await Playwright.CreateAsync() (not needed, no sidecar to create)
playwright.Chromium.LaunchAsync(options) MotusLauncher.LaunchAsync(options)
playwright.Chromium.ConnectOverCDPAsync(endpoint) MotusLauncher.ConnectAsync(endpoint)
browser.NewContextAsync(options) browser.NewContextAsync(options)
context.RouteAsync(pattern, handler) context.RouteAsync(pattern, handler)
page.GotoAsync(url, options) page.GotoAsync(url, options)
page.GetByRole(role, new() { Name = name }) page.GetByRole(role, name)
page.GetByText(text, new() { Exact = exact }) page.GetByText(text, exact)
page.GetByTestId(testId) page.GetByTestId(testId)
page.FrameLocator(selector).Locator(inner) frame.Locator(inner), where frame came from page.Frames
page.SetViewportSizeAsync(width, height) page.SetViewportSizeAsync(viewportSize)
locator.ClickAsync(options) locator.ClickAsync(timeout)
locator.FillAsync(value, options) locator.FillAsync(value, timeout)
locator.WaitForAsync(options) locator.WaitForAsync(state, timeout)
locator.Filter(options) locator.Filter(options)
(no equivalent) context.GetPluginContext()

Four of those need a hand edit rather than a rename. Options objects flatten into positional parameters, so new() { Name = name } becomes a second argument. AriaRole enum values become plain role strings. FrameLocator has no Motus type at all, so take an IFrame out of page.Frames and call the same locator factories on it. And Playwright's two connect methods collapse into one ConnectAsync that accepts either a WebSocket URL or an HTTP debugging endpoint.

Step 6: configuration

playwright.config.ts becomes motus.config.json, read from the working directory or from the path in MOTUS_CONFIG. It is sectioned rather than flat.

{
  "launch": { "headless": true, "timeout": 30000 },
  "context": { "viewport": { "width": 1280, "height": 720 } },
  "assertions": { "timeout": 5000 }
}

Settings layer file first, then MOTUS_* environment variables, then code. LaunchOptions and ContextOptions passed at a call site always win, which is the rule to remember the day a value refuses to change.

What has no equivalent

Better you read this now than find it in week three.

Browser engines beyond Chromium and Firefox. There is no third transport. If part of your matrix runs somewhere else, that part is staying where it is.

The Playwright trace format and its viewer. Motus traces are their own zip layout, opened by motus trace show. Tooling built on Playwright's trace files will not read them.

Firefox parity with Chromium. Tracing, network interception, emulation overrides, security overrides, target multiplexing, the accessibility tree and code coverage are CDP-only. On Firefox those calls throw a NotSupportedException that names the feature and the transport, so you find out loudly rather than quietly. Locators, actions, assertions and script evaluation all work there.

Attaching to a Firefox endpoint. ConnectAsync builds a CDP transport unconditionally, so attaching is a Chromium-only path.

Packages built on Playwright. Anything in that ecosystem stops applying, and accessibility auditing is the one that stings: the guide notes that "Playwright delegates accessibility auditing to the separate axe-playwright ecosystem package". Motus has nine WCAG rules in the box and no extra dependency. Nine rules are not a replacement for a full audit library, and I am not going to pretend they are.

What you get in exchange: those audits and Core Web Vitals budgets without a second package, seven compile-time diagnostics from Motus.Analyzers, an extension model written in .NET instead of injected JavaScript ("Playwright's extensibility is limited to JavaScript-side selector engines and browser context options; Motus hooks are pure .NET"), and attaching to a browser you did not start.

Step 7: build, run, triage

dotnet build
motus run bin/Release/net8.0/MyTests.dll --reporter console

With Motus.Analyzers referenced, the build itself catches the common leftovers: a call you forgot to await, a browser not disposed with await using, a navigation with nothing waiting after it. Three of the seven diagnostics ship a code fix with Fix All.

The one failure that will surprise you

A Motus locator assertion needs its element to be present. ToBeVisibleAsync and its neighbors retry state once the element is attached, but they fail at once when the locator matches nothing, rather than waiting for it to render. Only ToBeAttachedAsync, ToBeDetachedAsync and ToHaveCountAsync are safe before the element exists.

So where a test asserts against something that renders later, wait for it first.

await Expect.That(page.GetByRole("dialog")).ToBeAttachedAsync();
await Expect.That(page.GetByRole("dialog")).ToBeVisibleAsync();

Then check the selectors themselves, which is the other thing a port tends to disturb.

motus check-selectors "tests/**/*.cs" --manifest tests.selectors.json --ci

The full mapping lives in the migration guide, the fixture shapes for all three frameworks in testing frameworks, and the whole config schema in configuration. If you hit a surface the mapping table does not cover, that is the issue I want to see.