diff --git a/docs/RFCs/014-TestRun-Current-PlannedTests.md b/docs/RFCs/014-TestRun-Current-PlannedTests.md new file mode 100644 index 0000000000..77e8fc5d94 --- /dev/null +++ b/docs/RFCs/014-TestRun-Current-PlannedTests.md @@ -0,0 +1,174 @@ +# RFC 014 - TestRun.Current ambient run-state API + +- [ ] Approved in principle +- [x] Under discussion +- [x] Implementation (initial slice: `PlannedTests`) +- [ ] Shipped + +## Summary + +Introduce a static, ambient `TestRun.Current` surface on top of an `ITestRunInfo` abstraction that exposes information about the *test run as a whole* (as opposed to `TestContext`, which describes a single executing test). The v1 slice exposes the set of tests that have been discovered and have passed the active filter for the current assembly (`PlannedTests`). Later additions can extend the surface with running/completed snapshots and events without breaking consumers. + +The motivating scenario is [microsoft/testfx#7311](https://github.com/microsoft/testfx/issues/7311): inside `[AssemblyInitialize]`, decide whether to perform expensive partial setup based on whether any test matching a given criterion will actually run. + +## Motivation + +Users today have no supported way to ask "given the filter the user typed in VS / on the command line, which tests are actually going to run in this assembly?". The information exists inside the adapter (it has to, in order to dispatch the tests), but it is not exposed. + +Concrete scenarios from the field (issue #7311 and adjacent feedback): + +- Building a compatibility solution before integration tests, but only when at least one compatibility test will run. +- Spinning up a Docker container or a real database before tests that need it, and skipping it entirely when the user runs a single non-DB test in VS. +- In `[AssemblyCleanup]`, deciding whether to publish telemetry based on whether any test of category X actually executed. +- Letting fixtures (NUnit-style shared setup) decide whether to spin up their dependency, without forcing users to wire ad-hoc booleans through `[AssemblyInitialize]`. + +Two design directions were discussed on the issue: + +1. **Put it on `TestContext`** (or a new per-phase `AssemblyInitializeTestContext`). This was rejected because `TestContext` is by design per-test, `TestContext.Current` is an `AsyncLocal` that is `null` outside test execution (so a static helper, fixture, or extension can't read it), and growing the surface to "the whole run" muddies what `TestContext` represents. +2. **Add a separate ambient run-state object.** This is what this RFC proposes. + +The same shape is used by other frameworks: xUnit v3 separates per-test `TestContext.Current` from run-wide pipeline objects, and NUnit separates `TestContext.CurrentContext` from `TestExecutionContext.CurrentContext`. + +## Detailed design + +### Public API + +All types live in `Microsoft.VisualStudio.TestTools.UnitTesting` (same as `TestContext`) and ship inside `MSTest.TestFramework.Extensions` (where `TestContext` lives). All are gated behind `[Experimental("MSTESTEXP")]` for v1. + +```csharp +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public static class TestRun +{ + public static ITestRunInfo Current { get; } +} + +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public interface ITestRunInfo +{ + IReadOnlyCollection PlannedTests { get; } +} + +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public sealed class PlannedTest +{ + public PlannedTest( + string fullyQualifiedTestClassName, + string testName, + string? testDisplayName, + string assemblyPath, + string? managedTypeName, + string? managedMethodName, + string? declaringFilePath, + int? declaringLineNumber, + IReadOnlyCollection testCategories, + IReadOnlyCollection> testProperties); + + public string FullyQualifiedTestClassName { get; } // mirrors TestContext + public string TestName { get; } // mirrors TestContext + public string? TestDisplayName { get; } // mirrors TestContext + public string AssemblyPath { get; } + public string? ManagedTypeName { get; } // ECMA-335 stable id + public string? ManagedMethodName { get; } // ECMA-335 stable id (incl. parameter types) + public string? DeclaringFilePath { get; } // matches TestMethodAttribute.DeclaringFilePath + public int? DeclaringLineNumber { get; } // matches TestMethodAttribute.DeclaringLineNumber + public IReadOnlyCollection TestCategories { get; } // [TestCategory] + public IReadOnlyCollection> TestProperties { get; } // [TestProperty] +} +``` + +### Naming rationale + +Every property name on `PlannedTest` either mirrors an existing public MSTest API or matches the user-facing attribute the user wrote: + +- `FullyQualifiedTestClassName` / `TestName` / `TestDisplayName` → mirror `TestContext`. +- `DeclaringFilePath` / `DeclaringLineNumber` → mirror the public `TestMethodAttribute` properties auto-captured via `[CallerFilePath]` / `[CallerLineNumber]`. +- `TestCategories` → mirrors `TestCategoryAttribute.TestCategories`. +- `TestProperties` → mirrors `[TestProperty(Name, Value)]`. Internally MSTest converts these to VSTest "Trait" objects (`GetTestPropertiesAsTraits` in `ReflectionHelper.cs`), but the user-facing concept the user wrote is `[TestProperty]`, so the public surface uses that name. + +### Type choices + +- **`TestRun` is a `static class`.** Matches `TestContext.Current` ambient-access habit. There is only one current run; no instance to manage. +- **`ITestRunInfo` is an `interface`.** Lets the platform swap the concrete impl (empty default, populated, future test doubles for advanced scenarios). +- **`PlannedTest` is a `sealed class`** with public constructor and get-only properties. + - Not a `struct`: 10 reference-typed fields, far past the size at which value semantics make sense. + - Not a `record`: structural equality across collection fields is surprising and expensive; positional records emit `init` accessors which the [repo guidelines explicitly forbid for new public APIs](../../.github/copilot-instructions.md#public-api-guidelines). + - `sealed` to lock in the shape and allow non-breaking additions later (extra get-only properties + extra ctor overload). +- **Collections**: + - `IReadOnlyCollection` for `TestCategories` rather than `IReadOnlySet` because `IReadOnlySet` is not available on netstandard2.0 / net462 (the TFMs `TestFramework.Extensions` targets) and the repo does not currently polyfill it. + - `IReadOnlyCollection>` for `TestProperties` (flat list) because `[TestProperty]` is multi-valued — the same name may appear several times — and a flat list represents that naturally without forcing an inner collection on every entry. + +### Lifetime / scoping + +- `TestRun.Current` is **never `null`**. Before any source begins execution, it returns an empty `ITestRunInfo` whose `PlannedTests` is empty. After execution, it retains the most recently populated snapshot until the next source replaces it. +- `PlannedTests` is scoped to **the current assembly's filtered tests**, populated once by the platform before the first test in the assembly runs. +- Scope: process-wide and (on .NET Framework with AppDomain isolation) AppDomain-wide. The implementation populates the static *inside* the `UnitTestRunner` constructor, which is the type instantiated in the child AppDomain when isolation is in use — so the snapshot is visible in the same domain that runs `[AssemblyInitialize]` and the tests themselves. Cross-process test hosts each have their own snapshot. + +### Implementation outline + +1. New types in `src/TestFramework/TestFramework.Extensions/`: + - `TestRun.cs` — static class with `Current` (get) and `internal SetCurrent(ITestRunInfo?)`. Default value is an internal `EmptyTestRunInfo` that returns empty collections. + - `ITestRunInfo.cs` — the interface. + - `PlannedTest.cs` — the DTO. +2. Internal adapter type `TestRunInfo` in `src/Adapter/MSTestAdapter.PlatformServices/Execution/TestRunInfo.cs`: + - Implements `ITestRunInfo`. + - Provides `static TestRunInfo CreateFrom(IReadOnlyList)` that materializes one `PlannedTest` per filtered `UnitTestElement`, reading `TestMethod.{FullClassName,Name,DisplayName,AssemblyName,ManagedTypeName,ManagedMethodName}`, `UnitTestElement.{DeclaringFilePath,DeclaringLineNumber,TestCategory,Traits}`. +3. Wire-up in `UnitTestRunner` constructor (post `_classCleanupManager = …`): + + ```csharp + TestRun.SetCurrent(TestRunInfo.CreateFrom(testsToRun)); + ``` + + This runs in the right domain/process for both the VSTest adapter path and the Microsoft.Testing.Platform path. + +### Example consumer code + +```csharp +[TestClass] +public static class GlobalSetup +{ + [AssemblyInitialize] + public static void Init(TestContext _) + { + bool anyCompatibilityTest = TestRun.Current.PlannedTests + .Any(t => t.TestCategories.Contains("Compatibility")); + + if (anyCompatibilityTest) + { + BuildCompatibilitySolution(); + } + } +} +``` + +The same call works from a helper class, a fixture, or any other code reachable during the run — not only from `[AssemblyInitialize]`. + +## Drawbacks + +- **New public API surface.** Adds three new public types (`TestRun`, `ITestRunInfo`, `PlannedTest`) to an already broad framework. Mitigation: `[Experimental]`, narrow v1 surface, minimal DTO shape designed for additive growth. +- **Ambient state is easy to misuse.** Reading run-wide state from inside `[TestMethod]` bodies can encourage hidden coupling between tests (test order dependencies based on what other tests have / haven't passed). Mitigation: v1 only exposes the plan (no outcomes), and a future analyzer can flag access from `[TestMethod]` bodies once `RunningTests` / `CompletedTests` are added. +- **AppDomain / process isolation.** Each test host process and each child AppDomain has its own snapshot. This is documented but may surprise users running multi-targeted tests in VSTest's parallel out-of-proc host. +- **Data-driven rows.** Tests whose data rows are only unfolded at execution time (non-serializable data, `UnfoldingStrategy.Fold`, `[DataSource]`) appear as a single `PlannedTest` rather than one per row. This is documented on `PlannedTests`. + +## Alternatives + +1. **Add `PlannedTests` (and friends) to `TestContext`** — rejected. `TestContext.Current` is `null` outside test execution (defeats the "queryable from anywhere" goal), per-run data on a per-test type is the bloat the team already pushed back on, and once we add `RunningTests` / `CompletedTests` they cannot live on `TestContext`. +2. **Split `TestContext` into per-lifecycle-phase subtypes** (`AssemblyInitializeTestContext`, `ClassInitializeTestContext`, …) and put `PlannedTests` on the assembly/class ones. Larger refactor; doesn't help consumers outside the lifecycle hooks (fixtures, helpers, extensions); doesn't compose with the future ambient state additions. Useful as an orthogonal cleanup, not as the place to put run-wide data. +3. **A first-class fixture model** (NUnit/xUnit-style) where setup runs the first time a class requesting the fixture is about to execute. Strongly preferred in the long run and complementary to this RFC: a fixture implementation can use `TestRun.Current.PlannedTests` internally. Tracked separately. +4. **Multiple gated `[AssemblyInitialize(WhenAnyTestMatches=…)]` attributes** — declarative, but only handles a single predicate per init method and adds a new gating-expression language. +5. **Do nothing.** Forces users to either over-eager setup in `[AssemblyInitialize]` (slow) or lazy `Lazy` patterns that start mid-run (breaks the "report time spent on setup" desire from the issue thread). + +## Compatibility + +- **Not a breaking change.** All additions; no existing types are modified. +- **Experimental.** The whole surface is annotated with `[Experimental("MSTESTEXP")]`. Consumers must explicitly opt in with `#pragma warning disable MSTESTEXP` (or equivalent). This lets us evolve the shape based on early feedback before locking it. +- **TFM coverage.** `TestFramework.Extensions` targets are unchanged; the new types compile across `netstandard2.0`, `net462`, `net8.0`, `net9.0`, UWP and WinUI. +- **Source compatibility for derived `TestContext`s.** None affected; we did not touch `TestContext`. + +### Unresolved questions + +- Should `Current` reset to empty between sources, or accumulate across all sources in the run? v1 ships per-source semantics (matches the AssemblyInitialize use case). A future `IRunWideTestInfo` could expose the union. +- Should we additionally expose `Hierarchy` (Namespace / Class / Method) on `PlannedTest`? Skipped in v1 since `FullyQualifiedTestClassName` is sufficient; add later if requested. +- Will users want a `TryGetTestProperty(name, out values)` convenience on `PlannedTest`, or always do their own LINQ? Defer until we see feedback. +- Future extensions to `ITestRunInfo` (e.g. `RunningTests`, `CompletedTests`, `TestStateChanged` event) — separate RFC once this slice ships. diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestRunInfo.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestRunInfo.cs new file mode 100644 index 0000000000..5999529378 --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestRunInfo.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution; + +/// +/// Builds snapshots from internal MSTest discovery data so that user +/// code (typically [AssemblyInitialize] or fixtures) can query . +/// +internal sealed class TestRunInfo : ITestRunInfo +{ + private static readonly KeyValuePair[] EmptyProperties = []; + private static readonly string[] EmptyCategories = []; + + public TestRunInfo(IReadOnlyCollection plannedTests) + => PlannedTests = plannedTests; + + public IReadOnlyCollection PlannedTests { get; } + + public static TestRunInfo CreateFrom(IReadOnlyList testsToRun) + { + if (testsToRun.Count == 0) + { + return new TestRunInfo([]); + } + + var planned = new PlannedTest[testsToRun.Count]; + for (int i = 0; i < testsToRun.Count; i++) + { + planned[i] = ToPlannedTest(testsToRun[i]); + } + + return new TestRunInfo(planned); + } + + private static PlannedTest ToPlannedTest(UnitTestElement element) + { + Trait[]? traits = element.Traits; + KeyValuePair[] testProperties; + if (traits is { Length: > 0 }) + { + testProperties = new KeyValuePair[traits.Length]; + for (int i = 0; i < traits.Length; i++) + { + testProperties[i] = new KeyValuePair(traits[i].Name, traits[i].Value); + } + } + else + { + testProperties = EmptyProperties; + } + + string[] categories = element.TestCategory is { Length: > 0 } + ? element.TestCategory + : EmptyCategories; + + // TestMethod.DisplayName defaults to TestMethod.Name when no display name was explicitly set; + // surface that distinction by passing null to PlannedTest so consumers can tell them apart. + string testName = element.TestMethod.Name; + string adapterDisplayName = element.TestMethod.DisplayName; + string? testDisplayName = string.Equals(adapterDisplayName, testName, StringComparison.Ordinal) + ? null + : adapterDisplayName; + + return PlannedTest.CreateFromOwnedArrays( + fullyQualifiedTestClassName: element.TestMethod.FullClassName, + testName: testName, + testDisplayName: testDisplayName, + assemblyPath: element.TestMethod.AssemblyName, + managedTypeName: element.TestMethod.ManagedTypeName, + managedMethodName: element.TestMethod.ManagedMethodName, + declaringFilePath: element.DeclaringFilePath, + declaringLineNumber: element.DeclaringLineNumber, + testCategories: categories, + testProperties: testProperties); + } +} diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs index 2c38ec4225..27cc6bb8ce 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs @@ -73,6 +73,12 @@ internal UnitTestRunner(MSTestSettings settings, UnitTestElement[] testsToRun, R _typeCache = new TypeCache(reflectHelper); _classCleanupManager = new ClassCleanupManager(testsToRun); + + // Expose the planned (post-filter) test list so that user code (typically [AssemblyInitialize] + // or fixtures) can query TestRun.Current.PlannedTests to decide whether expensive setup is + // needed. Set here so the snapshot lives in the same AppDomain/process that will execute the + // assembly initialize and the tests themselves. + TestRun.SetCurrent(TestRunInfo.CreateFrom(testsToRun)); } #pragma warning disable CA1822 // Mark members as static diff --git a/src/TestFramework/TestFramework.Extensions/ITestRunInfo.cs b/src/TestFramework/TestFramework.Extensions/ITestRunInfo.cs new file mode 100644 index 0000000000..cc56fa1226 --- /dev/null +++ b/src/TestFramework/TestFramework.Extensions/ITestRunInfo.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Read-only ambient information about the current test run. Accessed via . +/// +/// +/// Unlike , which describes the test currently executing, an +/// describes the run as a whole and is queryable from any code reachable +/// during a test run (for example helpers, fixtures, or [AssemblyInitialize] methods). +/// +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public interface ITestRunInfo +{ + /// + /// Gets the tests that have been discovered and have passed the active filter for the + /// currently-executing assembly. The collection is empty until the platform begins executing + /// tests for an assembly. + /// + /// + /// Data-driven tests whose rows are unfolded only at execution time may appear here as a + /// single entry rather than one entry per row. + /// + IReadOnlyCollection PlannedTests { get; } +} diff --git a/src/TestFramework/TestFramework.Extensions/PlannedTest.cs b/src/TestFramework/TestFramework.Extensions/PlannedTest.cs new file mode 100644 index 0000000000..2ed218210a --- /dev/null +++ b/src/TestFramework/TestFramework.Extensions/PlannedTest.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Represents a single test that is planned to run (i.e. has been discovered and passed the active +/// filter) in the current test run. Returned by . +/// +/// +/// Instances are immutable snapshots produced at the start of test execution for the assembly. +/// They are intended for read-only inspection (for example to decide whether expensive setup is +/// needed in [AssemblyInitialize]); they do not carry execution-time state such as outcome, +/// exceptions, or data-driven rows. +/// +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public sealed class PlannedTest +{ + /// + /// Initializes a new instance of the class. + /// + /// The fully-qualified name of the class declaring the test method. + /// The simple name of the test method. + /// The display name of the test, when set; otherwise . + /// The full path of the assembly that declares the test, on disk. + /// The ECMA-335 managed type name for the declaring type, when available. + /// The ECMA-335 managed method name (encoding parameter types) for the test method, when available. + /// The source file that declares the test, captured at compile-time by , when available. + /// The line number within at which the test is declared, when available. + /// The categories applied via . + /// The name/value pairs applied via . Multiple entries with the same name are preserved. + public PlannedTest( + string fullyQualifiedTestClassName, + string testName, + string? testDisplayName, + string assemblyPath, + string? managedTypeName, + string? managedMethodName, + string? declaringFilePath, + int? declaringLineNumber, + IReadOnlyCollection testCategories, + IReadOnlyCollection> testProperties) + { + FullyQualifiedTestClassName = fullyQualifiedTestClassName ?? throw new ArgumentNullException(nameof(fullyQualifiedTestClassName)); + TestName = testName ?? throw new ArgumentNullException(nameof(testName)); + TestDisplayName = testDisplayName; + AssemblyPath = assemblyPath ?? throw new ArgumentNullException(nameof(assemblyPath)); + ManagedTypeName = managedTypeName; + ManagedMethodName = managedMethodName; + DeclaringFilePath = declaringFilePath; + DeclaringLineNumber = declaringLineNumber; + + IReadOnlyCollection nonNullTestCategories = testCategories ?? throw new ArgumentNullException(nameof(testCategories)); + IReadOnlyCollection> nonNullTestProperties = testProperties ?? throw new ArgumentNullException(nameof(testProperties)); + + string[] copiedTestCategories = [.. nonNullTestCategories]; + KeyValuePair[] copiedTestProperties = [.. nonNullTestProperties]; + TestCategories = copiedTestCategories; + TestProperties = copiedTestProperties; + } + + private PlannedTest( + string fullyQualifiedTestClassName, + string testName, + string? testDisplayName, + string assemblyPath, + string? managedTypeName, + string? managedMethodName, + string? declaringFilePath, + int? declaringLineNumber, + string[] testCategories, + KeyValuePair[] testProperties, + bool _ /* trusted-arrays marker */) + { + FullyQualifiedTestClassName = fullyQualifiedTestClassName; + TestName = testName; + TestDisplayName = testDisplayName; + AssemblyPath = assemblyPath; + ManagedTypeName = managedTypeName; + ManagedMethodName = managedMethodName; + DeclaringFilePath = declaringFilePath; + DeclaringLineNumber = declaringLineNumber; + TestCategories = testCategories; + TestProperties = testProperties; + } + + /// + /// Internal factory used by the adapter to construct a without the + /// defensive copy performed by the public constructor. The supplied arrays MUST NOT be retained + /// or mutated by the caller after this call. + /// + internal static PlannedTest CreateFromOwnedArrays( + string fullyQualifiedTestClassName, + string testName, + string? testDisplayName, + string assemblyPath, + string? managedTypeName, + string? managedMethodName, + string? declaringFilePath, + int? declaringLineNumber, + string[] testCategories, + KeyValuePair[] testProperties) + => new( + fullyQualifiedTestClassName, + testName, + testDisplayName, + assemblyPath, + managedTypeName, + managedMethodName, + declaringFilePath, + declaringLineNumber, + testCategories, + testProperties, + _: true); + + /// + /// Gets the fully-qualified name of the class declaring the test method. + /// Mirrors . + /// + public string FullyQualifiedTestClassName { get; } + + /// + /// Gets the simple name of the test method. Mirrors . + /// + public string TestName { get; } + + /// + /// Gets the display name of the test, when one was set; otherwise . + /// Mirrors . + /// + public string? TestDisplayName { get; } + + /// + /// Gets the full path of the assembly that declares the test, on disk. + /// + public string AssemblyPath { get; } + + /// + /// Gets the ECMA-335 managed type name for the declaring type, when available. + /// + public string? ManagedTypeName { get; } + + /// + /// Gets the ECMA-335 managed method name (encoding parameter types) for the test method, + /// when available. Use this for unambiguous identification of overloaded methods. + /// + public string? ManagedMethodName { get; } + + /// + /// Gets the source file path that declares the test. This value is captured at compile time + /// by ; it can be when the test is + /// declared via a custom test-method attribute that does not propagate the caller information. + /// + public string? DeclaringFilePath { get; } + + /// + /// Gets the line number within at which the test is declared, + /// when available; otherwise . + /// + public int? DeclaringLineNumber { get; } + + /// + /// Gets the categories applied to this test via at the + /// method, class, or assembly level. + /// + public IReadOnlyCollection TestCategories { get; } + + /// + /// Gets the name/value pairs applied to this test via . + /// Multiple attributes with the same name are preserved as separate entries. + /// + public IReadOnlyCollection> TestProperties { get; } +} diff --git a/src/TestFramework/TestFramework.Extensions/PublicAPI/PublicAPI.Unshipped.txt b/src/TestFramework/TestFramework.Extensions/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..b9f2f26988 100644 --- a/src/TestFramework/TestFramework.Extensions/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/TestFramework/TestFramework.Extensions/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,17 @@ #nullable enable +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.ITestRunInfo +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.ITestRunInfo.PlannedTests.get -> System.Collections.Generic.IReadOnlyCollection! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.AssemblyPath.get -> string! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.DeclaringFilePath.get -> string? +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.DeclaringLineNumber.get -> int? +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.FullyQualifiedTestClassName.get -> string! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.ManagedMethodName.get -> string? +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.ManagedTypeName.get -> string? +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.PlannedTest(string! fullyQualifiedTestClassName, string! testName, string? testDisplayName, string! assemblyPath, string? managedTypeName, string? managedMethodName, string? declaringFilePath, int? declaringLineNumber, System.Collections.Generic.IReadOnlyCollection! testCategories, System.Collections.Generic.IReadOnlyCollection>! testProperties) -> void +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.TestCategories.get -> System.Collections.Generic.IReadOnlyCollection! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.TestDisplayName.get -> string? +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.TestName.get -> string! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.PlannedTest.TestProperties.get -> System.Collections.Generic.IReadOnlyCollection>! +[MSTESTEXP]Microsoft.VisualStudio.TestTools.UnitTesting.TestRun +[MSTESTEXP]static Microsoft.VisualStudio.TestTools.UnitTesting.TestRun.Current.get -> Microsoft.VisualStudio.TestTools.UnitTesting.ITestRunInfo! diff --git a/src/TestFramework/TestFramework.Extensions/TestRun.cs b/src/TestFramework/TestFramework.Extensions/TestRun.cs new file mode 100644 index 0000000000..74f228ceab --- /dev/null +++ b/src/TestFramework/TestFramework.Extensions/TestRun.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Provides ambient access to information about the currently-executing test run. +/// +/// +/// +/// always returns a non- ; +/// before tests start executing it exposes empty collections. +/// +/// +/// The information is scoped to the current process and (on .NET Framework) the current +/// AppDomain. Cross-process or cross-AppDomain test runs each have their own snapshot. +/// +/// +[Experimental("MSTESTEXP", UrlFormat = "https://aka.ms/mstest/diagnostics#{0}")] +public static class TestRun +{ + /// + /// Gets information about the currently-executing test run. Never returns . + /// + public static ITestRunInfo Current { get; private set; } = EmptyTestRunInfo.Instance; + + /// + /// Set by the platform before executing tests for an assembly. Pass to + /// reset to the empty implementation. + /// + internal static void SetCurrent(ITestRunInfo? info) + => Current = info ?? EmptyTestRunInfo.Instance; + + private sealed class EmptyTestRunInfo : ITestRunInfo + { + public static EmptyTestRunInfo Instance { get; } = new(); + + public IReadOnlyCollection PlannedTests { get; } = []; + } +} diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestRunInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestRunInfoTests.cs new file mode 100644 index 0000000000..e787279850 --- /dev/null +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestRunInfoTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution; +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution; + +public sealed class TestRunInfoTests : TestContainer +{ + public void CreateFromEmptyListReturnsInfoWithEmptyPlannedTests() + { + var info = TestRunInfo.CreateFrom([]); + + info.PlannedTests.Should().BeEmpty(); + } + + public void CreateFromSingleElementPopulatesAllRequiredFields() + { + var testMethod = new TestMethod( + managedMethodName: "MyMethod", + hierarchyValues: null, + name: "MyMethod", + fullClassName: "Some.Namespace.MyClass", + assemblyName: @"c:\repo\bin\MyTests.dll", + displayName: "My Display Name", + parameterTypes: null); + + var element = new UnitTestElement(testMethod); + + var info = TestRunInfo.CreateFrom([element]); + + info.PlannedTests.Should().HaveCount(1); + PlannedTest planned = info.PlannedTests.Single(); + planned.FullyQualifiedTestClassName.Should().Be("Some.Namespace.MyClass"); + planned.TestName.Should().Be("MyMethod"); + planned.TestDisplayName.Should().Be("My Display Name"); + planned.AssemblyPath.Should().Be(@"c:\repo\bin\MyTests.dll"); + planned.ManagedMethodName.Should().Be("MyMethod"); + planned.ManagedTypeName.Should().Be("Some.Namespace.MyClass"); + planned.TestCategories.Should().BeEmpty(); + planned.TestProperties.Should().BeEmpty(); + } + + public void CreateFromPopulatesCategoriesAndProperties() + { + var testMethod = new TestMethod("Run", "Tests.MyClass", "MyTests.dll", null); + var element = new UnitTestElement(testMethod) + { + TestCategory = ["Smoke", "Compatibility"], + Traits = [new Trait("Owner", "alice"), new Trait("Owner", "bob"), new Trait("Priority", "1")], + }; + + var info = TestRunInfo.CreateFrom([element]); + + PlannedTest planned = info.PlannedTests.Single(); + planned.TestCategories.Should().BeEquivalentTo(["Smoke", "Compatibility"]); + planned.TestProperties.Should().BeEquivalentTo(new[] + { + new KeyValuePair("Owner", "alice"), + new KeyValuePair("Owner", "bob"), + new KeyValuePair("Priority", "1"), + }); + } + + public void TestRunCurrentIsNeverNull() + => TestRun.Current.Should().NotBeNull(); + + public void SetCurrentNullResetsToEmpty() + { + var testMethod = new TestMethod("M", "T", "A.dll", null); + TestRun.SetCurrent(TestRunInfo.CreateFrom([new UnitTestElement(testMethod)])); + TestRun.Current.PlannedTests.Should().HaveCount(1); + + TestRun.SetCurrent(null); + + TestRun.Current.Should().NotBeNull(); + TestRun.Current.PlannedTests.Should().BeEmpty(); + } + + public void CreateFromMapsDefaultDisplayNameToNull() + { + // TestMethod.DisplayName defaults to TestMethod.Name when no display name is explicitly provided. + // PlannedTest.TestDisplayName should be null in that case so consumers can distinguish + // "no explicit display name" from "display name set". + var testMethod = new TestMethod("MyMethod", "Tests.MyClass", "MyTests.dll", displayName: null); + var element = new UnitTestElement(testMethod); + + var info = TestRunInfo.CreateFrom([element]); + + PlannedTest planned = info.PlannedTests.Single(); + planned.TestName.Should().Be("MyMethod"); + planned.TestDisplayName.Should().BeNull(); + } + + public void PlannedTestCopiesInputCollections() + { + string[] categories = ["Smoke"]; + KeyValuePair[] properties = [new("Owner", "alice")]; + + var plannedTest = new PlannedTest( + fullyQualifiedTestClassName: "Tests.MyClass", + testName: "Run", + testDisplayName: null, + assemblyPath: "MyTests.dll", + managedTypeName: "Tests.MyClass", + managedMethodName: "Run", + declaringFilePath: "Tests.cs", + declaringLineNumber: 42, + testCategories: categories, + testProperties: properties); + + categories[0] = "Changed"; + properties[0] = new KeyValuePair("Owner", "bob"); + + plannedTest.TestCategories.Should().Equal("Smoke"); + plannedTest.TestProperties.Should().Equal([new KeyValuePair("Owner", "alice")]); + } +}