Mert Özen Further With Every Line
Testing & Quality Integration and E2E Testing — #07

End-to-End Testing Bookstore: Bringing Up the API, Angular and the Database Together

Mert Özen Nov 14, 2026 16 min 2 views
End-to-End Testing Bookstore: Bringing Up the API, Angular and the Database Together

Playwright's webServer starts two processes: a .NET host that opens the SQL Server container and runs the API on a real port with WebApplicationFactory.UseKestrel, and ng serve. With real runs we show three surprises in the host, signing in once with a setup project and storageState, preparing test data through the API, combining page objects with fixtures, and UseKestrel ignoring the port with WithWebHostBuilder.

Version note: The code in this post was built and run with .NET 10, Angular 22.2, Playwright 1.65, Microsoft.AspNetCore.Mvc.Testing 10.0.15, Testcontainers for .NET 4.16, WireMock.Net 2.31 and SQL Server 2025.

Guess First

In post 6, Playwright's webServer setting started a small .NET host that brought up the API and the database. The first version of that host was a console project: with top-level statements it opened the SQL Server container, then started a WebApplicationFactory<Program>, very similar to the factory from post 1, on Kestrel:

// Bookstore.E2E.Host/Program.cs (first version)
await using MsSqlContainer sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
await sql.StartAsync();

await using E2EApiFactory api = new(connectionString);
api.UseKestrel(5280);
api.StartServer();

// Bookstore.E2E.Host/E2EApiFactory.cs
public sealed class E2EApiFactory(string connectionString) : WebApplicationFactory<Program>
{
    // ...
}

The build succeeded with a single warning:

E2EApiFactory.cs(6,84): warning CS0436: The type 'Program' in '.../Bookstore.E2E.Host/Program.cs' conflicts with the imported type 'Program' in 'Bookstore.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. Using the type defined in '.../Bookstore.E2E.Host/Program.cs'.

What happens when you start it with dotnet run? Does the API open on 5280, do you get an error, or something else?

The Answer: The Factory Ran Its Own Program

Within a minute these lines piled up (shortened from the Testcontainers log):

[testcontainers.org 00:00:01.35] Docker container cbc9cf883c51 created
[testcontainers.org 00:00:08.29] Docker container cbc9cf883c51 ready
[testcontainers.org 00:00:08.35] Docker container da22368ad75c created
[testcontainers.org 00:00:14.24] Docker container da22368ad75c ready
[testcontainers.org 00:00:14.26] Docker container 79a98f366488 created
[testcontainers.org 00:00:20.18] Docker container 79a98f366488 ready
[testcontainers.org 00:00:20.21] Docker container 2826fa527cfc created
[testcontainers.org 00:00:26.29] Docker container 2826fa527cfc ready
...

A new SQL Server container every six seconds. The warning said it all: the host project's top-level statements also mean a compiler-generated Program class, and the compiler picked the local one of the two. WebApplicationFactory<TEntryPoint> finds the application it runs from the assembly of TEntryPoint; that assembly was the host itself. StartServer called the host's entry point, which opened a new container and called StartServer again. When I stopped the process, Testcontainers' Ryuk container, the helper container that removes containers left behind when a session ends, cleaned them up.

The fix is to give the type argument a type from the API assembly:

// The type argument must be a type from the API assembly, not this project's own Program
public sealed class E2EApiFactory(string connectionString, FakeProviders providers, E2ETokenIssuer tokens)
    : WebApplicationFactory<BookstoreDbContext>

Test projects don't hit this, because xUnit v3 generates the test project's entry point itself and does not name it Program. The moment you use the factory outside a test, in a process with its own Main, the name Program exists in two places.

The Full-Stack Setup: Three Parts, One Command

07-e2e-stack-en.png

Everything that runs when you type npx playwright test is in the figure. Playwright starts two webServers: Bookstore.E2E.Host and ng serve. The host opens the SQL Server container and applies the migrations, starts WireMock.Net servers for payment and shipping, then makes the API listen on a real port with WebApplicationFactory.UseKestrel. The browser goes to the Angular dev server, and Angular forwards /api requests to the API through a proxy setting. The tests, outside the browser, talk to the API directly through Playwright's APIRequestContext to prepare test data and tokens.

