Sitemap
iamprovidence

iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

E2E Tests

13 min readSep 7, 2025

--

Press enter or click to view image in full size

Whether you’re new to testing or looking to improve your test strategy, understanding E2E testing is key to shipping quality software confidently.

In this article, we’ll explore what E2E tests are, you will see what makes them unique, and why they are both essential and challenging.

Don’t forget to check out other articles:

Meanwhile, we are starting!

Understanding E2E Tests

End-to-end tests verify the complete flow of the application — starting from the UI or API layer (one end) all the way to the database or final output (the other end) — to ensure that all components work together correctly. That’s where the name comes from.

[Test]
public void Create_and_save_order()
{
// Arrange
Browser.OpenPage("orders");

// Act
Browser.ClickButton("#create-order");

// Assert
var tableItems = Browser.GetTable("orders")
tableItems.Count.Should().Be(1);
}

End-to-end tests ensure that all layers — routing, validation, business logic, persistence — are correctly connected.

They’re also called acceptance tests because they validate that the system meets business requirements and acceptance criteria.

You can find some developers often refers to them as UI tests. While the term is common, it’s a bit misleading.

UI tests typically refer to unit tests that validate individual UI components in isolation, not full user workflows.

For example, in React, a UI test might look like this:

test('renders a button and handles click', async () => {
// arrange
const onClick = jest.fn();
render(<Button onClick={onClick}>Click Me</Button>);

// act
await userEvent.click(screen.getByText('Click Me'));

// assert
expect(onClick).toHaveBeenCalledTimes(1);
});

E2E Test Characteristics

The main distinction of E2E are:

  • driven by user scenarios — each test usually represents a complete user journey, such as “submit order,” “log in,” or “reset password”
  • covers the full system flow — E2E tests go through all layers — UI/API, business logic, database, and external services — mimicking real user behavior
  • tests real dependencies — unlike unit, E2E tests avoid mocks and stubs, using actual implementations and infrastructure
  • slow and resource-intensive — they take longer to run due to setup, HTTP calls, and database interactions
  • brittle but valuable — small changes in the UI or backend can break E2E tests, so maintenance is higher — but they catch issues no other tests can
Press enter or click to view image in full size

What to Test?

Do you remember the testing quadrant from the previous articles?

Press enter or click to view image in full size

It is completely not applicable for E2E tests 😅.

According to the testing pyramid, you should have the least amount of those tests.

E2E tests run through the full stack (HTTP, DB, services), making them much slower than unit or integration tests.

Press enter or click to view image in full size

Use E2E tests to cover only the most important user journeys for the flows where most of the logic is present on the UI — like login, checkout, or critical business paths.

Let cheaper, faster tests handle the rest.

Selenium vs Playwright: A Quick Comparison

In E2E, two major technologies dominate the field: Selenium and Playwright.

Selenium (2004) is a long-established browser automation tool with broad language support and a mature ecosystem, making it a popular choice for many teams. It requires separate browser drivers and can be slower due to its older architecture.

[Test]
public void Create_and_save_order()
{
// Arrange
var driver = new ChromeDriver();
driver.Navigate().GoToUrl("https://yourapp.com/orders");

// Act
driver.FindElement(By.CssSelector("#create-order")).Click();

// Assert
var tableItems = driver.FindElements(By.CssSelector("table#orders tbody tr"));
tableItems.Count.Should().Be(1);

driver.Quit();
}

Playwright (2019), on the other hand, is a newer framework from Microsoft that offers faster execution, built-in support for multiple browsers (Chromium, Firefox, WebKit), and modern features like automatic waiting, network interception, and multiple browser contexts.

[Test]
public async Task Create_and_save_order()
{
// Arrange
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
});

var page = await _browser.NewPageAsync();
await page.GotoAsync("https://yourapp.com/orders");

// Act
await page.ClickAsync("#create-order");

// Assert
var tableItems = await page.QuerySelectorAllAsync("table#orders tbody tr");
tableItems.Count.Should().Be(1);

await browser.CloseAsync();
}

While Selenium supports more programming languages, Playwright’s modern API and ease of setup make it especially appealing for new projects focused on speed and reliability.

I don’t want to be biased, but if I were you, I would choose Playwright 😄.

Manage E2E Test Challenges

If you’ve worked with E2E tests, you’ve likely experienced how difficult they can be to maintain.

Even minor UI changes can cause tests to break. Browser updates may introduce unexpected failures. Slow CI/CD pipelines, longer page load times, and unstable network conditions can all contribute to flaky or failing tests, making E2E testing feel frustrating and impractical at times 😒.

To handle these common E2E test issues effectively, here is some common advice:

  • use stable selectors

