Reading Old Integration and E2E Tests: WebApplicationFactory from .NET 6 to 11, Testcontainers 3 to 4, Protractor to Playwright
When reading an old integration test, the first question is which application the factory brings up: a Startup-based factory can compile on .NET 10 and return 404 without ever running Bookstore. With real builds and runs we cover how WebApplicationFactory changed from .NET 6 to 11, .NET 11's ConfigureWebApplicationBuilder hook, the Testcontainers 4 changes, moving off Protractor, and the breaking changes in Playwright 1.58–1.65.
Version note: The code in this post was built and run with .NET 10 and .NET 11 (the ConfigureWebApplicationBuilder example), Microsoft.AspNetCore.Mvc.Testing 10.0.15 and 11.0.0, xUnit.net v3 4.0.3, Testcontainers for .NET 4.16 and Playwright 1.65.
Guess First
A new team member wants to add an integration test to Bookstore and copies this factory from a well-rated old blog post:
public class CustomWebApplicationFactory : WebApplicationFactory<Startup>
{
protected override IWebHostBuilder CreateWebHostBuilder() =>
WebHost.CreateDefaultBuilder().UseStartup<Startup>();
protected override TestServer CreateServer(IWebHostBuilder builder) => base.CreateServer(builder);
}
The first build stops with error CS0246: The type or namespace name 'Startup' could not be found; Bookstore has no Startup. Our colleague adds an almost empty Startup class to the test project (AddRouting and UseRouting), and the build passes with warnings:
warning ASPDEPR008: 'WebHost' is obsolete: 'WebHost is obsolete. Use HostBuilder or WebApplicationBuilder instead. For more information, visit https://aka.ms/aspnet/deprecate/008.'
warning CS0672: Member 'CustomWebApplicationFactory.CreateServer(IWebHostBuilder)' overrides obsolete member 'WebApplicationFactory<Startup>.CreateServer(IWebHostBuilder)'. ...
warning ASPDEPR008: 'WebApplicationFactory<Startup>.CreateServer(IWebHostBuilder)' is obsolete: 'IWebHost, which this method uses, is obsolete. Use one of the overloads that takes an IServiceProvider instead. ...'
What does a GET /api/books request return with this factory?
The Answer: It Compiles, but It Does Not Test Bookstore
After providing the content root by hand as well and running it:
GET /api/books -> 404 NotFound
Because CreateWebHostBuilder was overridden, the factory never ran Bookstore's Program.cs. It built a different application, with no endpoints, from the hand-added Startup, and the tests sent their requests to it. You can silence the warnings and say "the test is green", but what is green is not Bookstore. That is why the first question when reading an old test should be "which application does this factory bring up?". This post answers that question version by version: how WebApplicationFactory changed from .NET 6 to 11, the move from Testcontainers 3 to 4, the move from Protractor to Playwright, and the changes in Playwright's recent versions that affect test authors.
WebApplicationFactory: From .NET 6 to 11
Up to .NET 5 the application was set up with a Startup class, and that was also the factory's type argument. With .NET 6's minimal hosting model, there was no builder left for the factory to grab; Program.cs is just top-level statements. The solution came in ASP.NET Core 6 Preview 6: the factory now actually runs Program.cs. HostFactoryResolver listens to hosting's DiagnosticListener events; the moment the application builds its host, it captures it and stops the application from moving on to Run by throwing HostAbortedException. This detail explains two surprises in the series: the code before Build in Program.cs also running in tests in post 2, and the E2E host in post 7, given the wrong Program, running its own entry point over and over.
| Version | Change | How it looks in an old test |
|---|---|---|
| .NET 5 and earlier | WebApplicationFactory<Startup>, IWebHostBuilder | CreateWebHostBuilder override, UseStartup |
| .NET 6 | minimal hosting; the factory runs Program.cs | public partial class Program { } or InternalsVisibleTo |
| .NET 10 | a source generator emits the Program declaration | the hand-written declaration is unnecessary (ASP0027) |
| .NET 10 | UseKestrel, StartServer | hand-written Kestrel code for a real port |
| .NET 10 | WebHostBuilder and WebHost obsolete (ASPDEPR004 and ASPDEPR008 warnings) | a CreateServer(IWebHostBuilder) override produces warnings |
| .NET 11 | the ConfigureWebApplicationBuilder hook | workarounds written for configuration before Build |
From .NET 6 to 9 the Program class was internal by default, so Learn suggested two ways: give the test project InternalsVisibleTo in the API project, or add public partial class Program { } at the end of Program.cs. In .NET 10 a source generator emits that declaration; as we saw in post 1, a hand-written line is flagged with the ASP0027 info diagnostic and can be deleted.
Both .NET 10 additions were used in this series. UseKestrel and StartServer are the foundation of the E2E host in post 7; before .NET 10, getting a real port meant code, mostly copied from blog posts, that overrode the factory's CreateHost and started Kestrel by hand. WebHostBuilder becoming obsolete is the source of the warnings above: according to Microsoft's breaking change page the change arrived in .NET 10 RC 1, with warning ASPDEPR004 for WebHostBuilder and ASPDEPR008 for WebHost. The factory's CreateServer overload that takes an IWebHostBuilder became obsolete too; an overload taking an IServiceProvider replaced it. The warnings don't stop the build, but in a project with TreatWarningsAsErrors an old factory no longer compiles.
ConfigureWebApplicationBuilder in .NET 11
The main question of post 2 was when test configuration reaches Program.cs: a value given with ConfigureAppConfiguration did not reach what Program.cs read before Build, and the fix was UseSetting or CreateHost. .NET 11 added a new hook to the factory for this. I tried it with a small API whose Program.cs reads a setting before Build:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// A setting read before Build: the same problem as in post 2
string searchMode = builder.Configuration["Catalog:SearchMode"] ?? "contains";
WebApplication app = builder.Build();
app.MapGet("/search-mode", () => searchMode);
app.Run();
public sealed class OldStyleFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureAppConfiguration(config =>
config.AddInMemoryCollection(new Dictionary<string, string?> { ["Catalog:SearchMode"] = "full-text" }));
}
public sealed class NewHookFactory : WebApplicationFactory<Program>
{
// .NET 11: runs inside the WebApplication.CreateBuilder call, before Program.cs continues
protected override void ConfigureWebApplicationBuilder(IHostApplicationBuilder builder) =>
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Catalog:SearchMode"] = "full-text" });
}
I wrote two tests expecting /search-mode to return "full-text" for both factories. With the .NET 11 SDK:
Hook.Check.SearchModeTests.ConfigureAppConfiguration_value_arrives_too_late [FAIL]
Assert.Equal() Failure: Strings differ
↓ (pos 0)
Expected: "full-text"
Actual: "contains"
↑ (pos 0)
=== TEST EXECUTION SUMMARY ===
Hook.Check Total: 2, Errors: 0, Failed: 1, Skipped: 0, Not Run: 0, Time: 0,200s
The old way behaves the same in .NET 11; the value given through the new hook is visible before Build. According to the API documentation, the hook is called very early, during the entry point's call to WebApplication.CreateBuilder. Its naming history also helps when reading old blog posts: the hook arrived in .NET 11 Preview 6 as ConfigureHostApplicationBuilder and was renamed to ConfigureWebApplicationBuilder in Preview 7. An example written in the Preview 6 days does not compile on the GA release. Since this series stays on .NET 10 LTS, Bookstore's factories still use UseSetting; this is the first place that will get simpler when moving to .NET 11.
Testcontainers for .NET: From 3.x to 4.x
Testcontainers 4.0 came out in November 2024 and, together with the following minor versions, brought changes that affect old tests. What I gathered from the GitHub release notes:
- 4.0: the
IImageproperties holding the parts of an image name were reorganized to follow Docker's naming, the Azure SQL Edge module was removed, and the MSSQL module's default image was updated. - 4.1: the
net6.0target was removed andnet9.0added; the Testcontainers team's own fork of Docker.DotNet became the Docker client. - 4.8: wait strategies wait for the container's Running state by default; a container that exits during startup now throws an exception.
- 4.9: the
net10.0target was added. - 4.10: the parameterless constructors of module builders became obsolete; the image is now given explicitly.
The last item is the most common warning in old tests. The typical line from the 3.x days looks like this on 4.16:
MsSqlContainer sql = new MsSqlBuilder().Build();
warning CS0618: 'MsSqlBuilder.MsSqlBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'
There is a testing decision behind the warning: the parameterless builder used the module's default image for that version, and when the Testcontainers package was updated, the SQL Server version the tests ran against could change silently. new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest"), which we have used since post 1, writes that decision into code. If you want to be stricter, a specific CU tag or an image digest can be used instead of 2025-latest.
From Protractor to Playwright
Older Angular projects have an e2e/ folder, a protractor.conf.js and tests written with Jasmine. In an RFC opened in April 2021, the Angular team laid out Protractor's future: new projects would not get Protractor from Angular 12 on, development would end at the end of 2022 with Angular 15, and Protractor would reach end of life in August 2023. The GitHub repository is archived today. As we saw in post 6, ng e2e no longer comes with a framework and suggests a package for Playwright, Cypress, Nightwatch, WebdriverIO or Puppeteer.
Protractor's distinctive feature was synchronizing with Angular: waitForAngular waited, through Zone.js, until pending HTTP requests and timers had finished. Playwright has no such tie to Angular; waiting is based on the element's state (actionability) and web-first assertions. That is also the right model for Angular 22 running zoneless: in an application without Zone.js the question "is Angular done?" has no answer, but "is this the value the user sees?" does. Roughly, the counterparts when porting an old test:
| Protractor | Playwright Test | Note |
|---|---|---|
browser.get('/cart') | page.goto('/cart') | relative to baseURL |
element(by.css('.book-card button')) | page.getByRole('button', { name: 'Add to cart' }) | role and name instead of CSS |
by.model, by.binding | getByLabel, getByText | these locators were AngularJS-only anyway |
browser.wait(EC.visibilityOf(el)) | await expect(locator).toBeVisible() | web-first assertion |
browser.sleep(2000) | no counterpart | find the condition to wait for |
browser.waitForAngular() | no counterpart | auto-waiting and assertions |
Rewriting the user scenarios in Playwright is usually faster than translating tests line by line: Protractor tests tend to lean on CSS selectors and browser.sleep calls, and a one-to-one translation carries those weaknesses along.
Playwright's Recent Versions: Changes That Affect Test Authors
Playwright ships minor versions frequently, and each version's release notes can have a "Breaking Changes" section. The ones from recent versions that directly affect old tests:
1.58 removed the _react and _vue selectors, the :light suffix and the devtools option of launch. An old line that uses a removed engine does not get a descriptive error on 1.65:
await page.locator('_react=BookCard').click();
Error: locator.click: TypeError: Cannot read properties of undefined (reading 'queryAll')
at InjectedScript._queryEngineAll (<anonymous>:7292:48)
at InjectedScript.querySelectorAll (<anonymous>:7279:30)
Call log:
- waiting for locator('_react=BookCard')
Finding the cause of that error without the release notes is hard; when upgrading an old suite, searching the code for _react=, _vue= and :light is a quick pre-check.
1.61 brought two additions: a virtual WebAuthn authenticator through browserContext.credentials (to test passkey sign-in without a real hardware key) and the page.localStorage and page.sessionStorage APIs. In the setup project in post 7 we wrote the token to localStorage with page.evaluate; on 1.61 and later, page.localStorage.setItem does the same job. 1.62 added an option to save passkeys into storageState as well. In 1.64 the screen value from device descriptors started to be forwarded to the page; in projects using ...devices['Desktop Chrome'], window.screen and media queries now see the emulated screen, which can change visual test baselines.
The "Breaking Changes" list of 1.65 has ten items. The first one hits old page objects hardest: TypeScript class fields now follow the useDefineForClassFields setting in tsconfig.json, which is on for ES2022 and later targets. A common way of writing page objects failed on 1.65 like this:
class CartPanel {
readonly total = this.page.getByText(/^Total:/);
constructor(private readonly page: Page) {}
}
TypeError: Cannot read properties of undefined (reading 'getByText')
2 |
3 | class CartPanel {
> 4 | readonly total = this.page.getByText(/^Total:/);
| ^
The field initializer now runs before the page property created from the constructor parameter. The release notes suggest moving the assignment into the constructor or turning useDefineForClassFields off. The CartPage from post 7 already assigns its locators in the constructor, so it was not affected. Other items on the list that can affect tests: actions now also wait for the element not to be inert (the last item of the actionability list in post 6), a run in which no shard finds any test fails with "No tests found", cookie matching follows RFC 6265, params in APIRequestContext are appended to the existing query of the URL instead of replacing it, and text the browser does not render is no longer part of the accessible name. Also, 1.65 is no longer tested on Node.js 20; Node.js 22, 24 or 26 is required.
Making Upgrades Safe
The order I follow when reading an old suite or upgrading the tools:
- Verify which application the factory brings up: is the type argument from the API assembly, is there a
CreateWebHostBuilderorCreateHostoverride? - Read the build together with its warnings: ASPDEPR004, ASPDEPR008, CS0618 and ASP0027 show where the old APIs are.
- Give Testcontainers modules their image explicitly, with a fixed tag.
- Upgrade Playwright version by version; read each version's "Breaking Changes" section and update the browsers at the same time.
- After the upgrade, run the suite a few times with
--repeat-eachand compare the flaky count with the one before the upgrade. - Update visual test baselines in the same pull request as the upgrade, in the CI image, and review the diffs one by one.
In Production
Upgrade test tools with the same discipline as application packages. The version of Microsoft.AspNetCore.Mvc.Testing must stay in the same family as the ASP.NET Core version the API targets; the 11.0 package is for net11.0, and a .NET 10 project should stay on the 10.0.x line. Playwright's npm package and the Docker image tag must show the same version; the visual test baselines in post 8 are tied to the v1.65.0-noble image.
The cheapest way to track version differences is to keep tool versions in one place: Central Package Management or at least pinned version numbers on the .NET side, package-lock.json and versions without ^ on the Angular side. A bot such as Dependabot or Renovate can open the upgrade pull requests; but in the pull request for a package that brings browser builds, such as Playwright, make sure the E2E suite and the visual tests really run.
Be careful with examples written for preview versions. The .NET 11 factory hook changed its name between two previews; in Playwright, experimental APIs (such as packages with the experimental- prefix) can be removed. Before copying a blog example, check which version it was written for.
Common Pitfalls
Copying the Startup-based factory from old blog examples into .NET 10. As we saw above, a Startup added to make it compile cuts the tests off from the real application, and the tests stay green. In an application that uses minimal hosting, the factory's type argument must be a type from the application's own assembly, and CreateWebHostBuilder must not be overridden.
Silencing obsolete warnings with #pragma. ASPDEPR warnings mark APIs that may become errors in the next version; silencing them means leaving the upgrade to the next team. The line that triggers the warning usually changes in a few lines with the new API.
Translating Protractor tests one to one. A conversion that turns browser.sleep calls into waitForTimeout and CSS selectors into page.locator carries every pitfall from post 6 into the new suite.
Skipping several Playwright versions at once and looking at the failures one by one. In a suite that jumps from 1.58 to 1.65 in a single step, it is hard to find which version an unexplained error such as a TypeError comes from. Upgrading version by version, or at least reading the release notes of every version in between, matches each failure with its cause.
A Small Experiment
★☆☆ In Bookstore's auth.setup.ts, replace the localStorage write done with page.evaluate with page.localStorage.setItem, which came with 1.61, and verify that the content of the storageState file does not change.
★★☆ Add TreatWarningsAsErrors to the test project's csproj and compile the old factory above. Which warnings became errors? Rewrite the factory to use Bookstore's Program and see GET /api/books return 200.
★★★ Open a branch that moves Bookstore to net11.0 with the .NET 11 SDK: upgrade Microsoft.AspNetCore.Mvc.Testing to 11.0, move the UseSetting lines from post 2 into ConfigureWebApplicationBuilder, and compare the duration of the integration and E2E suites on the two versions.
The series ends here. Bookstore's API is under test with a real SQL Server, external services faked at the boundary, the contract with Angular and end-to-end flows that go through the browser; we also have a method for dealing with flaky tests and version changes. To keep the same suite fast in CI, see the Playwright post in the GitHub Actions series; to see how it behaves under load, see the Performance and Load Testing series.