There are three ways to bring the API up, and which one fits depends on where you run it:

SetupHowUpsideCost
Host process, UseKestrelwebServer starts the host with dotnet runone command; fakes and the test identity in code, as in the integration testsnot the production process but a test factory
Separate processpublished API + SQL Server in Docker + environment variablesclosest to production; runs from an image in CIfakes and the test identity must be provided through configuration
All inside xUnitMicrosoft.Playwright + factory + container in the same test process.NET tools onlystarting Angular still stays outside

In Bookstore I use the first setup locally and in pull request CI: the developer machine already has Docker and the .NET SDK, and everything the test needs lives in code. Before a release, when you want to test the very container image that will be deployed, the second setup makes more sense; then the test identity and the fake provider addresses are given through environment variables, and a docker compose file takes over the host's job.

The E2E Host: Testcontainers, UseKestrel and Fakes at the Boundary

The final version of the host:

using System.Runtime.InteropServices;
using Bookstore.Api.Data;
using Bookstore.E2E.Host;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MsSql;

// Playwright's webServer starts this process: SQL Server container + API on Kestrel
const int ApiPort = 5280;

// WebApplicationFactory looks for MvcTestingAppManifest.json, which holds the API's content root,
// in the working directory; dotnet run does not change the working directory
Environment.CurrentDirectory = AppContext.BaseDirectory;

await using MsSqlContainer sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
await sql.StartAsync();
string connectionString = new SqlConnectionStringBuilder(sql.GetConnectionString())
{
    InitialCatalog = "BookstoreDb"
}.ConnectionString;

// The schema and seed data must be ready before the API starts listening
DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
    .UseSqlServer(connectionString)
    .Options;
await using (BookstoreDbContext db = new(options))
{
    await db.Database.MigrateAsync();
    db.Books.AddRange(
        new Book { Isbn = "978-0441172719", Title = "Dune", Price = 64.90m, Stock = 7 },
        new Book { Isbn = "978-0141439587", Title = "Emma", Price = 39.50m, Stock = 3 },
        new Book { Isbn = "978-0547928227", Title = "The Hobbit", Price = 52.00m, Stock = 0 },
        new Book { Isbn = "978-0141439549", Title = "Middlemarch", Price = 48.75m, Stock = 5 });
    // The test identity customer_id=1 maps to this customer
    db.Customers.Add(new Customer { Email = "ada@example.com", Name = "Ada" });
    await db.SaveChangesAsync();
}

using FakeProviders providers = new();
await using E2EApiFactory api = new(connectionString, providers, new E2ETokenIssuer());
api.UseKestrel(ApiPort);
api.StartServer();
Console.WriteLine($"Bookstore API listening on {api.ClientOptions.BaseAddress}");

TaskCompletionSource stopped = new();
using PosixSignalRegistration sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, Stop);
using PosixSignalRegistration sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, Stop);
await stopped.Task;
Console.WriteLine("Stopping: disposing API and SQL Server container");

void Stop(PosixSignalContext context)
{
    context.Cancel = true;
    stopped.TrySetResult();
}

UseKestrel came to WebApplicationFactory with .NET 10. The default TestServer does not open a socket; the browser needs a real port to connect to. According to the API reference, UseKestrel must be called before the factory is initialized, that is, before CreateClient or StartServer; called later, it throws InvalidOperationException. When Kestrel is used, factory.Server throws NotSupportedException instead of returning a TestServer, and CreateClient returns a plain HttpClient pointing at the real address.

Two lines of the code fix two errors found along the way. The first is Environment.CurrentDirectory. On the first run the API crashed while starting:

Unhandled exception. System.IO.DirectoryNotFoundException: .../lab/Bookstore.Api/
   at Microsoft.Extensions.FileProviders.PhysicalFileProvider..ctor(String root, ExclusionFilters filters)
   ...
   at Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(String[] args)

The factory reads the API's content root from MvcTestingAppManifest.json, generated at build time, and looks for that file in the working directory. Test runners make the bin folder the working directory; dotnet run --project leaves it where you ran the command. When the file is not found, the factory tries a folder named after the assembly under the solution folder, and in our layout the API is under src, so there is nothing there.