Some CSS selectors like .btn-primary > spancan be very specific. If the UI changes, it will cause test to fail:

await _page.ClickAsync(".btn-primary > span");

In HTML, there are special data attributes. Those are custom attributes that start with data-* and allow you to store extra information directly on HTML elements without affecting their presentation or behavior.

It is common to define data-testid attribute to identify a DOM node for testing purposes.

<button data-testid="create-order-button">
Create Order
</button>

Its value should be unique and not cause conflicts between components.

Now, the test will continue working regardless HTML structure:

await _page.ClickAsync("[data-testid='create-order-button']");
  • encapsulate complex pages, UI components

In E2E, there is a popular pattern called a page object. It is a class that represents a specific page or component (like pagination) of your application.

// Page Object class
public class OrdersPage
{
private readonly IPage _page;

public OrdersPage(IPage page)
{
_page = page;
}

public async Task NavigateAsync()
{
await _page.GotoAsync("/orders");
}

public async Task ClickCreateOrderButtonAsync()
{
await _page.ClickAsync("[data-testid='create-order-button']");
}

public async Task<IReadOnlyList<IElementHandle>> GetOrdersAsync()
{
return await _page.QuerySelectorAllAsync("table#orders tbody tr");
}
}

Encapsulation means:

  • Page Objects provide a simplified abstraction that leads to cleaner, more readable code
  • you don’t repeat locators or UI interaction code across multiple tests — you define them once in the Page Object
  • if the UI changes (e.g., a button ID changes), you update only the Page Object, not every test
[Test]
public async Task Create_and_save_order()
{
// Arrange
var ordersPage = new OrdersPage(_page);

await ordersPage.NavigateAsync();

// Act
await ordersPage.ClickCreateOrderButtonAsync();

// Assert
var orders = await ordersPage.GetOrdersAsync();
orders.Count.Should().Be(1);
}
  • generate unique values per test

To avoid conflicts between test runs, generate unique values for things like email, username, or login:

var email = $"user_{Guid.NewGuid()}@test.com";
await RegisterAsync(email, "SecurePass123!");
  • implement retries and waits

Avoid Task.Wait() that just slows your tests without actually guaranteeing the system will be in the expected state.

Use smart waiting strategies with conditions:

await page.WaitForSelector("button:has-text('Submit')");

Retry failed tests to reduce flakiness:

[Test]
[Retry(3)] // retries the test up to 3 times if it fails
public async Task Create_and_save_order()
{
. . .
}
  • keep tests isolated

Reset test state and data between runs to avoid dependencies.

Clean up data inserted during the test run in the database, clear cookies, and local storage:

await _page.Context.ClearCookiesAsync();

Avoid tests that depend on each other’s data.

  • run tests in reliable environments

Use stable CI/CD infrastructure and consistent browser versions. Pin versions of dependencies and test tools. Use Docker or CI runners for uniformity.

_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Channel = "chrome" // specify browser channel/version if needed
});
  • mock external dependencies

Similarly to the integration tests, you should mock unstable and unmanaged dependencies:

Press enter or click to view image in full size
  • keep tests short and focused

Each test should verify one specific behavior or user story — for example, creating, updating, or deleting an order. Avoid writing long, complex E2E tests that try to cover many steps at once.

[Test]
public async Task CreateOrder()
{
// Arrange
await _page.GotoAsync("/orders");

// Act
await _page.ClickAsync("[data-testid='create-order-button']");

// Assert
var orders = await _page.QuerySelectorAllAsync("table#orders tbody tr");
orders.Count.Should().Be(1);
}

[Test]
public async Task DeleteOrder()
{
// separate test focused only on deleting
}

For longer workflows, either split them into multiple focused E2E tests or combine E2E tests with integration tests to cover different layers efficiently.

  • arrange data with API instead of UI

Set up prerequisite data using API calls or direct database access rather than going through the UI. This speeds up tests and avoids unnecessary flaky UI steps.

[Test]
public async Task Delete_order()
{
// Arrange
// 👇 setup via API, database before UI test
_db.Orders.Add(new Order());

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
});

var page = await _browser.NewPageAsync();
await page.GotoAsync("https://yourapp.com/orders");

// Act
await page.ClickAsync("#delete-order");

// Assert
var tableItems = await page.QuerySelectorAllAsync("table#orders tbody tr");
tableItems.Count.Should().Be(0);

await browser.CloseAsync();
}

You can use contexts (in-memory storage within your test) to keep track of important data, IDs, etc, created during setup, so tests don’t depend on querying APIs repeatedly.

  • capture and log failures

