End-to-End Tests
By the end of this lesson
Cover critical journeys without building a slow, brittle suite.
An end-to-end test drives the real application the way a person would. It opens a browser, fills in the form, clicks the button and reads the page.
Nothing else gives you that evidence. Every other test in this course substitutes something: a double for a dependency, an in-process host for a deployment, an HttpClient for a browser. An end-to-end test substitutes nothing, so when it passes you know the whole assembled system did the job — the JavaScript, the CSS that positions the button, the API, the database, the configuration of the environment it ran against.
It is also the slowest and most fragile kind of test you can write, by a wide margin. Both statements are true at once, and that is what makes the number of them a decision rather than a preference.
The same purchase, covered at two levels:
| API test | End-to-end test | |
|---|---|---|
| Typical duration | Tens of milliseconds per request | Several seconds per journey, plus browser startup |
| What has to exist | The application, hosted in the test process | A deployed environment, a browser, and usable data in it |
| Covers | Routing, binding, validation, authorisation, serialisation | All of that, plus the interface, the client-side code and the environment |
| When it fails | The status code and body usually identify the cause | A screenshot and a trace, then a person works out what happened |
| Common cause of a red result | The behaviour genuinely changed | Timing, a moved element, or data left behind by another run |
| Sensible number of them | One or more per endpoint | A handful for the whole product |
using Microsoft.Playwright;
using Xunit;
using static Microsoft.Playwright.Assertions;
public class PlaceOrderJourney : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync();
}
[Fact]
public async Task ACustomerCanPlaceAnOrderAndSeeItConfirmed()
{
IPage page = await _browser.NewPageAsync();
await page.GotoAsync("https://staging.example.internal/orders/new");
// Act — the journey, as a person would perform it
await page.GetByLabel("Product").SelectOptionAsync("Desk lamp");
await page.GetByLabel("Quantity").FillAsync("8");
await page.GetByRole(AriaRole.Button, new() { Name = "Place order" }).ClickAsync();
// Assert — wait for the outcome, never for a duration
await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Order confirmed" }))
.ToBeVisibleAsync();
await Expect(page.GetByTestId("order-total")).ToHaveTextAsync("200.00");
}
public async Task DisposeAsync()
{
await _browser.DisposeAsync();
_playwright.Dispose();
}
}- GetByLabel and GetByRole find elements the way a person or a screen reader does — by visible label and by role. A CSS path such as div.form > div:nth-child(3) input breaks the next time somebody adds a wrapper, and it tells a reader nothing.
- GetByTestId is the escape hatch for a value with no accessible name of its own, like a computed total. Add an explicit test identifier in the markup rather than reaching for a class name that exists for styling and may change for styling reasons.
- Expect(...).ToBeVisibleAsync retries until the condition holds or a timeout expires. That retry is what makes the test tolerate a request that takes 200ms one run and 900ms the next.
- There is no sleep anywhere. A fixed wait is either too short, which is a flaky failure, or too long, which is time added to every run forever. Wait for the state you expect.
- The browser is launched once for the class and a fresh page is opened per test, so tests do not inherit each other's cookies or local storage. Launching a browser is the most expensive part of the run.
- Headless is the default, which is what you want in a pipeline. Pass LaunchAsync(new() { Headless = false }) locally when you need to watch what happens.
Flakiness is what kills these suites, and it comes from a short list of causes. Each has a real fix:
- Waiting for a duration instead of a condition. Replace every sleep with an assertion that retries until the expected state arrives.
- Selectors tied to structure or styling. Use roles, labels and explicit test identifiers, which change only when the interface genuinely changes.
- Data left behind by a previous run. Have each test create the records it needs with identifiers unique to that run, and clean up afterwards.
- Tests that depend on each other. If test three needs the order that test two created, a single failure cascades and the report is unreadable.
- An environment that other people are using. A colleague testing a deployment mid-run produces failures nobody can reproduce.
- Animations and transitions. An element can be present, visible and still moving when the click lands. Disabling animations in the test environment removes a whole category of intermittent failure.
- Third-party content — payment iframes, analytics, consent banners. Use the provider's sandbox where one exists, and handle the banner explicitly rather than hoping it does not appear.
Where these run matters as much as how many there are. A small smoke set — sign in, place an order — belongs on every deployment, because it answers the one question a deployment raises: is the thing that just went out usable? The rest can run on a schedule, nightly or before a release, where a twenty-minute suite costs nobody's attention.
Expect to maintain them. An interface changes more often than a domain rule, and every change can move a selector. Budget for that rather than being surprised by it, and treat a test that needs constant attention as a candidate for deletion once cheaper tests cover the same behaviour.
One last honest note: a failing end-to-end test usually needs a person. It tells you the journey is broken, not which of eight components broke it. That is the price of the only test that checks the assembled whole, and it is a reasonable price to pay a small number of times.
Summary
- An end-to-end test substitutes nothing, which is why it is the only test that proves the assembled system works
- It is also the slowest and most fragile, so a handful of revenue-critical journeys is the target rather than broad coverage
- Find elements by role, label or explicit test id, and wait for expected state rather than for a duration
- Flakiness has identifiable causes; re-running until green trains the team to ignore the suite
- Run a small smoke set on every deployment and the rest on a schedule, and keep screenshots or traces from failures
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Choose five journeys
An internal ordering application has: sign-in, a product catalogue with search and filters, an order form, a checkout with payment, order history, a profile page, an admin screen for product pricing, and a reporting dashboard.
Pick the five journeys you would cover end to end, and say what you are deliberately leaving to cheaper tests.
Show solution
A defensible five: sign in; place an order through to payment confirmation; an admin changes a price and the catalogue reflects it; a customer views an order in their history; sign out clears access. Each one either earns money, controls access, or would be reported by users within minutes of breaking.
Deliberately excluded: search and filter combinations, validation messages on the order form, the profile page, and every report on the dashboard. Filters and validation are logic with many variations, which is exactly what unit tests do better and faster. The dashboard is worth an API test per report rather than a browser test per chart.
The price change journey is the interesting inclusion. It spans two roles and two screens, so no single lower-level test covers the sequence, and getting it wrong means customers see stale prices — a commercial problem rather than a cosmetic one.
There is no single correct answer here. What matters is that each inclusion has a stated reason and each exclusion has a named cheaper test that covers it.
Try it yourself
Remove the flakiness from a test
This test fails roughly one run in five, always in the pipeline and never locally:
await page.ClickAsync("#submit-btn"); await Task.Delay(2000); string text = await page.InnerTextAsync(".confirmation h2"); Assert.Equal("Order confirmed", text);
Identify every cause of unreliability and rewrite it.
Show solution
The fixed delay is the main cause. Two seconds is enough on a developer machine and not always enough on a loaded pipeline agent, so the failure rate tracks how busy the agent is. Replacing it with an assertion that retries removes the race and usually makes the test faster, because it continues as soon as the heading appears rather than always waiting two seconds.
The selectors are the second cause. An id such as #submit-btn and a class path such as .confirmation h2 both describe the current markup rather than the thing the user interacts with. A role and an accessible name survive restructuring, and they double as a check that the button is reachable by assistive technology.
Reading the text into a variable and then asserting is the third. That comparison happens once, at whatever moment the delay expired. An assertion that retries is both more reliable and clearer about what it is waiting for.
What not to do: raise the delay to five seconds. That hides the race, adds five seconds to every run, and leaves the test failing occasionally on a slower day.
await page.GetByRole(AriaRole.Button, new() { Name = "Place order" }).ClickAsync();
// Retries until the heading appears, or fails with a clear timeout
await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Order confirmed" }))
.ToBeVisibleAsync();Saved in this browser only.