The second is ordering. On the next attempt the host printed "Bookstore API listening on http://127.0.0.1:5280/", but curl could not connect. Reading the log from the top made the reason clear: LowStockReportService, which we turned off in the tests in post 2, was running here, went to the database before the migration, failed with "Cannot open database "BookstoreDb"" and, because of the BackgroundServiceExceptionBehavior.StopHost default, stopped the API host. Since the process stayed alive, everything looked up from the outside. Now the migration and seeding happen with a separate DbContext before the factory starts, and the background service is removed in the factory:

// The type argument must be a type from the API assembly, not this project's own Program
public sealed class E2EApiFactory(string connectionString, FakeProviders providers, E2ETokenIssuer tokens)
    : WebApplicationFactory<BookstoreDbContext>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("E2E");
        builder.UseSetting("ConnectionStrings:Bookstore", connectionString);
        builder.UseSetting("Payments:BaseUrl", $"{providers.Payments.Url}/");
        builder.UseSetting("Payments:ApiKey", "e2e");
        builder.UseSetting("Shipping:BaseUrl", $"{providers.Shipping.Url}/");

        builder.ConfigureTestServices(services =>
        {
            // The report job is not what E2E tests cover; if it fails it stops the host and Kestrel
            ServiceDescriptor worker = services.Single(d =>
                d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(LowStockReportService));
            services.Remove(worker);

            services.AddScoped<IOrderEmailSender, LoggingOrderEmailSender>();

            // JWT validation is real; only the party signing the token changes
            services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.Authority = null;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = E2ETokenIssuer.Issuer,
                    ValidAudience = "bookstore-api",
                    IssuerSigningKey = tokens.SigningKey
                };
            });
            services.AddSingleton(tokens);
            services.AddSingleton<IStartupFilter, E2ETokenEndpoint>();
        });
    }
}

The rest of the factory is familiar from posts 2 and 4: configuration with UseSetting, external services with WireMock.Net, email with a fake. FakeProviders starts two WireMock servers and answers every payment request with an approval and every shipping request with the tracking number TRK-E2E-1. We verified sending email with Mailpit in post 4; instead of repeating that in E2E, I use a fake that writes the send to the log. Failure scenarios (payment declined, shipping timeout) are not here; they belong in the integration tests.

Signing In Once: Setup Project and storageState

In production Angular redirects the user to the identity provider and puts the returned access token into localStorage; an interceptor adds it to every /api request as the Authorization header. The identity provider is an external system, just like the payment provider. In E2E I fake it at the boundary: the API's JWT validation runs unchanged, only the token is signed by the host itself with a test key generated randomly on every run.

// Stands in for the production identity provider: signs tokens with a new key on every run
public sealed class E2ETokenIssuer
{
    public const string Issuer = "bookstore-e2e";

    public SymmetricSecurityKey SigningKey { get; } = new(RandomNumberGenerator.GetBytes(32));

    public string Issue(TokenRequest request)
    {
        List<Claim> claims = [new("sub", $"customer-{request.CustomerId}"), new("customer_id", request.CustomerId.ToString())];
        claims.AddRange((request.Roles ?? []).Select(role => new Claim("role", role)));

        return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
        {
            Issuer = Issuer,
            Audience = "bookstore-api",
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddHours(1),
            SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256)
        });
    }
}

// POST /e2e/token exists only in this host; it is added in front of the API's own pipeline
public sealed class E2ETokenEndpoint(E2ETokenIssuer issuer) : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
    {
        app.Map("/e2e/token", branch => branch.Run(async context =>
        {
            TokenRequest? request = await context.Request.ReadFromJsonAsync<TokenRequest>();
            await context.Response.WriteAsJsonAsync(new { accessToken = issuer.Issue(request!) });
        }));
        next(app);
    };
}

The endpoint is not added to the API's code but only to the E2E host's pipeline through IStartupFilter; the production build has no such path. The key is born in the process's memory and dies there; it is never written to a file or to config.

On the Playwright side, signing in happens once in a setup project:

import { test as setup } from '@playwright/test';
import { apiURL, customerStorageState } from './support/config';

