Skip to content

Auto-discover unit-test projects in CI#2470

Draft
GarrettBeatty wants to merge 7 commits into
devfrom
normj/ci-add-unit-tests
Draft

Auto-discover unit-test projects in CI#2470
GarrettBeatty wants to merge 7 commits into
devfrom
normj/ci-add-unit-tests

Conversation

@GarrettBeatty

Copy link
Copy Markdown
Contributor

Problem

CI runs unit tests via the run-unit-tests target in buildtools/build.proj, which hardcoded only 4 projects:

  • SnapshotRestore.Registry.Tests
  • Amazon.Lambda.RuntimeSupport.UnitTests
  • Amazon.Lambda.Annotations.SourceGenerators.Tests
  • Amazon.Lambda.DurableExecution.Tests

As a result, many unit-test projects were never executed in CI, including Amazon.Lambda.AspNetCoreServer.Test, Amazon.Lambda.Core.Tests, Amazon.Lambda.AspNetCoreServer.Hosting.Tests, EventsTests.NET8, Amazon.Lambda.Logging.AspNetCore.Tests, Amazon.Lambda.DynamoDBEvents.SDK.Convertor.Tests, Amazon.Lambda.DurableExecution.Testing.Tests, Amazon.Lambda.DurableExecution.Analyzers.Tests, PowerShellTests, and TestFunctionCSharp. New test projects also silently went unrun unless someone remembered to append them here.

Change

Replace the hardcoded list with buildtools/run-unit-tests.ps1, which discovers unit-test projects by convention — any *.csproj that references Microsoft.NET.Test.Sdk (or sets <IsTestProject>true</IsTestProject>), excluding *.IntegrationTests.csproj (those run in a separate phase via run-integ-tests-parallel.ps1). This mirrors the existing integ-test discovery pattern.

Discovery is content-based rather than name-based because the repo's test projects follow no single naming convention (*.Test, *.Tests, EventsTests.NET8, PowerShellTests, ...). Non-test fixture projects (TestServerlessApp, TestWebApp, HandlerTest, ...) don't reference the test SDK, so they're cleanly excluded.

The script now discovers 14 projects (the original 4 plus the 10 that were being skipped) and exits non-zero listing any that fail.

The run-unit-tests target only ran 4 hardcoded projects, so test
projects such as Amazon.Lambda.AspNetCoreServer.Test, Core.Tests,
EventsTests.NET8 and others were never executed in CI. Replace the
hardcoded list with run-unit-tests.ps1, which discovers any project
referencing the test SDK (excluding *.IntegrationTests.csproj, which
run in a separate phase), mirroring run-integ-tests-parallel.ps1.
@GarrettBeatty GarrettBeatty added the Release Not Needed Add this label if a PR does not need to be released. label Jul 8, 2026
These test projects were never run in CI before the discovery change, so
pre-existing failures went unnoticed. Fixes by category:

Real test bugs (failed deterministically, incl. locally):
- BuildStreamingPreludeTests / ResponseStreamingPropertyTests: the
  StandalonePreludeBuilder subclassed the HTTP API v2 function (whose
  BuildStreamingPrelude override collapses headers into single-value
  Headers), but the tests assert on the multi-value MultiValueHeaders
  collection. Derive from the REST v1 APIGatewayProxyFunction instead,
  matching the assertions and the section comments.

CI-environment robustness (passed locally, failed in CI):
- ci.buildspec.yml: raise fs.inotify.max_user_instances (container
  default 128 was exhausted once all ASP.NET-host-starting unit tests
  run, surfacing as inotify IOExceptions across ~90 tests).
- TestMinimalAPI: capture child-process stdout/stderr and handle the
  WaitForExit timeout so the out-of-process 'dotnet run' failure
  (exit 129) is diagnosable and the process cannot leak.
- AddAWSLambdaBeforeSnapshotRequestTests: poll for the before-snapshot
  callback instead of racing a fixed 500 ms delay that was flaky under
  CI load.
@GarrettBeatty

Copy link
Copy Markdown
Contributor Author

CI run surfaced pre-existing failures (now fixed)

The first CI run went red — as designed, discovery turned on test projects that were never executed in CI before, exposing their pre-existing failures (Amazon.Lambda.AspNetCoreServer.Test: 106 fails; Hosting.Tests: 1). None were caused by the build.proj/script change itself. Fixed in e11f4ac, split by root cause:

