ProtoTest vs Testcontainers, Alba and Aspire testing
Every tool on this page is good, and ProtoTest is built on several of them. Your application runs in-process with WebApplicationFactory. Infrastructure comes from Testcontainers. Browsers use Playwright or Selenium. The question is never whether those libraries work. It is which shape fits your suite.
This page says where each alternative wins, where ProtoTest wins, and ends with the cases where ProtoTest is the wrong choice. When ProtoTest is not the right choice is the honest version up front.
| If you | Pick |
|---|---|
| Only test HTTP | WebApplicationFactory plus an assertion library |
| Live in declarative HTTP scenarios with DI stubbing | Alba |
| Need deploy fidelity | Aspire testing |
| Only drive a browser | Playwright alone |
| Cross API, database, broker and browser in one test | ProtoTest |
What stays in the test
ProtoTest moves setup to the suite host and attributes, and the scenario lines stay the same. Below is one test against the same in-process application, written as a regular NUnit fixture and with ProtoTest. Both send the same requests and check the same values. The marked lines are plumbing: they start the application, create clients, provision the tenant and user, authenticate, deserialize, clean up, or are the file's own scaffolding. Everything else is the scenario.
Per fixture, 35 lines of plumbing without ProtoTest and 13 with it. The scenario lines stay the same. Both sides also have files written once for the suite: 44 lines of helpers and DTOs without, 85 with, which also produce the trace, the contract coverage and the report.
| Task | Without ProtoTest | With ProtoTest |
|---|---|---|
| Run the application in-process | A WebApplicationFactory per fixture, created and disposed by hand. | Registered once for the suite. [Application] on the class selects it, and every test gets the running app. |
| A tenant of its own | A field, a SetUp call and an HTTP helper with its own client. | [SampleEnvironment] provisions it before the test and hands it to the context. |
| A user with the right role | Created in SetUp, so every test in the fixture gets the same role. | You declare the role on the test that needs it. |
| Authenticate every request | Default headers on one HttpClient shared by the whole fixture. | ProtoTest applies it per request from the test context, so parallel tests keep separate headers. |
| Make the calls | HttpClient calls, with the query string written into the route. | The fluent client. Routes, parameters and bodies are recorded in the trace. |
| Check the responses | Deserialize into DTOs written for the purpose, then assert field by field. | The expected shape is the assertion. No DTOs, and a mismatch lists every property that differs. |
| Clean up, even when the test fails | TearDown disposes the client and deletes the tenant. If setup failed partway, some cleanup is skipped. | The attribute that created the tenant deletes it. Teardown runs in reverse order and is traced. |
| Find out what happened | Only the assertion message and the console output. | Steps, requests, and assertions land in a .prototrace file and an HTML report, with contract coverage. |
The files written once
Without ProtoTest: TestSupport.cs, 36 lines
namespace Billing.Tests;using System.Net.Http.Json;public static class Environments{private static readonly HttpClient Client = new(){BaseAddress = new Uri("http://localhost:5080")};public static async Task<TestEnvironment> CreateAsync(){var name = $"test-{Guid.NewGuid():N}";using var response = await Client.PostAsJsonAsync("/test-support/environments",new CreateEnvironmentRequest(name));response.EnsureSuccessStatusCode();return (await response.Content.ReadFromJsonAsync<TestEnvironment>())!;}public static async Task DeleteAsync(TestEnvironment environment){using var response = await Client.DeleteAsync($"/test-support/environments/{environment.Tenant}");response.EnsureSuccessStatusCode();}}public static class Users{public static async Task<TestUser> CreateAsync(TestEnvironment environment, string role){using var client = new HttpClient { BaseAddress = environment.ApiUrl };using var response = await client.PostAsJsonAsync($"/test-support/environments/{environment.Tenant}/users",new CreateUserRequest($"{role}.{Guid.NewGuid():N}@example.test", role));response.EnsureSuccessStatusCode();return (await response.Content.ReadFromJsonAsync<TestUser>())!;}}
Without ProtoTest: Dtos.cs, 8 lines
namespace Billing.Tests;public sealed record TestEnvironment(string Tenant, string Name, Uri ApiUrl);public sealed record TestUser(string Id, string Tenant, string Email, string Role, string AccessToken);public sealed record OrderResponse(int Id, string Product, int Quantity, decimal Total, string Status);public sealed record InvoicePage(string State, IReadOnlyList<Invoice> Invoices);public sealed record Invoice(int Id, string State, decimal Total);
With ProtoTest: Setup.cs, 22 lines
namespace Billing.Tests;using ProtoTest.AspNetCore;using ProtoTest.Core;using ProtoTest.NUnit;using ProtoTest.OpenApi;using ProtoTest.Reporting;using ProtoTest.Rest;[SetUpFixture]public sealed class Setup : ProtoTestAssembly{protected override void Configure(IProtoHostBuilder builder) =>builder.AddRest(rest => rest.AddClient(SampleAppTargets.Api).AddCollector<RestCoverageCollector>().AddCollector<OpenApiCoverageCollector>()).AddAspNetCoreServer<Program>(SampleAppTargets.Api).ConfigureTracing(trace =>trace.OutputPath = "TestResults/billing.prototrace").AddSink<HtmlReportSink>(sink =>sink.OutputPath = "TestResults/report.html");}
With ProtoTest: Scenario.cs, 63 lines
namespace Billing.Tests;using System.Net;using System.Net.Http.Headers;using ProtoTest.Core;using ProtoTest.Http;using ProtoTest.Rest;public sealed class SampleEnvironmentAttribute : ProtoAttribute{public SampleEnvironmentAttribute() => Order = -200;public override async Task BeforeTestAsync(ProtoExecutionContext context){using var response = await context.Rest(SampleAppTargets.Api).WithoutAuth().Body(new CreateEnvironmentRequest($"test-{context.TestId}")).PostAsync("/test-support/environments");var environment = response.Should.HaveHttpStatus(HttpStatusCode.Created).ReadAsJson<EnvironmentResponse>()!;context.SetContext(new SampleEnvironmentContext(environment.Tenant, environment.Name, environment.ApiBaseUrl));}public override async Task AfterTestAsync(ProtoExecutionContext context){var environment = context.TryResolve<SampleEnvironmentContext>();if (environment is null) return;using var response = await context.Rest(SampleAppTargets.Api).WithoutAuth().DeleteAsync("/test-support/environments/{tenant}",new { environment.Tenant });response.Should.HaveHttpStatus(HttpStatusCode.NoContent);}}public sealed class SampleUserAttribute(string role) : ProtoAttribute{public override async Task BeforeTestAsync(ProtoExecutionContext context){var environment = context.Resolve<SampleEnvironmentContext>();using var response = await context.Rest(SampleAppTargets.Api).WithoutAuth().Body(new CreateUserRequest($"{role}.{context.TestId}@example.test", role)).PostAsync("/test-support/environments/{tenant}/users",new { environment.Tenant });var user = response.Should.HaveHttpStatus(HttpStatusCode.Created).ReadAsJson<UserResponse>()!;context.SetContext(new SampleUserContext(user.Id, user.Tenant, user.Email, user.Role, user.AccessToken));}}public sealed class SampleUserAuthenticator : IProtoHttpAuthenticator{public ValueTask AuthenticateAsync(ProtoHttpAuthenticationContext context,CancellationToken cancellationToken = default){var environment = context.Test.Resolve<SampleEnvironmentContext>();var user = context.Test.Resolve<SampleUserContext>();context.Request.Headers.Authorization =new AuthenticationHeaderValue("Bearer", user.AccessToken);context.Request.Headers.Add("X-Tenant", environment.Tenant);return ValueTask.CompletedTask;}}
WebApplicationFactory, Testcontainers, Verify and Shouldly
The established .NET combination: host the application with WebApplicationFactory, start PostgreSQL and RabbitMQ with Testcontainers, assert responses with Shouldly and snapshot them with Verify. ProtoTest wraps exactly these primitives. The difference is what connects them.
Where they win
- Nothing new to adopt. Each library is maintained by people who focus on one thing, and you can replace any of them independently.
- There is no lifecycle to learn beyond the primitives themselves. A team that knows
WebApplicationFactoryis productive on day one. - You assemble the diagnostics you need, library by library: container logs here, a snapshot there, a browser trace when a browser is involved.
What adopting ProtoTest costs: one setup class per test project, one package per integration, and three ideas to learn (the host, the lifecycle, the trace). Tests swap [Test] or [Fact] for [ProtoTest], select the application with [Application], and reach clients through Proto.Context. One test beside your existing suite costs five minutes (your first test). A suite pays that learning cost once, and then per test only the attribute and accessor lines.
Where ProtoTest wins
- One host, one execution context and one lifecycle across every integration a test uses. The API, the database, the broker and the browser share the setup that already ran.
- One
.prototraceper run, with the operations of every integration and the failing assertion in place. Read it in the static trace viewer, or summarize it withprototest summaryin CI. - Contract coverage: which endpoints, responses and fields the suite actually asserted. The four-library stack does not provide it.
- Provisioners and seed data as reusable setup instead of helpers that grow inside the test project.
- Five test-runner adapters: the same suite shape works across runners.
If your suite is already shaped this way and the glue does not hurt, keep it. The glue is the product here.
Alba
Alba is the closest neighbour. It is a mature declarative testing library for ASP.NET Core with a Scenario DSL, service registration stubbing, and deep integration with the Critter Stack (Marten and Wolverine).
Where they win
- Maturity and focus. Over a decade of releases, a large body of examples, a stable public API.
- Declarative HTTP scenarios with built-in dependency-injection and authentication stubbing.
- First-class depth in the JasperFx ecosystem that ProtoTest does not target.
Where ProtoTest wins
- Scope beyond HTTP. One test can write through REST, read through GraphQL, check the database, publish through messaging and drive a browser, all in one trace.
- Coverage, provisioning and reporting belong to the foundation instead of being assembled per project.
- The trace is a versioned, documented file (format 2.0) with a static viewer, so a CI failure is readable where it happened.
- Runner adapters for each framework rather than one testing style.
For per-test substitution use context.Override<T>(), [ReplaceService<T>] or [FailDependency<T>]. Each test gets a dedicated server, so an override does not leak into the next test. Alba remains stronger for suites built entirely around declarative HTTP scenarios with DI stubbing.
Aspire testing
.NET Aspire composes a distributed application from an AppHost and can launch that topology inside a test.
Where they win
- Real topology. Every service and dependency runs as its own process, so the suite sees the system closed-box, exactly as it deploys.
- Microsoft backing, first-class tooling and the .NET 10 toolchain.
- The best choice when deployment fidelity itself is what you are testing.
Where ProtoTest wins
- In-process speed and direct assertions. Services run inside the test process where the test can inspect them, and contract coverage can see every response.
- No AppHost requirement. In-process hosting with Testcontainers runs on .NET 8, 9 and 10 today, with less CI machinery.
- The same suite can point at a deployed environment through configuration when closed-box fidelity is what a given run needs.
These are complementary rather than competing. Keep Aspire where deployment topology matters, and a ProtoTest suite can test the same system from outside. ProtoTest.Aspire composes an AppHost through the same infrastructure hooks and publishes its resources as application targets.
Playwright .NET alone
Where they win
- Best-in-class browser automation: tracing, code generation, auto-waiting, maintained by Microsoft.
- If your tests only ever need a browser, it is the shortest path, with no other layer to learn.
Where ProtoTest wins
- The browser is one integration among several.
ProtoTest.Web.Playwright(or the Selenium backend) drives the same flows. The API call that arranged the data and the database check that proves it landed are in the same test and the same trace. - A Playwright trace shows what the browser did. A
.prototraceshows the browser alongside everything that led to it. - Login, page objects and attachments belong to the same lifecycle rather than a separate helper layer.
If browser testing is the whole problem, Playwright alone is simpler. If the browser is one hop in a longer journey, ProtoTest keeps the hops together.
Building the foundation in-house
Some teams build their own integration-testing layer instead. I did too.
Where it wins
- Perfect fit for your domain vocabulary, your infrastructure and your priorities, with nobody else's roadmap to negotiate.
- Total control: any behavior can be changed the day it annoys you.
Where ProtoTest wins
- The same idea already exists in public, with documentation, tests and a changelog.
- It survives people. An in-house framework usually stops being maintained when its author changes teams. ProtoTest is MIT, the trace format is documented and extension points are public.
- Diagnostics you would rarely fund yourself: the trace, the viewer, contract coverage and reports.
- Runner adapters and container-backed infrastructure that you would otherwise rebuild one integration at a time.
A framework you own completely is the right answer when the problem is truly yours alone. When it is the same problem everybody has, sharing the foundation is cheaper.
Support and maintenance
| Project | Maintained by | Issues and help | Fix line |
|---|---|---|---|
| ProtoTest | One person, in personal time. No company, no team, no sponsors. | Bugs go to GitHub Issues, questions to Discussions. Best effort, with no response time guarantee. | Fixes land on the newest released line. Older lines get security or critical fixes case by case, never promised. There is no LTS line. |
| ASP.NET Core, Playwright, Aspire testing | Microsoft teams behind the .NET stack. | Vendor backed channels plus large public trackers. | LTS releases on the .NET schedule. |
| Alba | The JasperFx community and the Critter Stack, with over a decade of releases. | A public tracker with a long history of answered questions. | A stable public API with a long track record. |
| Testcontainers | The Testcontainers organization, with a commercial offering beside the open source libraries. | A public tracker plus vendor backed channels. | Maintained release lines. |
The full ProtoTest story is on the support and sustainability page.
When ProtoTest is not the right choice
- You only test HTTP endpoints.
WebApplicationFactoryplus an assertion library is smaller and has no lifecycle to learn. - You want zero framework dependencies. Every ProtoTest integration is an opinionated wrapper. That is the design, and it is fair to decline it.
- Closed-box topology is the requirement. Aspire testing tests processes as they deploy. ProtoTest's in-process strengths are the wrong fit for that.
- You need per-test DI-level stubbing over a declarative HTTP scenario. ProtoTest's substitution works and is traced, but a library built entirely around scenario stubbing has a smaller surface.
- You are not on .NET 8, 9 or 10. ProtoTest targets those three runtimes, and earlier frameworks are out of scope.
- Your team already has a foundation it likes. Do not replace working infrastructure to gain a trace.
And the honest caveat in the other direction: ProtoTest is young. The alternatives have larger ecosystems, longer track records and more answers on the internet. If community size decides it for you, that is a fair reason to choose them.
If you want to judge it with your own problem, one test beside your existing suite costs five minutes: your first test. The packages are per-integration, and they do not replace the libraries underneath them. If you adopt and later change your mind, Leaving ProtoTest removes it one test at a time.