setup('sign in as Ada', async ({ page, request }) => {
  // In production the token comes from the identity provider; the E2E host stands in for it
  const response = await request.post(`${apiURL}/e2e/token`, { data: { customerId: 1 } });
  const { accessToken } = await response.json();

  await page.goto('/');
  await page.evaluate((token) => localStorage.setItem('bookstore.accessToken', token), accessToken);
  await page.context().storageState({ path: customerStorageState });
});
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: customerStorageState },
    dependencies: ['setup'],
  },
],

dependencies: ['setup'] makes sure the setup project finishes before the tests in the chromium project start. The file the setup saved looks like this with the token shortened:

{
  "cookies": [],
  "origins": [
    {
      "origin": "http://localhost:4200",
      "localStorage": [
        {
          "name": "bookstore.accessToken",
          "value": "eyJhbGciOiJI..."
        }
      ]
    }
  ]
}

Every test opens a new browser context with this file; so every test has the token, but cookies, localStorage and the in-memory cart are still private to the test. storageState carries cookies, localStorage and, optionally, IndexedDB; it does not carry sessionStorage. If your application keeps the token in sessionStorage, you have to save it by hand and restore it in every test, as the Playwright documentation shows. The file is under playwright/.auth/ and in .gitignore; it contains a valid token.

Preparing Test Data Through the API

The real subject of the test is the order flow. Creating the book and the coupon through the UI, on the admin screens, would both slow the test down and tie it to screens outside its subject. The data is prepared through the API with the admin role:

// Test data is prepared through the API, not the UI, with unique names for every test
export class BookstoreData {
  private counter = 0;

  constructor(
    private readonly api: APIRequestContext,
    private readonly suffix: string,
  ) {}

  async book(title: string, price: number, stock = 10): Promise<TestBook> {
    const isbn = `978-${String(Math.floor(Math.random() * 1e10)).padStart(10, '0')}`;
    const response = await this.api.post('/api/books', {
      data: { isbn, title: `${title} ${this.unique()}`, price, stock },
    });
    expect(response.status(), await response.text()).toBe(201);
    return response.json();
  }

