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.
|
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?
There was a problem hiding this comment.
Removed the ReplaceLineEndings calls and added tests/Exceptionless.Tests/Controllers/Data/openapi.json text eol=lf to .gitattributes so the baseline is always LF on disk. Kept the \r\n → \n replace because those are escape sequences embedded inside JSON string values (XML doc comments processed on Windows get serialized as "...\r\n..."), not on-disk line endings — Environment.NewLine would be CRLF on Windows runners and break the comparison there.
| { | ||
| 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_
There was a problem hiding this comment.
Done — dropped the ConditionalWeakTable cache and both static fields entirely. The cache only narrowed the failure timeout (30s → 2s) on warm servers; on the success path the first /ready call returns immediately anyway, so the optimization wasn't pulling its weight. Now there's just a single 30s timeout (already covers cold starts and slow CI).
| 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_
There was a problem hiding this comment.
Removed the hardcoded URL and the s_ field. The Elasticsearch URL is now resolved from the running Aspire app via elasticsearch.Resource.GetConnectionStringAsync() after app.StartAsync(), then passed into WaitForElasticsearchAsync as a parameter. Single source of truth — the port (9200) is declared once on AddElasticsearch(...) and Aspire owns the rest.
| 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?
There was a problem hiding this comment.
The connection string is now derived from the Aspire ElasticsearchResource itself (see reply above), so it's no longer hardcoded — the test AppHost is the configuration. I'd rather not pull it from appsettings here because then we'd have two places declaring the test ES endpoint that could drift; AddElasticsearch("Elasticsearch", port: 9200) is the single declaration and GetConnectionStringAsync reads it back.
| 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.
There was a problem hiding this comment.
Yeah, agreed — the reorder lets Docker cache the dotnet restore layer across source-only changes, which should noticeably speed up CI builds when only .cs files change.
Co-authored-by: Copilot <copilot@github.com>
| public AppWebHostFactory() | ||
| { | ||
| if (!s_pool.TryDequeue(out var instanceId)) | ||
| instanceId = Interlocked.Increment(ref s_counter); |
| catch (HttpRequestException) | ||
| { | ||
| } |
| catch (TaskCanceledException) | ||
| { | ||
| } |
Co-authored-by: Copilot <copilot@github.com>
…/exceptionless/Exceptionless into parallel-integration-test-slices
Summary
Adopt Aspire-first local and CI workflows, parallelize backend integration tests, and add a sample event generator for new accounts.
What Changed
Test infrastructure
AppScopeslice per integration test fixture usingtest,test-1, and so on (the first fixture keeps the plaintestscope so single-test runs stay easy to inspect)TestServerreadiness tracking safe for multiple concurrent hosts without retaining disposed serversAspire and local dev
aspire runas the default local startup pathAPI_HTTPS/API_HTTPproxy conventionASPNETCORE_URLSenv wiring inconnect.js/vite.config.tswith the values Aspire injectsCI/CD workflow
versionjob outputs (should_publish,is_prod_deploy,is_dev_deploy) so publish/deploy jobs are skipped cleanly on forks and PRsexceptionlessDocker image tag-only; standard PRs buildapi,job, andappFrontend
dependenciesintodevDependencies(SvelteKit static SPA — nothing is consumed at runtime)vite.config.tsand fixdev:apito useAPI_HTTPSinstead of the no-opASPNETCORE_URLSSample data
GenerateSampleEventsWorkItem/RandomEventGeneratorto seed realistic events for fresh accountsDocs
Verification
dotnet testdotnet test -- --filter-class Exceptionless.Tests.Controllers.OpenApiControllerTestsdotnet test -- --filter-class Exceptionless.Tests.Controllers.AuthControllerTestsaspire runfrom the repo root starts the appNotes
src/Exceptionless.Web/ClientApp.angularstill powers the main site while the Svelte 5 app insrc/Exceptionless.Web/ClientAppremains under development