Real test bugs (deterministic — reproduced locally)

  • BuildStreamingPreludeTests / ResponseStreamingPropertyTests (14 tests): the StandalonePreludeBuilder helper subclassed the HTTP API v2 function, whose BuildStreamingPrelude override collapses headers into the single-value Headers collection — but the tests assert on multi-value MultiValueHeaders (v1/REST semantics, as their section comments describe). Changed the helper to derive from the v1 APIGatewayProxyFunction. These were introduced with the response-streaming feature (Merge response streaming feature branch #2354) and never ran in CI, so the mismatch went unnoticed.

CI-environment robustness (passed locally, failed only in CI)

  • inotify limit (~90 tests): failed with The configured user limit (128) on the number of inotify instances has been reached. The AspNetCoreServer tests each start an ASP.NET Core host; the container default was exhausted once all host-starting projects ran. Raised fs.inotify.max_user_instances in ci.buildspec.yml.
  • TestMinimalAPI.TestMapPostComplexType: spawns dotnet run out-of-process and failed with a bare exit code 129. Now captures stdout/stderr and handles the WaitForExit timeout so failures are diagnosable and the child can't leak.
  • AddAWSLambdaBeforeSnapshotRequestTests: raced a fixed 500 ms delay for the before-snapshot callback (flaky under load). Now polls for the callback.

All AspNetCoreServer.Test (193) and the snapshot tests pass locally on net8.0. Re-running CI to confirm.

Previously the script ran every unit-test project and only reported the
aggregate failures at the end. Exit immediately on the first failing
project for quicker feedback and to avoid spending CI time on later
projects once the build is already red.
Rework run-unit-tests.ps1 to mirror run-integ-tests-parallel.ps1: build
all discovered projects once serially (avoids the shared ProjectReference
build-output race), then run 'dotnet test --no-build' in parallel with
streamed, project-prefixed output. All projects run (no short-circuit) so
every failure is surfaced in one pass, and failed projects' output is
reprinted as a clean block before exiting non-zero.

Running the projects concurrently exposed a pre-existing intra-assembly
flake in Amazon.Lambda.AspNetCoreServer.Hosting.Tests: many tests mutate
the process-global AWS_LAMBDA_FUNCTION_NAME env var, and xUnit parallelizes
collections within an assembly by default, so one test's value leaked into
AddAWSLambdaHosting_NotInLambda_DoesNotRegisterHostingOptions (which needs
it unset). Add AssemblyInfo.cs disabling in-assembly parallelization for
that project so the env-var-dependent tests run serially.
PositiveHandlerTestsAsync intermittently failed (~1 in 5 under load) with
'Can't find method name in console text'. Two pre-existing issues, both
aggravated by concurrency:

- ExecHandlerAsync/TestHandlerFailAsync point a static logger action on
  the shared Amazon.Lambda.Core assembly at a per-invocation StringWriter,
  and LambdaBootstrap's background RunAsync writes through it. When xUnit
  runs collections in parallel, one test's logging can land in another's
  writer. Add AssemblyInfo.cs disabling in-assembly parallelization (same
  approach as Hosting.Tests); some classes already shared a [Collection]
  but that did not cover the whole assembly.
- The InvokeAsync/exception waiter loops busy-spun on a field with no
  yield, pinning a core and starving the bootstrap thread under load.
  Replace the tight spins with await Task.Delay(1).

Verified stable across 6 consecutive full-suite runs (was ~1 in 5).
@GarrettBeatty

Copy link
Copy Markdown
Contributor Author

Unit tests now run in parallel + fixed flakes it exposed

Reworked run-unit-tests.ps1 to mirror run-integ-tests-parallel.ps1: build all discovered projects once serially (avoids the shared ProjectReference build-output race), then run dotnet test --no-build in parallel (throttle 5) with streamed project-prefixed output. All projects run and every failure is reported in one pass (failed projects' output reprinted as a clean block), then exits non-zero listing failures.

Note: this replaces the earlier fail-fast behavior — like the integ script, it now surfaces all failures rather than short-circuiting.

Running projects concurrently surfaced two pre-existing intra-assembly flakes (xUnit parallelizes collections within an assembly by default), both fixed:

  1. Hosting.Tests — many tests mutate the process-global AWS_LAMBDA_FUNCTION_NAME; AddAWSLambdaHosting_NotInLambda_DoesNotRegisterHostingOptions (needs it unset) raced tests that set it. Added AssemblyInfo.cs with [assembly: CollectionBehavior(DisableTestParallelization = true)].
  2. RuntimeSupport.UnitTestsPositiveHandlerTestsAsync (~1 in 5 under load) failed with "Can't find method name in console text": ExecHandlerAsync points a static logger action on the shared Amazon.Lambda.Core assembly at a per-invocation StringWriter, and LambdaBootstrap's background RunAsync writes through it, so concurrent tests cross writers. Disabled in-assembly parallelization there too, and replaced two tight busy-spin wait loops with await Task.Delay(1). Verified stable across 6 consecutive full-suite runs.

All fixes verified locally on net8.0/net10.0. (The only local-only failure is NativeAOTTests.EnsureNoTrimWarningsDuringPublish, which needs the C++ native linker absent on my dev box but present in CI.)

The last CI run's unit tests all passed but the build hung ~1h50m after
they finished, until the 2-hour AWS credential expired ('security token
expired'). Amazon.Lambda.AspNetCoreServer.Test was the one project that
never printed a completion line: TestSnapStartInitialization started a
LambdaBootstrap polling loop against a dead endpoint (localhost:123) as
fire-and-forget (_ = bootstrap.RunAsync(cts.Token)). After the assert the
leaked loop kept retrying (the log shows GET /api/SnapStart repeating), so
under parallel load dotnet test never exited.

- Keep the RunAsync task, assert before teardown, then cancel and await
  the task so no background work outlives the test. The dead-endpoint loop
  surfaces OperationCanceledException or HttpRequestException on teardown;
  both are expected and swallowed.
- Add AssemblyInfo.cs disabling in-assembly parallelization (same approach
  as Hosting.Tests and RuntimeSupport.UnitTests): this project shares the
  process-global AWS_LAMBDA_* env vars and the static SnapStartController
  .Invoked flag across tests.

Verified 193/193 on net8.0 and net10.0, stable across repeated runs, with
no hang.
@GarrettBeatty

Copy link
Copy Markdown
Contributor Author

Root-caused the CI hang ('security token expired')

That run wasn't a test failure — all 286 unit tests passed. The build hung for ~1h50m after the tests finished (17:26 → 19:15) until the 2-hour AWS role credential expired, which surfaced as The security token included in the request is expired.

Cause: 13 of 14 unit-test projects printed a completion line; the one that didn't was Amazon.Lambda.AspNetCoreServer.Test. Its TestSnapStartInitialization fired a LambdaBootstrap.RunAsync polling loop against a dead endpoint (localhost:123) as fire-and-forget (_ = bootstrap.RunAsync(cts.Token)). After the assert, the leaked loop kept retrying (the hung log shows GET /api/SnapStart repeating), so dotnet test never exited and the parallel ForEach-Object never completed.

Fix (9ae885ec):

  • Keep the RunAsync task; assert SnapStartController.Invoked first, then cancel and await the task so no background work outlives the test. The dead-endpoint loop surfaces OperationCanceledException or HttpRequestException on teardown — both expected, both swallowed.
  • Added AssemblyInfo.cs disabling in-assembly parallelization (same approach as Hosting.Tests and RuntimeSupport.UnitTests); this project shares process-global AWS_LAMBDA_* env vars and the static SnapStartController.Invoked flag.

Verified 193/193 on net8.0 and net10.0, stable across repeated runs, no hang.

Two CI runs hung ~1h50m (until the 2-hour credential expired) inside
Amazon.Lambda.AspNetCoreServer.Test. Its TestSnapStartInitialization drives
SnapStart init, which invokes the before-snapshot hooks accumulated in the
process-global Amazon.Lambda.Core.SnapshotRestore registry by every test's
function constructor; each hook runs in-process ASP.NET requests. Under the
CPU/host contention of the parallel pool that work deadlocked. The project
passes reliably on its own (193/193, ~8s), so run it serially after the
parallel pool instead of concurrently.

run-unit-tests.ps1 now splits discovered projects into a parallel pool and
a serial list (matched by file name); all are built once up front, the pool
runs with --no-build, then the serial projects run one at a time. Verified
locally: AspNetCoreServer.Test passes serially with no hang.
@GarrettBeatty

Copy link
Copy Markdown
Contributor Author

Second hang root-caused → run AspNetCoreServer.Test serially

The previous run hung the same way (unit tests started, then ~1h50m of silence until the credential expired). Same project: Amazon.Lambda.AspNetCoreServer.Test was again the only one of 14 that never printed a completion line — it stalled after ~64 of 193 tests, with a GET /api/SnapStart request started but never finished.

Root cause (deeper than the last fix): TestSnapStartInitialization drives SnapStart init, which invokes the before-snapshot hooks accumulated in the process-global Amazon.Lambda.Core.SnapshotRestore registry. Every test that constructs an APIGatewayHttpApiV2ProxyFunction (dozens of them) enqueues a hook there, and each hook runs in-process ASP.NET requests. When this test finally drains that global queue it replays them all; under the CPU/host contention of the parallel pool that in-process request work deadlocked. The project passes reliably on its own (193/193, ~8s).

Fix (d1bcd3a9): run-unit-tests.ps1 now splits discovered projects into a parallel pool and a serial list. All are built once up front; the pool runs concurrently with --no-build; then the serial projects (currently just AspNetCoreServer.Test) run one at a time afterward. The earlier hardening (SnapStart test tears down its bootstrap; DisableTestParallelization on this assembly) stays as defense-in-depth.

Verified locally end-to-end: the full script completes, AspNetCoreServer.Test passes serially with no hang. (The only local failure is NativeAOTTests.EnsureNoTrimWarningsDuringPublish, the C++-linker issue that's local-only and green in CI.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Release Not Needed Add this label if a PR does not need to be released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant