Adopt Aspire workflows and parallelize integration tests#2205
Adopt Aspire workflows and parallelize integration tests#2205
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enables bounded class-level parallelism for the backend integration test suite by scoping test data per fixture (AppScope) while sharing the Aspire-managed infrastructure across the overall test process.
Changes:
- Enable xUnit parallel execution with a fixed
MaxParallelThreadsvalue. - Introduce per-fixture
AppScopeslices (test,test-1, …) while sharing a single Aspire-backed Elasticsearch instance. - Update test helpers/baselines to behave correctly under parallel execution (server readiness tracking, file sharing, OpenAPI baseline comparison).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Exceptionless.Tests/Utility/StackData.cs | Allow reading stack JSON files without exclusive locks (parallel-safe file access). |
| tests/Exceptionless.Tests/Properties/PropertyInfo.cs | Enable bounded xUnit class-level parallelism. |
| tests/Exceptionless.Tests/IntegrationTestsBase.cs | Remove global reset lock; reset data per fixture scope and clear additional queues. |
| tests/Exceptionless.Tests/Extensions/TestServerExtensions.cs | Make readiness wait tracking per-TestServer instead of global. |
| tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs | Compare OpenAPI baseline structurally and attempt to normalize line endings. |
| tests/Exceptionless.Tests/AppWebHostFactory.cs | Share Aspire infrastructure across the run and allocate/reuse AppScope slices per fixture. |
| AGENTS.md | Document new parallel integration test behavior and AppScope slicing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -26,9 +46,11 @@ public async ValueTask InitializeAsync() | |||
| .WithContainerName("Exceptionless-Elasticsearch-Test") | |||
| .WithLifetime(ContainerLifetime.Persistent); | |||
|
|
|||
| _app = builder.Build(); | |||
| var app = builder.Build(); | |||
|
|
|||
| await _app.StartAsync(); | |||
| await app.StartAsync(); | |||
|
|
|||
| return app; | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
The shared Aspire DistributedApplication is started via the static s_sharedApplication but there is no corresponding disposal/shutdown path at the end of the test run. This can leave background resources running longer than necessary and can cause hangs or port contention in some runners/IDE scenarios. Consider adding a single assembly-/collection-level fixture to dispose the shared application once after all tests complete (while still avoiding per-class restarts).
There was a problem hiding this comment.
I have not added a one-time teardown for the shared Aspire application in this PR. The current change keeps the shared app alive for the duration of the test process to avoid per-class restarts, so I’m leaving this thread open rather than claiming it is addressed.
| var oldLoggingLevel = Log.DefaultLogLevel; | ||
| Log.DefaultLogLevel = LogLevel.Warning; | ||
|
|
There was a problem hiding this comment.
ResetDataAsync temporarily changes Log.DefaultLogLevel, which is a static/global setting. With class-level parallelism enabled, concurrent fixtures can interleave these assignments and restores, leading to the wrong log level being applied in other tests (and potentially affecting log-based assertions). Consider avoiding global log-level mutation here (e.g., adjust only specific categories) or guard the change/restore with a dedicated lock around just the log-level section.
There was a problem hiding this comment.
I have not changed the Log.DefaultLogLevel mutation path yet. This thread is still open intentionally because the global log-level race concern is valid and still needs a separate follow-up decision.
| public AppWebHostFactory() | ||
| { | ||
| if (!s_pool.TryDequeue(out var instanceId)) | ||
| instanceId = Interlocked.Increment(ref s_counter); |
There was a problem hiding this comment.
I did not take this refactor-only suggestion in this PR. The constructor logic is still the same and the thread is left open intentionally rather than being resolved as addressed.
| catch (HttpRequestException) | ||
| { | ||
| } |
There was a problem hiding this comment.
I did not change this retry loop yet. I’m leaving the thread open intentionally rather than marking it addressed, although I agree that attaching the last exception would improve timeout diagnostics.
| catch (TaskCanceledException) | ||
| { | ||
| } |
There was a problem hiding this comment.
I did not apply this comment-only suppression change in this PR. This thread is still open intentionally so it is clear the empty-catch suggestion has not been taken yet.
|
Follow-up on the review feedback: Addressed in
Verification:
The matching OpenAPI and Still not addressed:
Those are still open on purpose rather than being silently ignored. |
| .ReplaceLineEndings("\n") | ||
| .Replace("\\r\\n", "\\n"); | ||
| actualJson = actualJson | ||
| .ReplaceLineEndings("\n") | ||
| .Replace("\\r\\n", "\\n"); |
There was a problem hiding this comment.
is this really needed? should we be using Environment.NewLine so it's less magic strings?
| { | ||
| var startupContext = server.Services.GetService<StartupActionsContext>(); | ||
| var maxWaitTime = !_alreadyWaited ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(2); | ||
| var maxWaitTime = !s_waitedServers.TryGetValue(server, out _) ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(2); |
There was a problem hiding this comment.
really wish we got rid of the s_
| public class AppWebHostFactory : WebApplicationFactory<Startup>, IAsyncLifetime | ||
| { | ||
| private DistributedApplication? _app; | ||
| private static readonly Uri s_elasticsearchUri = new("http://127.0.0.1:9200"); |
There was a problem hiding this comment.
why hard code this, can we also get rid of s_
| public class AppWebHostFactory : WebApplicationFactory<Startup>, IAsyncLifetime | ||
| { | ||
| private DistributedApplication? _app; | ||
| private static readonly Uri s_elasticsearchUri = new("http://127.0.0.1:9200"); |
There was a problem hiding this comment.
also shouldn't this come from configuration?
| FROM build AS build-node | ||
| RUN apt-get update -yq \ | ||
| && apt-get install -yq curl ca-certificates \ | ||
| && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ | ||
| && apt-get install -yq nodejs \ | ||
| && rm -rf /var/lib/apt/lists/* |
There was a problem hiding this comment.
I never understood why we didn't do this first, so this layer could be cached more.
Summary
Adopt Aspire-first local and CI workflows while enabling bounded parallel execution for backend integration tests.
What Changed
AppScopeslice per integration test fixture usingtest,test-1, and so onTestServerreadiness tracking safe for multiple concurrent hosts without retaining disposed serversaspire runas the default local startup pathexceptionlessimage tag-onlyVerification
dotnet test --project "tests\Exceptionless.Tests\Exceptionless.Tests.csproj"dotnet test --project ".\tests\Exceptionless.Tests\Exceptionless.Tests.csproj" --configuration Release -- --filter-class Exceptionless.Tests.Controllers.OpenApiControllerTestsdotnet test --project ".\tests\Exceptionless.Tests\Exceptionless.Tests.csproj" --no-restore --no-build --configuration Release -- --filter-class Exceptionless.Tests.Controllers.AuthControllerTestsaspire runfrom the repo root starts the appNotes
testscope so single-test runs are easy to inspectsrc/Exceptionless.Web/ClientApp.angularstill powers the main site while the Svelte 5 app insrc/Exceptionless.Web/ClientAppremains under development