.NET developers also deserve E2E!
.NET folks, Where’s our agentic E2E story?
If you hang around X this week, you already know the answer for TypeScript: tester-army/e2e. It launched hard. Launch posts pulled hundreds of thousands of views. The repo sat at #1 on GitHub Trending. People keep saying the same two things: write the goal in plain English (agent.act("upgrade the workspace to Pro")), and once a step is verified, replay it from cache with no model calls until the UI changes. Agentic where it helps. Deterministic where it matters. That mix is why everyone is calling it cool.
So I ported the idea. e2e for .NET is a community .NET take on that flow (not an official TesterArmy product). To prove it, I rewrote Playwright’s TodoMVC example for https://demo.playwright.dev/todomvc: 23 tests, 625 lines. The rewrite: 20 tests, 310 lines. Same app. Same flows. And a few weak checks in the original got fixed along the way.
Wait, isn’t this “just Playwright”?
Kind of. e2e for .NET runs on top of Playwright. The browser engine is Microsoft.Playwright driving Chromium. So no, Playwright is not the villain here.
The point is: write intent, not selectors, and let the agent and the cache handle the mechanics.
Playwright also uses agents, by the way. That TodoMVC suite was generated by Playwright’s own agents (planner, generator, healer in .claude/agents/). Each test even starts with a // spec: comment pointing at a plan file. Those agents write selector code once, and the healer repairs it when it breaks.
e2e keeps the intent in the test and resolves it at run time, then records it. That contrast is the whole post.
Numbers, no drama
| Playwright example | e2e for .NET sample | |
|---|---|---|
| Tests | 23 (one empty seed) |
20 |
| Lines of test code | 625 | 310 |
| How actions are written | A selector per control | Natural-language Agent.ActAsync steps |
| How checks are written | expect(locator) |
Expect.That(locator) (no model call) |
First run with an empty cache and Copilot claude-sonnet-5.5: about 4 minutes (3 min 51 s to 4 min 28 s across three runs). Every ActAsync calls the model.
Next run, replay from cache: 17 to 32 seconds. No model calls. Most tests take about a second.
I did not time the Playwright suite, so I’m not comparing run times. “Better” here means shorter, closer to what the user does, no fragile selectors in actions, and stronger checks. Not faster. Not free.
Side by side
Toggle all complete. Playwright:
const newTodoInput = page.getByRole('textbox', { name: 'What needs to be done?' });
await newTodoInput.fill('Task 1');
await newTodoInput.press('Enter');
await newTodoInput.fill('Task 2');
await newTodoInput.press('Enter');
await newTodoInput.fill('Task 3');
await newTodoInput.press('Enter');
await expect(page.getByText('Task 1')).toBeVisible();
await expect(page.getByText('Task 2')).toBeVisible();
await expect(page.getByText('Task 3')).toBeVisible();
await expect(page.getByText('3 items left')).toBeVisible();
await page.getByRole('checkbox', { name: '❯Mark all as complete' }).click();
await expect(page.getByText('0 items left')).toBeVisible();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
e2e:
[Test]
public async Task Marks_every_todo_complete_at_once()
{
await AddTodosAsync("Task 1", "Task 2", "Task 3");
await Agent.ActAsync("mark all todos as complete with one click");
await Expect.That(Screen.GetByText("0 items left")).ToBeVisibleAsync();
await Expect.That(Screen.GetByRole("button", "Clear completed")).ToBeVisibleAsync();
}
See that ❯ in the Playwright selector? That’s part of the accessible name the page renders. Fragile. The e2e step just says what the user does.
Rename is even clearer. Playwright double-clicks todo-title, fills the Edit box, presses Enter. e2e:
[Test]
public async Task Renames_a_todo()
{
await AddTodosAsync("Buy milk");
await Agent.ActAsync("rename the todo Buy milk to Buy organic milk");
await Expect.That(Todo("Buy organic milk")).ToBeVisibleAsync();
await Expect.That(Todo("Buy milk")).ToBeHiddenAsync();
await Expect.That(Todos).ToHaveCountAsync(1);
}
The e2e test does not know that a double-click opens the editor, or what the edit box is called. It also checks that the old title is gone. The Playwright one does not.
The rewrite also fixed weak tests
Playwright’s should-uncomplete-completed-todo clicks a checkbox twice and has no assertion at all. The rewrite checks the checkbox state and the counter.
should-delete-specific-todo-from-multiple deletes Task 2, then only checks that Task 1 and Task 3 are visible. It never checks that Task 2 is gone. Ours does.
should trim whitespace from new todo uses getByText('Todo with spaces'). Playwright’s text matching normalizes whitespace, so that check also passes if the app does not trim. The rewrite reads the exact title text and compares it with the trimmed string.
And those duplicate folders in the Playwright example (adding-todos/ and todo-creation/ testing the same things) got merged.
Try the sample
The sample lives at samples/TodoMvc. It needs package 0.1.5.
dotnet tool install --global E2E.Cli
e2e login github-copilot
dotnet test --project samples/TodoMvc
NuGet packages: E2E and E2E.NUnit. Repo: hardkoded/e2e-dotnet.
Final words
I want .NET developers to write what the user does, not where to click. Playwright still drives the browser. The agent and the cache take care of the rest — after the first slow run.
Try it, break it, open an issue. I’d love that.
Don’t stop coding!