From 3f4769dd2e8012fea5fb295fa469e1bb77bd3b08 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Fri, 25 Sep 2026 10:37:07 -0400 Subject: [PATCH 1/2] Add DependencyModules.xUnit4 for xunit.v3 4.x xunit.v3 4.0 changed ISelfExecutingXunitTestCase.Run and made the old RunXunitTestCase throw. A ModuleTestCase built against 3.x therefore does not load on 4.x, and a run-time lookup cannot supply a missing interface method. DependencyModules.xUnit stays on [3.2.2,4.0.0). DependencyModules.xUnit4 compiles the same sources against [4.0.0,5.0.0), and Impl/ModuleTestCase.XunitMajor.cs holds the code that differs. The xUnit test projects run on both majors through XunitMajor. The 4.x leg uses xunit.v3.mtp-off, because Microsoft.Testing.Platform v2 refuses VSTest dotnet test on the .NET 10 SDK. A weekly workflow runs the tests on the newest xunit.v3, prereleases included. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build-package.yaml | 5 + .github/workflows/release.yaml | 4 + .github/workflows/xunit-prerelease.yaml | 50 ++++++ CONTRIBUTING.md | 19 ++- DependencyModules.sln | 15 ++ Directory.Build.props | 25 +++ README.md | 3 +- .../SutProject.Tests/SutProject.Tests.csproj | 7 +- .../WebApiApp.Tests/WebApiApp.Tests.csproj | 7 +- scripts/test-xunit.sh | 47 ++++++ scripts/verify-packages.sh | 25 +++ .../DependencyModules.Testing.csproj | 2 +- .../DependencyModules.xUnit.csproj | 27 +-- .../Impl/ModuleTestCase.XunitMajor.cs | 158 ++++++++++++++++++ .../Impl/ModuleTestCase.cs | 60 +------ .../Impl/ModuleTestDiscoverer.cs | 3 +- .../DependencyModules.xUnit4.csproj | 54 ++++++ .../ApiTests/PublicApiTests.cs | 12 ++ .../DependencyModules.Tests.csproj | 12 +- .../PublicApiTests.XUnit4Api.verified.txt | 45 +++++ .../xUnitTests/ModuleTestCaseDataTests.cs | 45 +++++ .../xUnitTests/ModuleTestDiscovererTests.cs | 6 + website/guide/testing-xunit.md | 19 ++- website/guide/testing.md | 7 +- 24 files changed, 574 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/xunit-prerelease.yaml create mode 100755 scripts/test-xunit.sh create mode 100644 src/DependencyModules.xUnit/Impl/ModuleTestCase.XunitMajor.cs create mode 100644 src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj create mode 100644 tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnit4Api.verified.txt diff --git a/.github/workflows/build-package.yaml b/.github/workflows/build-package.yaml index 2c8632c..5c88f41 100644 --- a/.github/workflows/build-package.yaml +++ b/.github/workflows/build-package.yaml @@ -70,6 +70,11 @@ jobs: name: coverage-report path: artifacts/coverage + # The coverage run is on xunit.v3 3.x. This runs the xUnit tests again on 4.x, against + # DependencyModules.xUnit4. + - name: Test on xunit.v3 4.x + run: ./scripts/test-xunit.sh 4 + # Packs and consumes the packages the way a real user would. Catches packaging faults # that a ProjectReference-based test suite cannot see. - name: Verify packages diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ca1636a..b417d71 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -74,6 +74,9 @@ jobs: cat artifacts/coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" fi + - name: Test on xunit.v3 4.x + run: ./scripts/test-xunit.sh 4 + - name: Verify packages run: ./scripts/verify-packages.sh "${{ steps.version.outputs.version }}" @@ -86,6 +89,7 @@ jobs: src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ + src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj \ src/DependencyModules.NUnit/DependencyModules.NUnit.csproj \ src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ src/DependencyModules.Moq/DependencyModules.Moq.csproj \ diff --git a/.github/workflows/xunit-prerelease.yaml b/.github/workflows/xunit-prerelease.yaml new file mode 100644 index 0000000..477dec1 --- /dev/null +++ b/.github/workflows/xunit-prerelease.yaml @@ -0,0 +1,50 @@ +name: xunit-prerelease + +# Runs the xUnit tests against the newest xunit.v3 on nuget.org, prereleases included. xUnit ships +# prereleases of a major for months before the release, and the 4.0 breaks were in them: the first +# on 2026-04-12, four months before 4.0.0. A failure here is notice of a coming break. Without it, +# the first report comes from a consumer. +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # See build-package.yaml: the 10.0 SDK builds, the 8.0 runtime runs the net8.0 tests. + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Find the newest xunit.v3 and runner + id: versions + run: | + newest() { + curl -fsSL "https://api.nuget.org/v3-flatcontainer/$1/index.json" | python3 -c ' + import json, sys + + def precedence(version): + release, _, prerelease = version.partition("-") + numbers = [int(part) for part in release.split(".")] + if not prerelease: + return (numbers, 1, []) + return (numbers, 0, [(0, int(part), "") if part.isdigit() else (1, 0, part) for part in prerelease.split(".")]) + + print(max(json.load(sys.stdin)["versions"], key=precedence)) + ' + } + echo "xunit=$(newest xunit.v3.mtp-off)" >> "$GITHUB_OUTPUT" + echo "runner=$(newest xunit.runner.visualstudio)" >> "$GITHUB_OUTPUT" + + # 4 is the newest DependencyModules xUnit build. Raise it when a build for a new major is added. + - name: Test on xunit.v3.mtp-off ${{ steps.versions.outputs.xunit }} + run: ./scripts/test-xunit.sh 4 "${{ steps.versions.outputs.xunit }}" "${{ steps.versions.outputs.runner }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f9a9b4..427ef57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,15 @@ To run all tests with code coverage, use the coverage script. The script writes ./scripts/coverage.sh 85 ``` -To do a test of the packages that a user gets, use the package script. The script packs the nine packages. Then it builds and runs a test application for each target framework. The test application references these packages. +The xUnit test projects use `xunit.v3` version 3 and `DependencyModules.xUnit`. To run them with `xunit.v3` version 4 and `DependencyModules.xUnit4`, use the xUnit script: + +```shell +./scripts/test-xunit.sh 4 +``` + +The `XunitMajor` property in `Directory.Build.props` selects the version. The script sets it to the major version that you give. + +To do a test of the packages that a user gets, use the package script. The script packs the ten packages. Then it builds and runs a test application for each target framework. The test application references these packages. ```shell ./scripts/verify-packages.sh @@ -63,7 +71,10 @@ The `build-package` workflow runs for each pull request to `main`. It does these 1. It does a check of the format with CSharpier. 2. It builds the solution. 3. It runs all tests with code coverage. The line coverage must be 85 percent or more. -4. It runs `scripts/verify-packages.sh`. +4. It runs the xUnit tests again with `xunit.v3` version 4. +5. It runs `scripts/verify-packages.sh`. + +The `xunit-prerelease` workflow runs each week. It runs the xUnit tests with the newest `xunit.v3` on nuget.org, prereleases included. A failure tells you about a change in xUnit before its release. ## Documentation @@ -96,8 +107,8 @@ git push origin v1.5.0 The `release` workflow then does these steps: 1. It builds the code. -2. It runs the tests. -3. It packs the nine packages. +2. It runs the tests with `xunit.v3` version 3 and version 4. +3. It packs the ten packages. 4. It publishes the packages to nuget.org and to GitHub Packages. 5. It makes a GitHub release with generated release notes. diff --git a/DependencyModules.sln b/DependencyModules.sln index 19d4acb..61573f4 100644 --- a/DependencyModules.sln +++ b/DependencyModules.sln @@ -46,6 +46,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.NUnit", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SutProject.NUnitTests", "integ-tests\SutProject.NUnitTests\SutProject.NUnitTests.csproj", "{C3B5B8CE-D219-42C8-A1B8-5C283C053E98}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.xUnit4", "src\DependencyModules.xUnit4\DependencyModules.xUnit4.csproj", "{D1AEA1C9-CD76-4664-956B-706962E78E3D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -272,6 +274,18 @@ Global {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x64.Build.0 = Release|Any CPU {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x86.ActiveCfg = Release|Any CPU {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x86.Build.0 = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|x64.Build.0 = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Debug|x86.Build.0 = Debug|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|Any CPU.Build.0 = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|x64.ActiveCfg = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|x64.Build.0 = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|x86.ActiveCfg = Release|Any CPU + {D1AEA1C9-CD76-4664-956B-706962E78E3D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -296,5 +310,6 @@ Global {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {C3B5B8CE-D219-42C8-A1B8-5C283C053E98} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} + {D1AEA1C9-CD76-4664-956B-706962E78E3D} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 7799100..f17ff18 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,6 +35,31 @@ net8.0;net10.0 + + + 3 + + + + xunit.v3 + 3.2.2 + 3.1.5 + DependencyModules.xUnit + + + + xunit.v3.mtp-off + 4.0.1 + 4.0.0 + DependencyModules.xUnit4 + + Ian Johnson Ian Johnson diff --git a/README.md b/README.md index 27de41f..18f4640 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ The `integ-tests` folder contains projects that reference the source projects of | --- | --- | | [DependencyModules.Runtime](https://www.nuget.org/packages/DependencyModules.Runtime/) | The attributes, the interfaces, and the `AddModule` methods. | | [DependencyModules.SourceGenerator](https://www.nuget.org/packages/DependencyModules.SourceGenerator/) | The source generator. It operates only when you compile. | -| [DependencyModules.xUnit](https://www.nuget.org/packages/DependencyModules.xUnit/) | `[ModuleTest]` for xUnit v3. | +| [DependencyModules.xUnit](https://www.nuget.org/packages/DependencyModules.xUnit/) | `[ModuleTest]` for xUnit v3, with `xunit.v3` version 3. | +| [DependencyModules.xUnit4](https://www.nuget.org/packages/DependencyModules.xUnit4/) | `[ModuleTest]` for xUnit v3, with `xunit.v3` version 4. | | [DependencyModules.NUnit](https://www.nuget.org/packages/DependencyModules.NUnit/) | `[ModuleTest]` and `[ModuleTestCase]` for NUnit 4. | | [DependencyModules.Testing](https://www.nuget.org/packages/DependencyModules.Testing/) | The test attributes and interfaces that the xUnit and NUnit packages use. | | [DependencyModules.NSubstitute](https://www.nuget.org/packages/DependencyModules.NSubstitute/) | `[Mock]` parameters with NSubstitute. | diff --git a/integ-tests/SutProject.Tests/SutProject.Tests.csproj b/integ-tests/SutProject.Tests/SutProject.Tests.csproj index f341eec..dcda87a 100644 --- a/integ-tests/SutProject.Tests/SutProject.Tests.csproj +++ b/integ-tests/SutProject.Tests/SutProject.Tests.csproj @@ -12,7 +12,7 @@ - + @@ -24,11 +24,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj b/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj index fa0c3bb..8c9c0f0 100644 --- a/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj +++ b/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj @@ -9,7 +9,7 @@ - + @@ -20,10 +20,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/scripts/test-xunit.sh b/scripts/test-xunit.sh new file mode 100755 index 0000000..4fed26c --- /dev/null +++ b/scripts/test-xunit.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Runs the xUnit test projects against one xunit.v3 major. The default build and the coverage +# script use major 3. CI runs this for 4. +# +# Usage: +# scripts/test-xunit.sh 4 the versions Directory.Build.props sets for 4 +# scripts/test-xunit.sh 4 4.1.0-pre.12 4.0.0 a given xunit.v3.mtp-off and runner version + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAJOR="${1:?usage: scripts/test-xunit.sh [xunit.v3 version] [runner version]}" + +PROPERTIES=("-p:XunitMajor=${MAJOR}") +if [ -n "${2:-}" ]; then PROPERTIES+=("-p:XunitV3Version=$2"); fi +if [ -n "${3:-}" ]; then PROPERTIES+=("-p:XunitRunnerVersion=$3"); fi + +PROJECTS=( + "tests/DependencyModules.Tests/DependencyModules.Tests.csproj" + "integ-tests/SutProject.Tests/SutProject.Tests.csproj" + "integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj" +) + +TEST_LOG="$(mktemp)" +trap 'rm -f "${TEST_LOG}"' EXIT + +for project in "${PROJECTS[@]}"; do + echo "==> ${project} (xunit.v3 major ${MAJOR})" + dotnet test "${REPO_ROOT}/${project}" \ + --configuration Release \ + --nologo \ + "${PROPERTIES[@]}" | tee -a "${TEST_LOG}" + + # tee masks the exit status, so consult the pipeline's first element. + status="${PIPESTATUS[0]}" + [ "${status}" -eq 0 ] || exit "${status}" +done + +# The same guard as scripts/coverage.sh: xUnit drops a test case whose unique ID collides with +# one already discovered, and the run stays green. +if grep -q "duplicate ID" "${TEST_LOG}"; then + echo >&2 + echo "FAIL: xUnit skipped a test case with a duplicate unique ID. Tests were silently dropped." >&2 + grep "duplicate ID" "${TEST_LOG}" >&2 + exit 1 +fi diff --git a/scripts/verify-packages.sh b/scripts/verify-packages.sh index 5f21639..ba3327a 100755 --- a/scripts/verify-packages.sh +++ b/scripts/verify-packages.sh @@ -60,6 +60,7 @@ for proj in \ src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ + src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj \ src/DependencyModules.NUnit/DependencyModules.NUnit.csproj \ src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ src/DependencyModules.Moq/DependencyModules.Moq.csproj \ @@ -98,6 +99,7 @@ LIB_PACKAGES=( DependencyModules.Runtime DependencyModules.Testing DependencyModules.xUnit + DependencyModules.xUnit4 DependencyModules.NUnit DependencyModules.NSubstitute DependencyModules.Moq @@ -131,6 +133,29 @@ for tfm, body in re.findall(r"(.*?)" pass "${id} dependency groups are framework-matched" done +# A test package is built against one major of its framework and must stop below the next. A bare +# version packs as an open range, and NuGet then unifies a test project's newer major into it, +# which loads nothing at run time and warns about nothing at restore. +for pair in \ + DependencyModules.xUnit:xunit.v3.extensibility.core \ + DependencyModules.xUnit4:xunit.v3.extensibility.core \ + DependencyModules.NUnit:NUnit; do + id="${pair%%:*}" + dependency="${pair#*:}" + unzip -p "${FEED}/${id}.${VERSION}.nupkg" "${id}.nuspec" | python3 -c ' +import re, sys +dependency = sys.argv[1] +ranges = set(re.findall(r"id=\"" + re.escape(dependency) + r"\" version=\"([^\"]+)\"", sys.stdin.read())) +if not ranges: + sys.exit(f" no dependency on {dependency}") +for value in ranges: + match = re.fullmatch(r"\[(\d+)\.\d+\.\d+, (\d+)\.0\.0\)", value) + if not match or int(match.group(2)) != int(match.group(1)) + 1: + sys.exit(f" {dependency} is {value}, expected [lowest, next major.0.0)") +' "${dependency}" || fail "${id}: its ${dependency} range does not stop below the next major" + pass "${id} stops ${dependency} below the next major" +done + # Placeholder metadata must never ship. for pkg in "${FEED}"/*.nupkg; do id="$(basename "${pkg}" ".${VERSION}.nupkg")" diff --git a/src/DependencyModules.Testing/DependencyModules.Testing.csproj b/src/DependencyModules.Testing/DependencyModules.Testing.csproj index 3ff2aa8..2fed18f 100644 --- a/src/DependencyModules.Testing/DependencyModules.Testing.csproj +++ b/src/DependencyModules.Testing/DependencyModules.Testing.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.Testing - The test attributes and interfaces of DependencyModules: [Mock], [InjectValues], [Shared], [TestExport], and the interfaces for mock libraries and test attributes. It also contains the helpers that find the attributes of a test on the method, the class, and the assembly. It does not depend on a test framework. The DependencyModules.xUnit and DependencyModules.NUnit packages use this package. Add one of those packages to a test project. + The test attributes and interfaces of DependencyModules: [Mock], [InjectValues], [Shared], [TestExport], and the interfaces for mock libraries and test attributes. It also contains the helpers that find the attributes of a test on the method, the class, and the assembly. It does not depend on a test framework. The DependencyModules.xUnit, DependencyModules.xUnit4, and DependencyModules.NUnit packages use this package. Add one of those packages to a test project. true diff --git a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj index c54c3b0..80cefae 100644 --- a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj +++ b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.xUnit - xUnit v3 support for DependencyModules. [ModuleTest] builds a service provider from your modules for each test and each data row. The test parameters get their values from this service provider. + xUnit v3 support for DependencyModules, for xunit.v3 version 3. [ModuleTest] builds a service provider from your modules for each test and each data row. The test parameters get their values from this service provider. For xunit.v3 version 4, use DependencyModules.xUnit4. true @@ -22,17 +22,20 @@ diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.XunitMajor.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.XunitMajor.cs new file mode 100644 index 0000000..f502ed2 --- /dev/null +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.XunitMajor.cs @@ -0,0 +1,158 @@ +using Xunit.Sdk; +using Xunit.v3; + +namespace DependencyModules.xUnit.Impl; + +// The members of ModuleTestCase whose xUnit signatures differ between xunit.v3 majors, and nothing +// else. DependencyModules.xUnit compiles this file against 3.x. DependencyModules.xUnit4 compiles the +// same sources against 4.x with XUNIT_V4 defined. A later major adds a branch here and a project of +// its own. +public partial class ModuleTestCase +{ +#if XUNIT_V4 + /// + /// Runs the case the way xUnit would have, and disposes every container it built once the + /// run has returned - the tests passed, failed, were skipped or were cancelled alike. + /// + /// + /// XunitRunnerHelper.RunXunitTestCase is what the method runner calls for a case that + /// does not execute itself: it creates the tests, turns a failure or a dynamic skip during + /// creation into the case's result, and hands the tests to . + /// Wrapping that call is the whole of the difference. Disposal is per case, which for every + /// test but a data-driven one is per test; the rows of a data-driven test share the case and + /// are released together when the last has run. + /// + public async ValueTask Run( + ExplicitOption explicitOption, + IMessageBus messageBus, + object?[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, + ParallelMode parallelMode, + ExecutionScheduler scheduler, + FixtureMappingManager methodFixtureMappings + ) + { + try + { + return await XunitRunnerHelper.RunXunitTestCase( + testCase: this, + messageBus: messageBus, + cancellationTokenSource: cancellationTokenSource, + parallelMode: parallelMode, + scheduler: scheduler, + aggregator: aggregator, + explicitOption: explicitOption, + constructorArguments: constructorArguments, + methodFixtureMappings: methodFixtureMappings + ); + } + finally + { + await DisposeProviders(); + } + } +#else + /// + /// Runs the case the way xUnit would have, and disposes every container it built once the + /// run has returned - the tests passed, failed, were skipped or were cancelled alike. + /// + /// + /// is what the method runner calls for a + /// case that does not execute itself: it creates the tests, turns a failure or a dynamic skip + /// during creation into the case's result, and hands the tests to + /// . Wrapping that call is the whole of the difference. + /// Disposal is per case, which for every test but a data-driven one is per test; the rows of + /// a data-driven test share the case and are released together when the last has run. + /// + public async ValueTask Run( + ExplicitOption explicitOption, + IMessageBus messageBus, + object?[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource + ) + { + try + { + return await XunitRunnerHelper.RunXunitTestCase( + testCase: this, + messageBus: messageBus, + cancellationTokenSource: cancellationTokenSource, + aggregator: aggregator, + explicitOption: explicitOption, + constructorArguments: constructorArguments + ); + } + finally + { + await DisposeProviders(); + } + } +#endif + + /// + /// Creates one test of this case from values already resolved against the case. + /// + /// + /// and are the data row the test comes + /// from and the attribute that gave it, or null for a test without data. 4.x reads a label and + /// a parallelization choice from them, which 3.x does not have. + /// + /// Named throughout: has a second constructor that takes a uniqueID + /// string near where this passes testIndex, and positionally the two are told apart by the + /// argument's type alone. + /// + private XunitTest CreateTest( + string? skipReason, + Type? skipType, + string? skipUnless, + string? skipWhen, + string testDisplayName, + int testIndex, + IReadOnlyDictionary> traits, + int timeout, + object?[] testMethodArguments, + Xunit.ITheoryDataRow? row, + IDataAttribute? dataAttribute + ) + { +#if XUNIT_V4 + return new XunitTest( + testCase: this, + testMethod: TestMethod, + @explicit: Explicit, + skipReason: skipReason, + skipType: skipType, + skipUnless: skipUnless, + skipWhen: skipWhen, + testDisplayName: testDisplayName, + testIndex: testIndex, + traits: traits, + timeout: timeout, + testMethodArguments: testMethodArguments, + // What [Theory] passes for a row and [Fact] for a test without data. Left to the + // obsolete constructor, a row marked DisableParallelization would run in parallel. + testLabel: row?.Label ?? TestLabel, + disableParallelization: row?.DisableParallelization + ?? dataAttribute?.DisableParallelization + ?? DisableParallelization + ); +#else + return new XunitTest( + testCase: this, + testMethod: TestMethod, + @explicit: Explicit, + skipReason: skipReason, + skipType: skipType, + skipUnless: skipUnless, + skipWhen: skipWhen, + testDisplayName: testDisplayName, + testIndex: testIndex, + traits: traits, + timeout: timeout, + testMethodArguments: testMethodArguments + ); +#endif + } +} diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index 461e862..c27300c 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -22,7 +22,7 @@ namespace DependencyModules.xUnit.Impl; /// ModuleTestCommand has always disposed in a finally around the test; this is the /// same lifetime for xUnit. /// -public class ModuleTestCase : XunitTestCase, ISelfExecutingXunitTestCase +public partial class ModuleTestCase : XunitTestCase, ISelfExecutingXunitTestCase { /// /// One per container this case built: one for a plain test, one per row for a data-driven @@ -292,43 +292,6 @@ IEnumerable knownAttributes DependencyRegistry.LoadModules(serviceCollection, modules.ToArray()); } - /// - /// Runs the case the way xUnit would have, and disposes every container it built once the - /// run has returned - the tests passed, failed, were skipped or were cancelled alike. - /// - /// - /// is what the method runner calls for a - /// case that does not execute itself: it creates the tests, turns a failure or a dynamic skip - /// during creation into the case's result, and hands the tests to - /// . Wrapping that call is the whole of the difference. - /// Disposal is per case, which for every test but a data-driven one is per test; the rows of - /// a data-driven test share the case and are released together when the last has run. - /// - public async ValueTask Run( - ExplicitOption explicitOption, - IMessageBus messageBus, - object?[] constructorArguments, - ExceptionAggregator aggregator, - CancellationTokenSource cancellationTokenSource - ) - { - try - { - return await XunitRunnerHelper.RunXunitTestCase( - this, - messageBus, - cancellationTokenSource, - aggregator, - explicitOption, - constructorArguments - ); - } - finally - { - await DisposeProviders(); - } - } - private async ValueTask DisposeProviders() { var providers = _providers.ToArray(); @@ -419,13 +382,7 @@ IDataAttribute[] dataAttributes var startupValues = await SetupServiceCollection(); unitTests.Add( - // testIndex is named for more than readability: XunitTest has a second - // nine-parameter constructor differing only at this position, taking a uniqueID - // string. Positionally the two are told apart by the argument's type alone. - new XunitTest( - testCase: this, - testMethod: TestMethod, - @explicit: Explicit, + CreateTest( skipReason: theoryDataRow.Skip ?? SkipReason, // The row's own conditional-skip metadata takes precedence over the case's, // matching how skipReason above already defers to it. These became required @@ -442,7 +399,9 @@ IDataAttribute[] dataAttributes .GetTraits(TestMethod, theoryDataRow) .ToReadOnlyTraits(), timeout: theoryDataRow.Timeout ?? Timeout, - testMethodArguments: await ResolveArguments(data, startupValues) + testMethodArguments: await ResolveArguments(data, startupValues), + row: theoryDataRow, + dataAttribute: dataAttribute ) ); } @@ -513,10 +472,7 @@ private async Task> UnitTestWithNoDataAttributes return [ - new XunitTest( - testCase: this, - testMethod: TestMethod, - @explicit: Explicit, + CreateTest( skipReason: SkipReason, skipType: SkipType, skipUnless: SkipUnless, @@ -525,7 +481,9 @@ private async Task> UnitTestWithNoDataAttributes testIndex: 0, traits: Traits.ToReadOnlyTraits(), timeout: Timeout, - testMethodArguments: await ResolveArguments([], startupValues) + testMethodArguments: await ResolveArguments([], startupValues), + row: null, + dataAttribute: null ), ]; } diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestDiscoverer.cs b/src/DependencyModules.xUnit/Impl/ModuleTestDiscoverer.cs index f49adad..c33d44d 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestDiscoverer.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestDiscoverer.cs @@ -47,7 +47,8 @@ IFactAttribute factAttribute // second GetTestCaseDetails taking a trailing label, and since every added parameter on // both is optional, a three-argument call matches the two equally well and is ambiguous. // Naming a parameter only the newer one declares resolves it. A module test has no label, - // which is what null says. + // which is what null says. 4.x has only the one overload, with displayNameIndex added, and + // the DependencyModules.xUnit4 build binds the same call to it. var details = TestIntrospectionHelper.GetTestCaseDetails( discoveryOptions, testMethod, diff --git a/src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj b/src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj new file mode 100644 index 0000000..0a31985 --- /dev/null +++ b/src/DependencyModules.xUnit4/DependencyModules.xUnit4.csproj @@ -0,0 +1,54 @@ + + + + $(LibraryTargetFrameworks) + enable + enable + True + DependencyModules.xUnit4 + $(DefineConstants);XUNIT_V4 + xUnit v3 support for DependencyModules, for xunit.v3 version 4. [ModuleTest] builds a service provider from your modules for each test and each data row. The test parameters get their values from this service provider. For xunit.v3 version 3, use DependencyModules.xUnit. + true + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs index e28ad66..ea4bae4 100644 --- a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs +++ b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs @@ -27,11 +27,23 @@ public void RuntimeApi() Snapshot.Match(ApiOf(typeof(DependencyModuleAttribute))); } +#if XUNIT_V4 + /// + /// DependencyModules.xUnit4, which is the xUnit package's sources built against xunit.v3 4.x. + /// The two surfaces differ where the xUnit types they expose differ. + /// + [Fact] + public void XUnit4Api() + { + Snapshot.Match(ApiOf(typeof(ModuleTestAttribute))); + } +#else [Fact] public void XUnitApi() { Snapshot.Match(ApiOf(typeof(ModuleTestAttribute))); } +#endif /// /// The NUnit integration. Its [ModuleTest] shares a name with xUnit's and nothing else — diff --git a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj index 282c4c7..314533c 100644 --- a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj +++ b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj @@ -7,12 +7,17 @@ False + + + $(DefineConstants);XUNIT_V4 + + - + + + + diff --git a/src/DependencyModules.Testing/Impl/CurrentTest.cs b/src/DependencyModules.Testing/Impl/CurrentTest.cs new file mode 100644 index 0000000..9960a22 --- /dev/null +++ b/src/DependencyModules.Testing/Impl/CurrentTest.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; + +namespace DependencyModules.Testing.Impl; + +/// +/// The running test, read through the that the runner package +/// installed. +/// +/// +/// +/// DependencyModules.xUnit, DependencyModules.xUnit4 and DependencyModules.NUnit install their +/// provider before the first of their tests runs. Without a runner package is +/// null, and every member answers as if no test were running. +/// +/// +/// Settable, so that a test of code which reads this can put a provider of its own in place. A runner +/// installs its provider only when none is set. +/// +/// +public static class CurrentTest +{ + /// + /// The provider every member reads through, or null when no runner package has installed one. + /// + public static ICurrentTestProvider? Provider { get; set; } + + /// + public static object? Key => Provider?.Key; + + /// + public static string? DisplayName => Provider?.DisplayName; + + /// + public static Assembly? Assembly => Provider?.Assembly; + + /// + public static bool TryWriteLine(string message) => Provider?.TryWriteLine(message) ?? false; +} diff --git a/src/DependencyModules.Testing/Impl/TestOutputLoggerProvider.cs b/src/DependencyModules.Testing/Impl/TestOutputLoggerProvider.cs new file mode 100644 index 0000000..0b4b7f2 --- /dev/null +++ b/src/DependencyModules.Testing/Impl/TestOutputLoggerProvider.cs @@ -0,0 +1,119 @@ +using System.Collections.Concurrent; +using System.Text; +using DependencyModules.Testing.Attributes.Interfaces; +using Microsoft.Extensions.Logging; + +namespace DependencyModules.Testing.Impl; + +/// +/// A logger provider that writes each entry to the output of the running test. +/// +/// +/// +/// An entry is one line in the console logger's single-line format, +/// info: Shop.OrderService[0] Order 42 accepted, with an exception on the lines after it. +/// An entry logged while no test is running is dropped, because there is no output to write it to. +/// +/// +/// Registered in a test's container as an , it puts the log of the +/// application under test into the test's output. The container's logger factory writes to it +/// beside any other provider. +/// +/// +public sealed class TestOutputLoggerProvider : ILoggerProvider +{ + private readonly ICurrentTestProvider? _currentTest; + + private readonly ConcurrentDictionary _loggers = new( + StringComparer.Ordinal + ); + + /// + /// Writes through , read again for each entry. + /// + public TestOutputLoggerProvider() { } + + /// + /// Writes through . + /// + public TestOutputLoggerProvider(ICurrentTestProvider currentTest) + { + ArgumentNullException.ThrowIfNull(currentTest); + + _currentTest = currentTest; + } + + /// + public ILogger CreateLogger(string categoryName) => + _loggers.GetOrAdd(categoryName, name => new TestOutputLogger(name, this)); + + /// + public void Dispose() { } + + private void Write(string entry) => (_currentTest ?? CurrentTest.Provider)?.TryWriteLine(entry); + + private sealed class TestOutputLogger(string categoryName, TestOutputLoggerProvider provider) + : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + ArgumentNullException.ThrowIfNull(formatter); + + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + + // What the console logger does with an entry that has nothing to show. + if (string.IsNullOrEmpty(message) && exception == null) + { + return; + } + + var entry = new StringBuilder() + .Append(LevelName(logLevel)) + .Append(": ") + .Append(categoryName) + .Append('[') + .Append(eventId.Id) + .Append(']'); + + if (!string.IsNullOrEmpty(message)) + { + entry.Append(' ').Append(message); + } + + if (exception != null) + { + entry.AppendLine().Append(exception); + } + + provider.Write(entry.ToString()); + } + + private static string LevelName(LogLevel logLevel) => + logLevel switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => logLevel.ToString(), + }; + } +} diff --git a/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs b/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs index d76a20c..2c8c444 100644 --- a/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs +++ b/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs @@ -23,6 +23,9 @@ namespace DependencyModules.xUnit.Attributes; [AttributeUsage(AttributeTargets.Method)] public class ModuleTestAttribute : FactAttribute, IModuleTestAttribute { + // xUnit creates the attribute while it discovers the tests, before the first one runs. + static ModuleTestAttribute() => XunitCurrentTestProvider.Install(); + /// /// Marks a test method, taking no modules. /// diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index c27300c..cf0fd70 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -30,6 +30,10 @@ public partial class ModuleTestCase : XunitTestCase, ISelfExecutingXunitTestCase /// private readonly List _providers = []; + // Here as well as on ModuleTestAttribute, because an attribute of another library can name + // ModuleTestDiscoverer, and then this is the first of the package's types that xUnit creates. + static ModuleTestCase() => XunitCurrentTestProvider.Install(); + #pragma warning disable CS0618 // Type or member is obsolete /// /// Represents a specialized implementation of diff --git a/src/DependencyModules.xUnit/Impl/XunitCurrentTestProvider.cs b/src/DependencyModules.xUnit/Impl/XunitCurrentTestProvider.cs new file mode 100644 index 0000000..a14eda0 --- /dev/null +++ b/src/DependencyModules.xUnit/Impl/XunitCurrentTestProvider.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Xunit; +using Xunit.v3; + +namespace DependencyModules.xUnit.Impl; + +/// +/// Reads the running test from xUnit's . +/// +/// +/// +/// Installed from the static constructors of and of +/// ModuleTestAttribute. xUnit creates both while it discovers or deserializes the tests, +/// before the first one runs. +/// +/// +/// xUnit has no running test while a case builds its containers: the case creates its tests, and +/// the container for each, before xUnit runs the first of them. So and +/// are null in the hooks that build a container, and a line written there +/// is dropped. answers there, from the test class. +/// +/// +internal sealed class XunitCurrentTestProvider : ICurrentTestProvider +{ + public object? Key => TestContext.Current.Test; + + public string? DisplayName => TestContext.Current.Test?.TestDisplayName; + + public Assembly? Assembly => + TestContext.Current.TestClass is IXunitTestClass testClass + ? testClass.Class.Assembly + : null; + + public bool TryWriteLine(string message) + { + if (TestContext.Current.TestOutputHelper is not { } output) + { + return false; + } + + try + { + output.WriteLine(message); + + return true; + } + // The flow still carries the context of a test that has finished, and xUnit's helper + // refuses output that it can no longer report. + catch (InvalidOperationException) + { + return false; + } + } + + public static void Install() => CurrentTest.Provider ??= new XunitCurrentTestProvider(); +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt index ac0779e..87d8791 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -31,6 +31,13 @@ namespace DependencyModules.Testing.Attributes } namespace DependencyModules.Testing.Attributes.Interfaces { + public interface ICurrentTestProvider + { + System.Reflection.Assembly? Assembly { get; } + string? DisplayName { get; } + object? Key { get; } + bool TryWriteLine(string message); + } public interface IInjectValueAttribute { object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter); @@ -94,6 +101,14 @@ namespace DependencyModules.Testing.Impl public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.ParameterInfo parameterInfo) where T : class { } } + public static class CurrentTest + { + public static System.Reflection.Assembly? Assembly { get; } + public static string? DisplayName { get; } + public static object? Key { get; } + public static DependencyModules.Testing.Attributes.Interfaces.ICurrentTestProvider? Provider { get; set; } + public static bool TryWriteLine(string message) { } + } public static class SharedRegistrations { public static System.Collections.Generic.IReadOnlyCollection Collect(System.Reflection.MethodInfo method, System.Collections.Generic.IEnumerable knownAttributes) { } @@ -106,6 +121,13 @@ namespace DependencyModules.Testing.Impl public void Initialize(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.IServiceProvider pinned, System.Collections.Generic.IReadOnlyCollection pinnedServices, System.Func build, System.Func start, System.Action track) { } public void Initialize(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.IServiceProvider pinned, System.Collections.Generic.IReadOnlyCollection pinnedServices, System.Collections.Generic.IReadOnlyCollection namedServices, System.Func build, System.Func start, System.Action track) { } } + public sealed class TestOutputLoggerProvider : Microsoft.Extensions.Logging.ILoggerProvider, System.IDisposable + { + public TestOutputLoggerProvider() { } + public TestOutputLoggerProvider(DependencyModules.Testing.Attributes.Interfaces.ICurrentTestProvider currentTest) { } + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) { } + public void Dispose() { } + } public sealed class TestParameterResolver { public TestParameterResolver(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod) { } diff --git a/tests/DependencyModules.Tests/TestingTests/TestOutputLoggerProviderTests.cs b/tests/DependencyModules.Tests/TestingTests/TestOutputLoggerProviderTests.cs new file mode 100644 index 0000000..8993429 --- /dev/null +++ b/tests/DependencyModules.Tests/TestingTests/TestOutputLoggerProviderTests.cs @@ -0,0 +1,132 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace DependencyModules.Tests.TestingTests; + +/// +/// What the logger writes, read from a provider of the test's own rather than from a framework's +/// output. +/// +/// +/// Through the constructor that takes a provider, because CurrentTest.Provider is one static +/// for the whole assembly, and this assembly runs its test classes in parallel. The path through +/// CurrentTest is asserted under each framework, in SutProject.Tests and +/// SutProject.NUnitTests. +/// +public class TestOutputLoggerProviderTests +{ + private sealed class RecordingTest(bool running = true) : ICurrentTestProvider + { + public List Lines { get; } = []; + + public object? Key => running ? this : null; + + public string? DisplayName => running ? nameof(RecordingTest) : null; + + public Assembly? Assembly => null; + + public bool TryWriteLine(string message) + { + if (!running) + { + return false; + } + + Lines.Add(message); + + return true; + } + } + + [Fact] + public void AnEntryIsOneLineInTheConsoleLoggersSingleLineFormat() + { + var test = new RecordingTest(); + + new TestOutputLoggerProvider(test) + .CreateLogger("Shop.OrderService") + .LogInformation(new EventId(7), "Order {Id} accepted", 42); + + Assert.Equal(["info: Shop.OrderService[7] Order 42 accepted"], test.Lines); + } + + [Theory] + [InlineData(LogLevel.Trace, "trce")] + [InlineData(LogLevel.Debug, "dbug")] + [InlineData(LogLevel.Information, "info")] + [InlineData(LogLevel.Warning, "warn")] + [InlineData(LogLevel.Error, "fail")] + [InlineData(LogLevel.Critical, "crit")] + public void EachLevelHasTheConsoleLoggersName(LogLevel level, string name) + { + var test = new RecordingTest(); + + new TestOutputLoggerProvider(test).CreateLogger("Category").Log(level, "message"); + + Assert.Equal([$"{name}: Category[0] message"], test.Lines); + } + + [Fact] + public void AnExceptionIsOnTheLinesAfterTheEntry() + { + var test = new RecordingTest(); + var exception = new InvalidOperationException("The order was rejected."); + + new TestOutputLoggerProvider(test).CreateLogger("Category").LogError(exception, "failed"); + + Assert.Equal(["fail: Category[0] failed" + Environment.NewLine + exception], test.Lines); + } + + [Fact] + public void NoneIsNotEnabled() + { + var test = new RecordingTest(); + var logger = new TestOutputLoggerProvider(test).CreateLogger("Category"); + + logger.Log(LogLevel.None, "message"); + + Assert.False(logger.IsEnabled(LogLevel.None)); + Assert.Empty(test.Lines); + } + + [Fact] + public void AnEntryWithNothingToShowIsDropped() + { + var test = new RecordingTest(); + + new TestOutputLoggerProvider(test).CreateLogger("Category").LogInformation(""); + + Assert.Empty(test.Lines); + } + + /// + /// The case a logger meets on a background thread after its test has finished. A logger that + /// threw here would fail the application under test instead of the test. + /// + [Fact] + public void AnEntryLoggedWhileNoTestIsRunningIsDropped() + { + var test = new RecordingTest(running: false); + + new TestOutputLoggerProvider(test).CreateLogger("Category").LogInformation("message"); + + Assert.Empty(test.Lines); + } + + [Fact] + public void AScopeChangesNothing() + { + var test = new RecordingTest(); + var logger = new TestOutputLoggerProvider(test).CreateLogger("Category"); + + using (logger.BeginScope("scope")) + { + logger.LogInformation("message"); + } + + Assert.Equal(["info: Category[0] message"], test.Lines); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 38e0c78..4c08281 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -67,6 +67,7 @@ export default defineConfig({ { text: 'NUnit', link: '/guide/testing-nunit' }, { text: 'Mocks', link: '/guide/testing-mocking' }, { text: 'More service providers', link: '/guide/testing-container-source' }, + { text: 'CurrentTest and test output', link: '/guide/testing-current-test' }, ], }, { diff --git a/website/guide/testing-current-test.md b/website/guide/testing-current-test.md new file mode 100644 index 0000000..d4be401 --- /dev/null +++ b/website/guide/testing-current-test.md @@ -0,0 +1,94 @@ +# `CurrentTest` and test output + +`CurrentTest` gives information about the test that runs. Use it in code that does not get the test as a parameter, for example an assertion helper or a logger. This code does not need a reference to a test framework. `CurrentTest` is in the `DependencyModules.Testing.Impl` namespace of the `DependencyModules.Testing` package. + +## Members + +| Member | Value | +| --- | --- | +| `Key` | The object of the test framework for the test that runs. Compare it by reference. Two tests that run at the same time have different keys. | +| `DisplayName` | The display name of the test that runs. | +| `Assembly` | The assembly that contains the class of the test. | +| `TryWriteLine(message)` | Writes a line to the output of the test. | +| `Provider` | The `ICurrentTestProvider` that gives these values. | + +If no test runs, `Key`, `DisplayName`, and `Assembly` are `null`. Also, `TryWriteLine` writes nothing and returns `false`. + +The test framework keeps the `Key` object until the test ends. Thus you can keep data for each test in a `ConditionalWeakTable` with `Key` as the key. + +```csharp +using System.Runtime.CompilerServices; +using DependencyModules.Testing.Impl; + +namespace Shop.Tests; + +public static class LastOrder +{ + private static readonly ConditionalWeakTable Orders = new(); + + public static void Record(string orderId) + { + if (CurrentTest.Key is { } test) + { + Orders.AddOrUpdate(test, orderId); + } + } + + public static string? Get() => + CurrentTest.Key is { } test && Orders.TryGetValue(test, out var orderId) ? orderId : null; +} +``` + +## The test packages + +Each test package sets `CurrentTest.Provider` before its first test runs. Thus `CurrentTest` operates with all the test packages. + +| Package | Source of the values | +| --- | --- | +| `DependencyModules.xUnit` and `DependencyModules.xUnit4` | `TestContext.Current` of xUnit | +| `DependencyModules.NUnit` | `TestExecutionContext.CurrentContext` of NUnit | + +A test package does not replace a provider that is already set. + +## Differences between the test frameworks + +In xUnit, the test package builds the service provider before xUnit starts the test. Thus, in an `ITestServiceSetupAttribute` or an `ITestStartupAttribute`, `Key` and `DisplayName` are `null`. `TryWriteLine` returns `false`, but `Assembly` has a value. + +In NUnit, the test package builds the service provider in the test. Thus all values are available in these attributes. + +In NUnit, all iterations of a `[Repeat]` or `[Retry]` test have the same `Key`. NUnit does not make a different object for each iteration. Each iteration still gets a new service provider. + +## Write the log to the test output + +`TestOutputLoggerProvider` is an `ILoggerProvider` that writes each log entry to the output of the test that runs. It is in the `DependencyModules.Testing.Impl` namespace. Register it in the service collection of the test. Then the logs of your application show in the output of the test. + +```csharp +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Shop.Tests; + +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] +public class TestLoggingAttribute : Attribute, ITestServiceSetupAttribute +{ + public void SetupServiceCollection( + ITestMethodContext testMethod, + IServiceCollection serviceCollection + ) => + serviceCollection.AddSingleton(new TestOutputLoggerProvider()); +} +``` + +Put `[assembly: TestLogging]` in the test project to use the logger for all tests. The logger factory writes to all `ILoggerProvider` services in the service provider. Thus the other providers also get each entry. + +Each entry is one line, in the single-line format of the console logger: + +```text +info: Shop.OrderService[0] Order 42 accepted +``` + +If the entry has an exception, the exception is on the subsequent lines. If no test runs, the logger does not write the entry. For example, this occurs when a background task writes an entry after its test ends. + +The default constructor uses `CurrentTest.Provider`. To use a different `ICurrentTestProvider`, give it to the constructor. diff --git a/website/guide/testing-nunit.md b/website/guide/testing-nunit.md index d80c994..ab7c01f 100644 --- a/website/guide/testing-nunit.md +++ b/website/guide/testing-nunit.md @@ -83,3 +83,5 @@ The `ITestCaseInfo` interface in the `DependencyModules.NUnit.Impl` namespace ha | `TestMethod` | The NUnit `TestMethod`. | | `TestMethodArguments` | The values of the parameters. | | `TestMethodAttributes` | The attributes of the test method, the test class, and the assembly. | + +Code that does not get the test as a parameter can use `CurrentTest`. In NUnit, all iterations of a `[Repeat]` or `[Retry]` test have the same `CurrentTest.Key`. For more information, refer to [`CurrentTest` and test output](./testing-current-test.md). diff --git a/website/guide/testing-xunit.md b/website/guide/testing-xunit.md index 9978a10..b37f750 100644 --- a/website/guide/testing-xunit.md +++ b/website/guide/testing-xunit.md @@ -150,3 +150,5 @@ public class InfoTests } } ``` + +Code that does not get the test as a parameter can use `CurrentTest`. In xUnit, `CurrentTest` has no test while the test package builds the service provider. For more information, refer to [`CurrentTest` and test output](./testing-current-test.md). diff --git a/website/guide/testing.md b/website/guide/testing.md index 01dc52a..72294a4 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -9,7 +9,7 @@ The test packages build a service provider from your modules for each test. The | `DependencyModules.xUnit` | `[ModuleTest]` for xUnit v3, with `xunit.v3` version 3. | | `DependencyModules.xUnit4` | `[ModuleTest]` for xUnit v3, with `xunit.v3` version 4. | | `DependencyModules.NUnit` | `[ModuleTest]` and `[ModuleTestCase]` for NUnit 4. | -| `DependencyModules.Testing` | The attributes and interfaces that the test packages use. The test packages reference this package. | +| `DependencyModules.Testing` | The attributes and interfaces that the test packages use, `CurrentTest`, and `TestOutputLoggerProvider`. The test packages reference this package. | | `DependencyModules.NSubstitute` | `[NSubstituteSupport]` for mocks. | | `DependencyModules.Moq` | `[MoqSupport]` for mocks. | | `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` for mocks. | @@ -176,6 +176,10 @@ The decorators of the modules do not change a `[TestExport]` registration. The d The service provider of a test contains an `ITestCaseInfo` service. It has the test method, the argument values, and the attributes of the test. Each test package has an `ITestCaseInfo` interface in its `Impl` namespace. +## The test that runs: `CurrentTest` + +`CurrentTest` in `DependencyModules.Testing.Impl` gives the test that runs to code that does not get the test as a parameter. `TestOutputLoggerProvider` writes log entries to the output of that test. The two types operate with all the test packages. For more information, refer to [`CurrentTest` and test output](./testing-current-test.md). + ## Environment for a test To give an environment to a test, write an attribute that implements `IModuleEnvironmentProvider` from `DependencyModules.Runtime.Interfaces`: @@ -268,3 +272,4 @@ For each test, the test package does these steps: - [xUnit](./testing-xunit.md) and [NUnit](./testing-nunit.md) tell you about the test packages. - [Mocks](./testing-mocking.md) tells you how to replace services with mocks. - [More service providers in a test](./testing-container-source.md) tells you how to make more service providers in one test. +- [`CurrentTest` and test output](./testing-current-test.md) tells you how to get the test that runs and how to write the log to its output. diff --git a/website/reference/api.md b/website/reference/api.md index f72c6f0..44aafc7 100644 --- a/website/reference/api.md +++ b/website/reference/api.md @@ -133,6 +133,7 @@ The generated code uses these types: | `IMockSupportAttribute` | Makes mocks for `[Mock]`. | | `ISharedTestRegistration` | Selects the shared services. | | `ITestContainerSource` | `CreateAsync()` builds a new service provider for the test. | +| `ICurrentTestProvider` | `Key`, `DisplayName`, `Assembly`, and `TryWriteLine` for the test that runs. Each test package has an implementation. | | `IOrderedAttribute` | `Order`, with the default value 10. | Guide: [Testing](../guide/testing.md). @@ -145,3 +146,7 @@ Guide: [Testing](../guide/testing.md). | `TestContainerSource` | The `ITestContainerSource` implementation. | | `SharedRegistrations` | Finds the shared services of a test. | | `AttributeUtility` | Finds the attributes of a test on the method, the class, and the assembly. | +| `CurrentTest` | Gives the test that runs, from the `ICurrentTestProvider` of the test package. | +| `TestOutputLoggerProvider` | An `ILoggerProvider` that writes log entries to the output of the test that runs. | + +Guide: [`CurrentTest` and test output](../guide/testing-current-test.md).