  async coupon(percentOff: number, validDays = 30): Promise<string> {
    const code = `SAVE${percentOff}-${this.unique()}`.toUpperCase();
    const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000).toISOString();
    const response = await this.api.post('/api/coupons', { data: { code, percentOff, validUntil } });
    expect(response.status(), await response.text()).toBe(201);
    return code;
  }

  private unique(): string {
    return `${this.suffix}${++this.counter}`;
  }
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
  adminApi: [
    async ({ playwright }, use) => {
      const anonymous = await playwright.request.newContext({ baseURL: apiURL });
      const tokenResponse = await anonymous.post('/e2e/token', { data: { customerId: 0, roles: ['admin'] } });
      const { accessToken } = await tokenResponse.json();
      await anonymous.dispose();

      const api = await playwright.request.newContext({
        baseURL: apiURL,
        extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` },
      });
      await use(api);
      await api.dispose();
    },
    { scope: 'worker' },
  ],
  data: async ({ adminApi }, use, testInfo) => {
    await use(new BookstoreData(adminApi, `w${testInfo.workerIndex}t${Date.now().toString(36)}-`));
  },
  catalog: async ({ page }, use) => {
    await use(new CatalogPage(page));
  },
  cart: async ({ page }, use) => {
    await use(new CartPage(page));
  },
});

adminApi has worker scope: each worker gets the admin token once and uses it throughout its tests. data has test scope and builds a suffix from the worker number and the time for every test; book titles look like "Dune w1tmwfpok0f-1". The POST /api/coupons endpoint needed for the coupon was added to the API with this post, and PlaceOrderRequest got string? CouponCode = null. The contract check from post 5 classified the change like this:

$ oasdiff changelog base.json contracts/Bookstore.Api.json
3 changes: 0 error, 0 warning, 3 info
info	[endpoint-added] at contracts/Bookstore.Api.json
	in API POST /api/coupons
		endpoint added

info	[endpoint-added] at contracts/Bookstore.Api.json
	in API GET /api/coupons/{code}
		endpoint added

info	[new-optional-request-property] at contracts/Bookstore.Api.json
	in API POST /api/orders
		added the new optional request property `couponCode`

Thanks to the default value, the property did not enter the required list and was not counted as a breaking change; the opposite of the pitfall from post 5.

Page Object or Fixture?

They are not alternatives to each other. A page object gathers a screen's locators and the operations on that screen in one place; a fixture defines what the test needs and how it is prepared and cleaned up. In Bookstore I provide page objects as fixtures, so the test only writes what it wants in its parameter list:

export class CartPage {
  readonly total: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.total = page.getByText(/^Total:/);
    this.error = page.getByRole('alert');
  }

  async open(): Promise<void> {
    // The cart lives in memory; page.goto('/cart') reloads the app and empties the cart
    await this.page.getByRole('link', { name: /^Cart/ }).click();
  }

  async applyCoupon(code: string): Promise<void> {
    await this.page.getByLabel('Coupon code').fill(code);
    await this.page.getByRole('button', { name: 'Apply' }).click();
  }

  async placeOrder(city: string): Promise<void> {
    await this.page.getByLabel('Shipping city').fill(city);
    await this.page.getByRole('button', { name: 'Place order' }).click();
  }
}
import { expect, test } from './fixtures';

test('a customer orders a book with a coupon', async ({ data, catalog, cart, page }) => {
  const book = await data.book('Dune', 64.9);
  const coupon = await data.coupon(10);

  await catalog.open();
  await catalog.search(book.title);
  await catalog.addToCart(book.title);

  await cart.open();
  await cart.applyCoupon(coupon);
  await expect(cart.total).toHaveText('Total: ₺58.41');
  await cart.placeOrder('Izmir');

  await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();
  await expect(page.getByText('₺58.41')).toBeVisible();
  await expect(page.getByText('TRK-E2E-1')).toBeVisible();
});

There are no assertions in the page objects; the test states what it expects itself. Putting expect inside a page object makes it harder to reuse the same method in tests with different expectations. The test reads like a business flow: prepare data, search, add to cart, apply the coupon, place the order, see the result. A 10% coupon on a 64.90 TRY book gives 58.41 TRY; seeing the same amount on the order page shows that the discount is not only computed in the cart but is also in the order returned by the API. The whole suite:

$ npx playwright test
Running 5 tests using 4 workers

  ✓  1 [setup] › e2e/auth.setup.ts:4:1 › sign in as Ada (375ms)
  ✓  5 [chromium] › e2e/catalog.spec.ts:13:1 › adding a book updates the cart link (652ms)
  ✓  4 [chromium] › e2e/catalog.spec.ts:24:1 › an out-of-stock book cannot be added (686ms)
  ✓  2 [chromium] › e2e/catalog.spec.ts:3:1 › search finds a book with a lowercase term (767ms)
  ✓  3 [chromium] › e2e/checkout.spec.ts:3:1 › a customer orders a book with a coupon (1.8s)

  5 passed (17.4s)

In Production

Startup, not the tests, decides the duration. In the run above the five tests took a few seconds in total; the rest of the 17.4 seconds was the host and ng serve starting. When I opened them in separate terminals, the API was ready in 12 seconds (including the container, the migration and dotnet run's build check) and Angular in 2 seconds; with the servers up, the same suite finished in 3.0 seconds. That is why reuseExistingServer matters locally. In CI it is off through !process.env.CI; every run starts with a clean database.

When you need to see the servers' output, the DEBUG=pw:webserver environment variable adds the host's and ng serve's logs to Playwright's output. If the host crashes with an error, Playwright only says "Timed out waiting" once the timeout runs out; this log is the first place to look.

Think about shutdown on Windows separately. gracefulShutdown: { signal: 'SIGTERM' } sends SIGTERM to the host on Linux and macOS, and the host disposes the container and the factory. According to the Playwright documentation, Windows does not support SIGTERM and SIGINT, so the setting is ignored there and the process is terminated directly. The SQL Server container still does not stay behind: Testcontainers' Ryuk container removes the session's containers when the host's connection drops. In an environment that turns Ryuk off (some CI runners), that safety net is gone as well.

The ports are fixed: 5280 and 4200. Two runs on the same machine, or a host left running, silently connect the second run to the old server thanks to reuseExistingServer. In CI each job gets its own machine, so there is no problem; locally, if you are saying "the tests don't see my change", first check whether a host was left running.

Common Pitfalls

Using UseKestrel on a factory derived with WithWebHostBuilder. In post 2 we used WithWebHostBuilder for per-test variations. I tried the same path with Kestrel:

await using (ApiFactory baseFactory = new())
{
    await using WebApplicationFactory<BookstoreDbContext> derived = baseFactory.WithWebHostBuilder(b => b.UseSetting("Feature:X", "on"));
    derived.UseKestrel(5282);
    derived.StartServer();
    Console.WriteLine($"derived: {derived.ClientOptions.BaseAddress}");
}
base:    http://127.0.0.1:5281/
derived: http://localhost:5000/
derived (UseKestrel on base): http://localhost/; Server=TestServer

On the factory itself, UseKestrel(5281) worked as expected. On the derived factory the requested port was ignored and Kestrel opened on the default 5000; since the Angular proxy goes to 5282, the tests would get connection errors. Calling UseKestrel on the base factory and then deriving sent the derived factory's client to a TestServer, yet a "Now listening" line for 5283 still appeared in the log. That is the behavior in 10.0.15 at the time of writing; the dotnet/aspnetcore repository has an open issue (#65877) about the port being ignored on a derived factory. In a host that uses Kestrel, provide variations through a separate factory class or a constructor parameter, not through WithWebHostBuilder.

Tests seeing each other's data. On the first run after I added checkout.spec.ts, the search test from post 6 failed:

Error: expect(locator).toHaveCount(expected) failed

Locator:  getByRole('list', { name: 'Search results' }).getByRole('listitem')
Expected: 1
Received: 2
Timeout:  5000ms

The search test looked for the seeded "Dune" with "dune" and expected a single result. The order test, running at the same time in another worker, had created a book named "Dune w1…". Each test was correct on its own; it was the shared-data problem we saw in the integration tests of post 1, this time with parallel workers. The fix is the same: every test creates its own data with a unique name and looks only at that. The catalog tests now create their own books, such as data.book('Persuasion', 41.5). The four seeded books are useful when browsing the application by hand; the tests don't use them.

Trying business-rule combinations in E2E. Is the coupon expired, is the code unknown, percentage limits, rounding: an E2E test for each means seconds with the browser, Angular and the API. Those combinations belong in the integration tests; CouponTests verifies the amount charged with a valid coupon and the 400 returned for an expired and an unknown coupon without attempting a payment, and the three together take milliseconds. The single coupon test in E2E only shows that the pieces are wired together: the coupon box in the UI, the discount in the API and the amount on the order page.

Losing in-memory state with page.goto. The cart lives in a signal in Angular. Calling page.goto('/cart') after adding to the cart reloads the application from scratch and the cart opens empty; the test fails after 5 seconds saying the "Emma …" cell did not appear. The user goes to the cart through the link in the header, and the test should too. If surviving a page reload is a requirement, that should be a separate test.

A Small Experiment

★☆☆ Start the host in a separate terminal with dotnet run --project tests/Bookstore.E2E.Host, then run npx playwright test twice. Compare the duration of the second run with the first and check from /api/books how the number of books in the database grows.

★★☆ Add a second book to the order test, set its quantity in the cart to 2 and check the rounding of the discounted total. Do the discount calculation in Angular and Math.Round in the API give the same result? If not, which test should catch it?

★★★ Add a setting to the host that reads an E2E_API_PORT environment variable, convert the Angular proxy config to proxy.conf.mjs reading the same variable, and give both webServers in playwright.config.ts the same port. Can two runs then work on the same machine without getting mixed up?

The suite is green and fast for now. But the search test from post 6 failed twice in the first runs and passed when run again; one of those was the shared data above, and the cause of the other is not clear yet. The next post is exactly about this: flaky E2E tests; nondeterminism from shared data, time, animation and the network, worker parallelism and sharding, what retries hide, network mocking with page.route, and visual comparison.