From e32fa3f27c238cb1b12c332054a8be83a8a946fa Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Fri, 25 Sep 2026 10:37:55 -0400 Subject: [PATCH] Add CurrentTest and TestOutputLoggerProvider to DependencyModules.Testing Each runner package installs an ICurrentTestProvider over its test framework's own context: TestContext.Current for xUnit, and TestExecutionContext.CurrentContext for NUnit. A library can then read the test that runs, and write to its output, with no reference to a test framework. Hardened can drop [HardenedTest] and its two runner packages for [ModuleTest]. xUnit's output helper throws when a background task writes after its test has finished. The xUnit provider drops that line, so a logger does not throw into the application under test. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../SutProject.NUnitTests/CurrentTestTests.cs | 101 +++++++++++ .../TestFramework/CurrentTestTests.cs | 167 ++++++++++++++++++ .../Attributes/ModuleTestAttribute.cs | 3 + .../Impl/NUnitCurrentTestProvider.cs | 50 ++++++ .../Interfaces/ICurrentTestProvider.cs | 51 ++++++ .../DependencyModules.Testing.csproj | 4 +- .../Impl/CurrentTest.cs | 39 ++++ .../Impl/TestOutputLoggerProvider.cs | 119 +++++++++++++ .../Attributes/ModuleTestAttribute.cs | 3 + .../Impl/ModuleTestCase.cs | 4 + .../Impl/XunitCurrentTestProvider.cs | 58 ++++++ .../PublicApiTests.TestingApi.verified.txt | 22 +++ .../TestOutputLoggerProviderTests.cs | 132 ++++++++++++++ website/.vitepress/config.ts | 1 + website/guide/testing-current-test.md | 94 ++++++++++ website/guide/testing-nunit.md | 2 + website/guide/testing-xunit.md | 2 + website/guide/testing.md | 7 +- website/reference/api.md | 5 + 20 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 integ-tests/SutProject.NUnitTests/CurrentTestTests.cs create mode 100644 integ-tests/SutProject.Tests/TestFramework/CurrentTestTests.cs create mode 100644 src/DependencyModules.NUnit/Impl/NUnitCurrentTestProvider.cs create mode 100644 src/DependencyModules.Testing/Attributes/Interfaces/ICurrentTestProvider.cs create mode 100644 src/DependencyModules.Testing/Impl/CurrentTest.cs create mode 100644 src/DependencyModules.Testing/Impl/TestOutputLoggerProvider.cs create mode 100644 src/DependencyModules.xUnit/Impl/XunitCurrentTestProvider.cs create mode 100644 tests/DependencyModules.Tests/TestingTests/TestOutputLoggerProviderTests.cs create mode 100644 website/guide/testing-current-test.md diff --git a/README.md b/README.md index 18f4640..050a75d 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The `integ-tests` folder contains projects that reference the source projects of | [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.Testing](https://www.nuget.org/packages/DependencyModules.Testing/) | The test attributes and interfaces that the xUnit and NUnit packages use. It also gives the test that runs, and a logger that writes to the output of the test. | | [DependencyModules.NSubstitute](https://www.nuget.org/packages/DependencyModules.NSubstitute/) | `[Mock]` parameters with NSubstitute. | | [DependencyModules.Moq](https://www.nuget.org/packages/DependencyModules.Moq/) | `[Mock]` and `Mock` parameters with Moq. | | [DependencyModules.FakeItEasy](https://www.nuget.org/packages/DependencyModules.FakeItEasy/) | `[Mock]` parameters with FakeItEasy. | diff --git a/integ-tests/SutProject.NUnitTests/CurrentTestTests.cs b/integ-tests/SutProject.NUnitTests/CurrentTestTests.cs new file mode 100644 index 0000000..3dd02eb --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/CurrentTestTests.cs @@ -0,0 +1,101 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using NUnit.Framework.Internal; + +namespace SutProject.NUnitTests; + +/// +/// CurrentTest under NUnit, where the runner package answers from +/// TestExecutionContext.CurrentContext. +/// +public class CurrentTestTests +{ + private static readonly List RepeatKeys = []; + + [ModuleTest] + public void KeyIsNUnitsObjectForTheRunningTest() => + Assert.That(CurrentTest.Key, Is.SameAs(TestExecutionContext.CurrentContext.CurrentTest)); + + [ModuleTest] + public void DisplayNameIsTheFullNameOfTheTest() => + Assert.That(CurrentTest.DisplayName, Is.EqualTo(TestContext.CurrentContext.Test.FullName)); + + [ModuleTest] + public void AssemblyIsTheAssemblyOfTheTestClass() => + Assert.That(CurrentTest.Assembly, Is.SameAs(typeof(CurrentTestTests).Assembly)); + + [ModuleTest] + public void ALineIsWrittenToTheOutputOfTheTest() + { + Assert.That(CurrentTest.TryWriteLine("written through CurrentTest"), Is.True); + + Assert.That( + TestExecutionContext.CurrentContext.CurrentResult.Output, + Does.Contain("written through CurrentTest") + ); + } + + [ModuleTest] + public void TheLoggerProviderWritesToTheOutputOfTheTest() + { + new TestOutputLoggerProvider() + .CreateLogger("Shop.OrderService") + .LogInformation("Order {Id} accepted", 42); + + Assert.That( + TestExecutionContext.CurrentContext.CurrentResult.Output, + Does.Contain("info: Shop.OrderService[0] Order 42 accepted") + ); + } + + /// + /// The container is built inside the iteration, so the test is already running in the hooks + /// that build it. Under xUnit it is not; the documentation of CurrentTest gives both. + /// + [ModuleTest] + [RecordCurrentTestAtStartup] + public void TheTestIsRunningWhileTheContainerIsBuilt() + { + Assert.That(RecordCurrentTestAtStartupAttribute.Key, Is.SameAs(CurrentTest.Key)); + Assert.That( + TestExecutionContext.CurrentContext.CurrentResult.Output, + Does.Contain("written while the container is built") + ); + } + + /// + /// NUnit makes no object of its own for one iteration, so the key is the test's for all of + /// them. The documentation of CurrentTest says so; the container is still one per iteration. + /// + [ModuleTest] + [Repeat(2)] + public void TheIterationsOfARepeatedTestShareOneKey() + { + RepeatKeys.Add(CurrentTest.Key); + + Assert.That(CurrentTest.Key, Is.Not.Null); + Assert.That( + RepeatKeys, + Has.Count.EqualTo(TestExecutionContext.CurrentContext.CurrentRepeatCount + 1) + ); + Assert.That(RepeatKeys, Is.All.SameAs(RepeatKeys[0])); + } +} + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RecordCurrentTestAtStartupAttribute : Attribute, ITestStartupAttribute +{ + public static object? Key { get; private set; } + + public Task StartupAsync(ITestMethodContext testMethod, IServiceProvider serviceProvider) + { + Key = CurrentTest.Key; + + CurrentTest.TryWriteLine("written while the container is built"); + + return Task.CompletedTask; + } +} diff --git a/integ-tests/SutProject.Tests/TestFramework/CurrentTestTests.cs b/integ-tests/SutProject.Tests/TestFramework/CurrentTestTests.cs new file mode 100644 index 0000000..06d3e92 --- /dev/null +++ b/integ-tests/SutProject.Tests/TestFramework/CurrentTestTests.cs @@ -0,0 +1,167 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace SutProject.Tests.TestFramework; + +/// +/// CurrentTest under xUnit, where the runner package answers from TestContext.Current. +/// +/// +/// SutProject.Tests runs against both xunit.v3 majors, so these hold for DependencyModules.xUnit and +/// for DependencyModules.xUnit4. +/// +public class CurrentTestTests +{ + private static readonly Dictionary RowKeys = []; + + [ModuleTest] + public void KeyIsXunitsObjectForTheRunningTest() => + Assert.Same(TestContext.Current.Test, CurrentTest.Key); + + [ModuleTest] + public void DisplayNameIsXunitsDisplayName() => + Assert.Equal(TestContext.Current.Test!.TestDisplayName, CurrentTest.DisplayName); + + [ModuleTest] + public void AssemblyIsTheAssemblyOfTheTestClass() => + Assert.Same(typeof(CurrentTestTests).Assembly, CurrentTest.Assembly); + + [ModuleTest] + public void ALineIsWrittenToTheOutputOfTheTest() + { + Assert.True(CurrentTest.TryWriteLine("written through CurrentTest")); + + Assert.Contains( + "written through CurrentTest", + TestContext.Current.TestOutputHelper!.Output + ); + } + + [ModuleTest] + public void TheLoggerProviderWritesToTheOutputOfTheTest() + { + new TestOutputLoggerProvider() + .CreateLogger("Shop.OrderService") + .LogInformation("Order {Id} accepted", 42); + + Assert.Contains( + "info: Shop.OrderService[0] Order 42 accepted", + TestContext.Current.TestOutputHelper!.Output + ); + } + + /// + /// Each row is a test of its own, so per-test state keyed on one row does not reach another. + /// + [ModuleTest] + [InlineData(1)] + [InlineData(2)] + public void EachDataRowHasAKeyOfItsOwn(int row) + { + var key = Assert.IsAssignableFrom(CurrentTest.Key); + + lock (RowKeys) + { + Assert.DoesNotContain(RowKeys.Values, earlier => ReferenceEquals(earlier, key)); + + RowKeys[row] = key; + } + } + + /// + /// A background flow keeps the context of the test that started it after that test finishes, + /// and xUnit's output helper then throws when written to. A logger on such a flow must drop + /// the line and not throw into the application under test. + /// + /// + /// Two tests in one class, which xUnit runs one after the other in an order it does not + /// promise. Whichever runs first starts the writer. The second tells it that the first has + /// finished, and reads what the write returned. + /// + [ModuleTest] + public Task ALineWrittenAfterItsTestHasFinishedIsDropped() => WriteAfterTheTestAsync(); + + [ModuleTest] + public Task WhicheverOfTheTwoLateWriteTestsRanFirst() => WriteAfterTheTestAsync(); + + private static readonly TaskCompletionSource FirstLateWriteTestFinished = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private static readonly TaskCompletionSource LateWrite = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private static int _lateWriteTestsStarted; + + private static async Task WriteAfterTheTestAsync() + { + if (Interlocked.Increment(ref _lateWriteTestsStarted) == 1) + { + _ = Task.Run(async () => + { + await FirstLateWriteTestFinished.Task; + + try + { + LateWrite.SetResult( + CurrentTest.TryWriteLine("written after the test finished") + ); + } + catch (Exception exception) + { + LateWrite.SetException(exception); + } + }); + + return; + } + + FirstLateWriteTestFinished.SetResult(); + + Assert.False(await LateWrite.Task.WaitAsync(TimeSpan.FromSeconds(10))); + } + + /// + /// A case builds the container for each of its tests before xUnit runs the first one, so no + /// test is running yet in the hooks that build it. The documentation of CurrentTest says so. + /// + [ModuleTest] + [RecordCurrentTestAtStartup] + public void NoTestIsRunningWhileTheContainerIsBuilt() + { + Assert.True(RecordCurrentTestAtStartupAttribute.Recorded); + Assert.Null(RecordCurrentTestAtStartupAttribute.Key); + Assert.False(RecordCurrentTestAtStartupAttribute.Wrote); + Assert.Same( + typeof(CurrentTestTests).Assembly, + RecordCurrentTestAtStartupAttribute.Assembly + ); + } +} + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RecordCurrentTestAtStartupAttribute : Attribute, ITestStartupAttribute +{ + public static bool Recorded { get; private set; } + + public static object? Key { get; private set; } + + public static Assembly? Assembly { get; private set; } + + public static bool Wrote { get; private set; } + + public Task StartupAsync(ITestMethodContext testMethod, IServiceProvider serviceProvider) + { + Key = CurrentTest.Key; + Assembly = CurrentTest.Assembly; + Wrote = CurrentTest.TryWriteLine("written while the container is built"); + Recorded = true; + + return Task.CompletedTask; + } +} diff --git a/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs b/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs index 0286a9a..53a2097 100644 --- a/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs +++ b/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs @@ -57,6 +57,9 @@ public class ModuleTestAttribute /// internal const string RowPropertyName = "DependencyModules.ModuleTestRow"; + // NUnit creates the attribute while it builds the tests, before the first one runs. + static ModuleTestAttribute() => NUnitCurrentTestProvider.Install(); + /// /// Marks a test method, optionally naming the modules to configure its container with. /// diff --git a/src/DependencyModules.NUnit/Impl/NUnitCurrentTestProvider.cs b/src/DependencyModules.NUnit/Impl/NUnitCurrentTestProvider.cs new file mode 100644 index 0000000..2ea14ad --- /dev/null +++ b/src/DependencyModules.NUnit/Impl/NUnitCurrentTestProvider.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using NUnit.Framework; +using NUnit.Framework.Internal; + +namespace DependencyModules.NUnit.Impl; + +/// +/// Reads the running test from NUnit's . +/// +/// +/// +/// Installed from the static constructor of ModuleTestAttribute. NUnit creates the attribute +/// while it builds the tests, before the first one runs. +/// +/// +/// The key is NUnit's , which the iterations of a [Repeat] or a +/// [Retry] share: NUnit makes no object of its own for one iteration. The container is still +/// built for each iteration. +/// +/// +internal sealed class NUnitCurrentTestProvider : ICurrentTestProvider +{ + public object? Key => RunningTest; + + public string? DisplayName => RunningTest?.FullName; + + public Assembly? Assembly => RunningTest?.TypeInfo?.Assembly; + + public bool TryWriteLine(string message) + { + if (RunningTest == null) + { + return false; + } + + TestContext.Out.WriteLine(message); + + return true; + } + + public static void Install() => CurrentTest.Provider ??= new NUnitCurrentTestProvider(); + + // NUnit answers outside a test with its ad hoc context rather than with null. + private static Test? RunningTest => + TestExecutionContext.CurrentContext is TestExecutionContext.AdhocContext + ? null + : TestExecutionContext.CurrentContext.CurrentTest; +} diff --git a/src/DependencyModules.Testing/Attributes/Interfaces/ICurrentTestProvider.cs b/src/DependencyModules.Testing/Attributes/Interfaces/ICurrentTestProvider.cs new file mode 100644 index 0000000..8739f33 --- /dev/null +++ b/src/DependencyModules.Testing/Attributes/Interfaces/ICurrentTestProvider.cs @@ -0,0 +1,51 @@ +using System.Reflection; + +namespace DependencyModules.Testing.Attributes.Interfaces; + +/// +/// Reads the running test from the test framework's own context. +/// +/// +/// +/// The ambient counterpart of . That one is handed to the hooks that +/// build a test's container. This one answers from anywhere the framework's context flows, such as +/// an assertion helper or a logger, with nothing handed to it. Each runner package installs its own +/// in CurrentTest.Provider: DependencyModules.xUnit and DependencyModules.xUnit4 read +/// TestContext.Current, and DependencyModules.NUnit reads +/// TestExecutionContext.CurrentContext. +/// +/// +/// Every member answers for the test running on the calling flow, and with null or false when there +/// is none. A library that reads this works under any runner package and references no framework. +/// +/// +public interface ICurrentTestProvider +{ + /// + /// The framework's own object for the running test, or null when no test is running. + /// + /// + /// Compared by reference. Two tests that run at the same time have different keys, and the + /// framework holds the object for as long as the test runs, so a weak table can hold per-test + /// state against it. + /// + object? Key { get; } + + /// + /// The framework's display name for the running test, or null when no test is running. + /// + string? DisplayName { get; } + + /// + /// The assembly that declares the running test's class, or null when there is no test class. + /// + Assembly? Assembly { get; } + + /// + /// Writes a line to the running test's output. + /// + /// + /// False, having written nothing, when no test is running or the test has finished. + /// + bool TryWriteLine(string message); +} diff --git a/src/DependencyModules.Testing/DependencyModules.Testing.csproj b/src/DependencyModules.Testing/DependencyModules.Testing.csproj index 2fed18f..049b4d6 100644 --- a/src/DependencyModules.Testing/DependencyModules.Testing.csproj +++ b/src/DependencyModules.Testing/DependencyModules.Testing.csproj @@ -6,17 +6,19 @@ 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, DependencyModules.xUnit4, 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. CurrentTest gives the test that runs. TestOutputLoggerProvider writes log entries to the output of the test that runs. 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.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).