On test failures, capture screenshots, videos, and logs to help diagnose problems quickly.

try
{
var context = await _browser.NewContextAsync(new BrowserNewContextOptions
{
RecordVideoDir = "videos/"
})

// your test code
}
catch (Exception)
{
await _page.ScreenshotAsync(new PageScreenshotOptions { Path = "failure.png" });
throw;
}

Behavior Driven Tests

It is common to see E2E in combination with BDD. It’s worth taking a closer look at how it works in practice.

Get iamprovidence’s stories in your inbox

Join Medium for free to get updates from this writer.

To get started, you’ll need a BDD test framework. In C#, a popular choice is Reqnroll (SpecFlow fork). You can install it using NuGet:

dotnet add package Reqnroll
dotnet add package Reqnroll.NUnit
dotnet add package Reqnroll.Tools.MSBuild.Generation

Or via the Visual Studio NuGet Package Manager.

Here’s a basic example of how a scenario might look using Gherkin syntax:

Scenario: Client add item in their basket
Given the client started shopping
Given the client added 1 "Electric guitar"
Given the client added 2 "Guitar pick"

When the basket is prepared

Then the basket price should be $195.0

You can also use a data-driven approach to reuse a test with different input combinations using a Scenario Outline:

Scenario Outline: Client adds a single item to the basket
Given the client started shopping
And the client added <quantity> <product>
When the basket is prepared
Then the basket price should be <total>

Examples:
| product | quantity | total |
| Electric guitar | 1 | 150.0 |
| Guitar pick | 2 | 45.0 |

This allows you to maintain a single scenario and run it with multiple data sets.

When dealing with more complex inputs — like multiple items added in a single step — you can use a data table:

Scenario: Client has multiple items in their basket
Given the client started shopping
And the client added
| product | quantity |
| Electric guitar | 1 |
| Guitar pick | 10 |
When the basket is prepared
Then the basket price should be $195.0

🔹 Notice: the keyword And continues the context of the previous step (Given in this case), making the scenario more readable.

Here’s how you might implement the step that handles multiple item entries using a table:

[Binding]
public class PriceCalculationSteps
{
. . .
[Given("the client added")]
public void GivenTheClientAdded(DataTable itemsTable)
{
var items = itemsTable.CreateSet<(string Product, int Quantity)>();
foreach (var item in items)
{
_basket.Add(item.Product, item.Quantity);
}
}
. . .
}

Since they offer better performance and can still process individual items, bulk actions are usually the preferred choice.

Project Structure

To keep your BDD tests organized and maintainable, it’s important to follow a clear and consistent folder structure.

Let’s take a look at a typical layout for a test project:

Press enter or click to view image in full size

I’ll go through the folders in order of importance, not in the order they appear. Try not to miss anything 🙃.

  • Features — holds the .feature files written in Gherkin syntax.
Feature: CreateTask

Scenario: User can create task
Given the user is logged in
And the task page is open
When user adds a new task
Then the task is present

I like to keep features grouped by entity, and each feature can have multiple scenarios inside.

Press enter or click to view image in full size
  • Steps — contains the step definitions that implement the steps in .feature files.
[Binding]
public class TaskSteps
{
. . .
[When("user adds a new task")]
public void WhenUserAddsANewTask()
{
. . .
}
. . .
}

Steps are usually per entity as well:

Press enter or click to view image in full size
  • Api — code for interacting with backend services (usually via REST APIs). Typically, this folder contains HTTP client wrappers along with request and response models.
Press enter or click to view image in full size
  • Contexts — stores shared objects or test-specific state used across different step definitions.
Press enter or click to view image in full size

Even though you can pass data between steps with ScenarioContext I prefer using a typed context because it provides compile-time safety, better readability, code navigation, and easier maintenance.

class TaskContext
{
public IList<TaskDto> CreatedTasks { get; } = new List<TaskDto>();
}

Once again, context can be used to:

  • set up initial data
  • share data between steps without new API calls
  • proper clean up
[Binding]
public class TaskStepDefinitions
{
private readonly TaskContext _context;
private readonly TaskApiClient _taskApiClient;

public TaskStepDefinitions(
TaskContext context,
TaskApiClient taskApiClient)
{
_context = context;
_taskApiClient = taskApiClient;
}

[Given("the following tasks are created")]
public async Task GivenTheFollowingTasksAreCreated(Table table)
{
// 👇 populate data through API, not UI, to avoid flakiness
var tasks = table.CreateSet<TaskDto>();
var createdTasks = await _taskApiClient.CreateTasks(tasks);
_context.CreatedTasks.AddRange(createdTasks);
}

[Given(@"the last task has a note")]
public void GivenTheLastTaskHasANote(Table table)
{
// 👇 avoid fetching the last item via network
var lastTask = _context.CreatedTasks.Last();

await _taskApiClient.AddNote(lastTask, "Test");
}
. . .

[AfterScenario]
public void CleanUp()
{
// 👇 clean up the API
await _taskApiClient.DeleteTasks(_context.CreatedTasks);
}
}
  • Hooks — contains setup and teardown logic for scenarios and features.
Press enter or click to view image in full size

For example, the following hook runs only for scenarios tagged with @CleanUpCache:

[Binding]
public class CacheCleanUp
{
[BeforeScenario("CleanUpCache")]
public void BeforeCleanUpCacheScenario()
{
// code to clean the cache
}

[AfterScenario("CleanUpCache")]
public void AfterCleanUpCacheScenario()
{
. . .
}
}

To trigger this hook, simply tag a scenario like this:

@CleanUpCache
Scenario: User can create task
. . .

This approach ensures cache cleanup runs only where it’s needed, improving both performance and test isolation.

  • UiComponents — stores reusable UI element abstractions, like Page Object Models (POM), CSS locators, and interaction methods.
Press enter or click to view image in full size
  • Utils — general-purpose helper classes and utilities.
Press enter or click to view image in full size

Of course, you can organize it differently. For example, you might follow screaming architecture. See, what suits you best 😉.

Testing Methodologies

Not every testing approach is equally suited for end-to-end testing. Since E2E tests focus on full user flows and real system behavior, some methodologies work better than others in this context.

Let’s explore how different testing styles — Test-After (Test Last), TDD, Snapshot Testing, and BDD — fit (or don’t fit) the goals of end-to-end testing.

— Test Last Development 🟢

I mean, at this point, you know that it always works when you write tests last 😅.

Press enter or click to view image in full size

It is often easier to write tests, pick the correct HTML selector, and mock the right dependencies when you know that the feature is complete and won’t be changed.

— TDD (Test Driven Development) 🔴

For end-to-end testing, TDD is less common and often impractical.

Press enter or click to view image in full size

Writing full user flow tests before the application logic or UI is implemented can lead to slow feedback cycles and brittle test setups.

— Snapshot Tests 🟡

Snapshot testing is generally a good fit for end-to-end tests, but it also depends on your test intentions.

Page snapshots can be useful for visual regression and UI bugs if it is critical for your system.

Press enter or click to view image in full size

However, capturing and comparing full-page HTML or visual states can lead to fragile tests that break on minor, unrelated UI changes.

— BDD (Behaviour Driven Development) 🟢

You probably already know the answer, but I will repeat it to maintain consistency with my previous articles 😅.

Writing behavior-focused scenarios like “Given, When, Then” aligns perfectly with end-to-end testing, where the goal is to validate complete user journeys.

This approach makes it easier to express expectations in a way that both developers and stakeholders can understand, especially for customer-facing flows.

Press enter or click to view image in full size

BDD can be particularly useful in cross-functional teams or when you need executable specifications.

Feature: CreateOrder

Scenario: Creating new order
Given I logged in as a test user
And I go to order page

When enter order name "My Order"
And I click button "Create Order"

Then the order is visible in the table
| Order Name |
| My Order |

However, for small internal tools or simple flows, the extra structure might not justify the overhead compared to well-written E2E tests with clear steps and assertions.

CI/CD

End-to-End (E2E) tests are essential for verifying that the entire system works correctly from the user’s perspective — covering everything from the UI or API layer down to the database and back.

However, they’re soooo duckiiing slow, complex, brittle, and require a special setup 😭. Therefore, developers rarely run them.

Because how slow and flaky those are, you cannot really run them on every PR in CI/CD. It is common to execute them once per night, by schedule, or before merging into main.

Press enter or click to view image in full size

They help to catch issues that unit and integration tests might miss, like broken workflows, incorrect routing, UI bugs, or misaligned user expectations.

Summary

End-to-end tests play a critical role in validating real user flows across the entire system — from the UI or API layer to the database and back.

While they are slower and more fragile than unit or integration tests, they provide high confidence that your application behaves correctly in realistic scenarios.

Remember:

  • E2E testing is meant to validate full system workflows, not error handling
  • use unit or integration testing to check how the system responds to errors
  • avoid cluttering E2E tests with scenarios better suited for other test types
Press enter or click to view image in full size

Let me know in the comments, does your project have E2E tests 💬

Give this article a clap if you liked it 👏

Do not forget to check out other articles 👇

Support me if you want ☕️

Follow to receive more ✅

See U 😉

--

--

iamprovidence
iamprovidence

Written by iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence