// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Globalization; using Microsoft.Testing.Platform.Acceptance.IntegrationTests; using Microsoft.Testing.TestInfrastructure; namespace MSTest.Acceptance.IntegrationTests; /// /// Acceptance tests for [DependsOn] and its testconfig.json equivalent. The generated assets /// record, for every test body, the millisecond at which it entered and left, which makes both claims /// checkable end to end: that a dependent never starts before its prerequisite finishes, and - the point of /// a graph rather than a flat order - that two tests sharing a prerequisite really do overlap. /// /// /// The overlap claim is asserted by a bounded rendezvous inside the asset rather than by comparing elapsed /// times, so it does not depend on how busy the machine is and this class needs no isolation from the rest /// of the suite. Each test method also generates and runs its own project, so nothing is shared between them. /// [TestClass] public sealed class TestDependencyExecutionTests : AcceptanceTestBase { private const string GraphProjectName = "TestDependencyGraphTestProject"; private const string FailureProjectName = "TestDependencyFailureTestProject"; private const string CycleProjectName = "TestDependencyCycleTestProject"; private const string TestConfigProjectName = "TestDependencyConfigTestProject"; public TestContext TestContext { get; set; } = default!; [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task DependsOn_RunsPrerequisitesFirst_AndLetsIndependentBranchesOverlap(string tfm) { TestHost testHost = AssetFixture.GetTestHost(GraphProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreEqual(0, result.ExitCode, result.StandardOutput); Assert.Contains("succeeded: 4", result.StandardOutput); IReadOnlyDictionary spans = AssetFixture.ReadProbe(GraphProjectName, tfm); // Ordering: a dependent may not start before its prerequisite has finished. Assert.IsGreaterThanOrEqualTo(spans["Root"].Exit, spans["BranchA"].Enter, "BranchA started before Root finished"); Assert.IsGreaterThanOrEqualTo(spans["Root"].Exit, spans["BranchB"].Enter, "BranchB started before Root finished"); Assert.IsGreaterThanOrEqualTo(spans["BranchA"].Exit, spans["Join"].Enter, "Join started before BranchA finished"); Assert.IsGreaterThanOrEqualTo(spans["BranchB"].Exit, spans["Join"].Enter, "Join started before BranchB finished"); // Fan-out: the two branches share a prerequisite but not each other, so they must be allowed to run at // the same time. This is what a dependency graph buys over a flat ordering attribute, which would have // serialized them. // // Asserted by rendezvous rather than by comparing timestamps: each branch signals its own arrival and // then waits for the other, so the run only completes if both were genuinely dispatched concurrently. // Inferring overlap from elapsed times would be a race - under CI load the second branch might not // start before the first one's sleep elapsed, failing a correct scheduler. If the branches are // serialized the barrier is never satisfied, the waits hit their timeout, and the asset fails the // tests rather than this assertion having to notice a suspicious ordering. Assert.Contains("BranchA:rendezvous-ok", result.StandardOutput, "BranchA did not overlap BranchB"); Assert.Contains("BranchB:rendezvous-ok", result.StandardOutput, "BranchB did not overlap BranchA"); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task DependsOn_WhenAPrerequisiteFails_SkipsDependentsTransitively_ButHonorsProceedOnFailure(string tfm) { TestHost testHost = AssetFixture.GetTestHost(FailureProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); // One real failure, its two (direct and transitive) dependents skipped rather than failed, and the two // tests that must still run - the ProceedOnFailure audit step and the unrelated test - passing. Assert.Contains("failed: 1", result.StandardOutput); Assert.Contains("skipped: 2", result.StandardOutput); Assert.Contains("succeeded: 2", result.StandardOutput); // The skip must say why, otherwise a skipped test is indistinguishable from one that was filtered out. Assert.Contains("Failing", result.StandardOutput); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task DependsOn_WhenDependenciesFormACycle_FailsTheTestsInTheCycle_AndStillRunsTheRest(string tfm) { TestHost testHost = AssetFixture.GetTestHost(CycleProjectName, tfm); TestHostResult result = await testHost.ExecuteAsync("--output detailed", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); // The cycle is a configuration error, so it is loud: the two tests in it fail with the cycle path, and // the unrelated test still runs, so one bad declaration does not cost the whole run. Assert.Contains("failed: 2", result.StandardOutput); Assert.Contains("succeeded: 1", result.StandardOutput); Assert.Contains("Dependency cycle detected", result.StandardOutput); } [TestMethod] [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] public async Task DependencyConfiguration_OrdersTestsThatDeclareNoAttribute(string tfm) { TestHost testHost = AssetFixture.GetTestHost(TestConfigProjectName, tfm); string probePath = Path.Combine(testHost.DirectoryName, "OrderProbe.txt"); File.Delete(probePath); // The asset ships a testconfig.json declaring the order; no test carries an attribute, and the // classes are declared in source in the reverse of the required order. string testConfigPath = Path.Combine(testHost.DirectoryName, "pipeline.testconfig.json"); TestHostResult result = await testHost.ExecuteAsync($"--config-file \"{testConfigPath}\"", cancellationToken: TestContext.CancellationToken); Assert.AreNotEqual(0, result.ExitCode, result.StandardOutput); // 'chains' ordered First -> Second -> Third, and 'nodes' hung Verify and Audit off Second. // Second fails, so the skip propagates to everything downstream that did not opt out: Third (next // in the chain) and Verify (a node). Audit runs anyway because its node set proceedOnFailure, and // First already ran. Hence 1 failed, 2 skipped, 2 succeeded. Assert.Contains("failed: 1", result.StandardOutput); Assert.Contains("skipped: 2", result.StandardOutput); Assert.Contains("succeeded: 2", result.StandardOutput); // Both skips name the prerequisite that did not pass. Assert.Contains("skipped Verify", result.StandardOutput); Assert.Contains("skipped Third", result.StandardOutput); Assert.Contains("Test skipped because it depends on 'Contoso.Tests.StepTwo.Second', which did not pass.", result.StandardOutput); string[] order = [.. File.ReadAllLines(probePath).Where(l => l.Length > 0)]; // The classes are declared in source in the reverse of this order and carry no attribute, so only // the configuration can be responsible for First running before Second. Assert.AreSequenceEqual(new[] { "First", "Second" }, order.Take(2).ToArray(), string.Join(",", order)); // Neither skipped test executed its body. Assert.DoesNotContain("Verify", order); Assert.DoesNotContain("Third", order); // Audit ran anyway, because its node set proceedOnFailure. Assert.Contains("Audit", order); } public sealed class TestAssetFixture : ITestAssetFixture { private readonly TempDirectory _tempDirectory = new(); private readonly Dictionary _assets = []; public TestHost GetTestHost(string projectName, string tfm) => TestHost.LocateFrom(_assets[projectName].TargetAssetPath, projectName, tfm); /// /// Reads the enter/exit millisecond of each recorded test body from the probe file written by the /// generated asset. /// public IReadOnlyDictionary ReadProbe(string projectName, string tfm) { string probePath = Path.Combine(GetTestHost(projectName, tfm).DirectoryName, "SpanProbe.txt"); var spans = new Dictionary(StringComparer.Ordinal); foreach (string line in File.ReadAllLines(probePath)) { string[] parts = line.Split(','); if (parts.Length == 3) { spans[parts[0]] = ( int.Parse(parts[1], CultureInfo.InvariantCulture), int.Parse(parts[2], CultureInfo.InvariantCulture)); } } return spans; } public async Task InitializeAsync(CancellationToken cancellationToken) { await GenerateAsync(GraphProjectName, GraphSourceCode, cancellationToken).ConfigureAwait(false); await GenerateAsync(FailureProjectName, FailureSourceCode, cancellationToken).ConfigureAwait(false); await GenerateAsync(CycleProjectName, CycleSourceCode, cancellationToken).ConfigureAwait(false); await GenerateAsync(TestConfigProjectName, TestConfigSourceCode, cancellationToken).ConfigureAwait(false); } private async Task GenerateAsync(string projectName, string sourceCode, CancellationToken cancellationToken) { string patched = sourceCode .PatchTargetFrameworks(TargetFrameworks.All) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$ProjectName$", projectName); TestAsset asset = await TestAsset.GenerateAssetAsync(projectName, patched, _tempDirectory); await DotnetCli.RunAsync($"build \"{asset.TargetAssetPath}\" -c Release", callerMemberName: projectName, cancellationToken: cancellationToken); _assets.Add(projectName, asset); } public void Dispose() { foreach (TestAsset asset in _assets.Values) { asset.Dispose(); } _tempDirectory.Dispose(); } private const string ProjectFile = """ #file $ProjectName$.csproj Exe true $TargetFrameworks$ latest """; private const string GraphSourceCode = ProjectFile + """ #file SpanProbe.cs using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; // Records when each test body ran, so the acceptance test can check both the ordering guarantee and the // overlap that proves independent branches were not serialized. internal static class SpanProbe { private static readonly Stopwatch s_clock = Stopwatch.StartNew(); private static readonly object s_gate = new object(); private static readonly List s_spans = new List(); public static void Run(string name, int milliseconds) { int enter = (int)s_clock.ElapsedMilliseconds; Thread.Sleep(milliseconds); int exit = (int)s_clock.ElapsedMilliseconds; lock (s_gate) { s_spans.Add(string.Format(CultureInfo.InvariantCulture, "{0},{1},{2}", name, enter, exit)); } } private static readonly Dictionary s_entered = new Dictionary(StringComparer.Ordinal); public static void Enter(string name) { lock (s_gate) { s_entered[name] = (int)s_clock.ElapsedMilliseconds; } } public static void Exit(string name) { lock (s_gate) { s_spans.Add(string.Format(CultureInfo.InvariantCulture, "{0},{1},{2}", name, s_entered[name], (int)s_clock.ElapsedMilliseconds)); } } public static void Flush() { lock (s_gate) { File.WriteAllText("SpanProbe.txt", string.Join(Environment.NewLine, s_spans)); } } } [TestClass] public sealed class ProbeLifecycle { [AssemblyCleanup] public static void Flush() => SpanProbe.Flush(); } #file Parallelization.cs using Microsoft.VisualStudio.TestTools.UnitTesting; // MethodLevel makes every test its own scheduling unit, which is what allows the two branches of the fan-out // to be handed to different workers. Four workers guarantee there is capacity for them to overlap. [assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] #file GraphTests.cs using System; using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] public sealed class GraphTests { // The two branches rendezvous instead of sleeping a fixed span: each signals its arrival and waits for // the other, so concurrent dispatch is proven rather than inferred from timestamps. A generous timeout // keeps a serialized scheduler from hanging the run - it fails the test instead. private static readonly CountdownEvent Rendezvous = new(2); [TestMethod] public void Root() => SpanProbe.Run("Root", 300); [TestMethod] [DependsOn(nameof(Root))] public void BranchA() => RunBranch("BranchA"); [TestMethod] [DependsOn(nameof(Root))] public void BranchB() => RunBranch("BranchB"); [TestMethod] [DependsOn(nameof(BranchA))] [DependsOn(nameof(BranchB))] public void Join() => SpanProbe.Run("Join", 50); private static void RunBranch(string name) { SpanProbe.Enter(name); Rendezvous.Signal(); bool met = Rendezvous.Wait(TimeSpan.FromSeconds(60)); SpanProbe.Exit(name); Assert.IsTrue(met, name + " timed out waiting for the other branch, so the two never ran concurrently."); Console.WriteLine(name + ":rendezvous-ok"); } } """; private const string FailureSourceCode = ProjectFile + """ #file Parallelization.cs using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] #file FailureTests.cs using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] public sealed class FailureTests { [TestMethod] public void Failing() => Assert.Fail("deliberate failure"); [TestMethod] [DependsOn(nameof(Failing))] public void DirectDependent() { } [TestMethod] [DependsOn(nameof(DirectDependent))] public void TransitiveDependent() { } // Must run even though its prerequisite failed: this is the audit/cleanup escape hatch. [TestMethod] [DependsOn(nameof(Failing), ProceedOnFailure = true)] public void Audit() { } [TestMethod] public void Unrelated() { } } """; private const string CycleSourceCode = ProjectFile + """ #file CycleTests.cs using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] public sealed class CycleTests { [TestMethod] [DependsOn(nameof(Two))] public void One() { } [TestMethod] [DependsOn(nameof(One))] public void Two() { } [TestMethod] public void Unrelated() { } } """; private const string TestConfigSourceCode = """ #file $ProjectName$.csproj Exe true $TargetFrameworks$ latest Always #file pipeline.testconfig.json { "mstest": { "execution": { "dependencies": { "chains": [ [ "Contoso.Tests.StepOne.First", "Contoso.Tests.StepTwo.Second", "Contoso.Tests.StepThree.Third" ] ], "nodes": [ { "test": "Contoso.Tests.VerifyStep.Verify", "dependsOn": [ "Contoso.Tests.StepTwo.Second" ] }, { "test": "Contoso.Tests.AuditStep.Audit", "dependsOn": [ "Contoso.Tests.StepTwo.Second" ], "proceedOnFailure": true } ] } } } } #file OrderProbe.cs using System.IO; using Microsoft.VisualStudio.TestTools.UnitTesting; internal static class OrderProbe { private static readonly object s_gate = new object(); public static void Record(string name) { lock (s_gate) { File.AppendAllText("OrderProbe.txt", name + "\n"); } } } #file Steps.cs using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Contoso.Tests; // Declared in the reverse of the order the configuration asks for, and carrying no dependency attribute, // so a correct run can only be explained by testconfig.json. [TestClass] public sealed class AuditStep { // Runs even though its prerequisite fails, because its node sets proceedOnFailure. [TestMethod] public void Audit() => OrderProbe.Record("Audit"); } [TestClass] public sealed class VerifyStep { // Must be skipped: same prerequisite, but no proceedOnFailure. [TestMethod] public void Verify() => OrderProbe.Record("Verify"); } [TestClass] public sealed class StepThree { [TestMethod] public void Third() => OrderProbe.Record("Third"); } [TestClass] public sealed class StepTwo { [TestMethod] public void Second() { OrderProbe.Record("Second"); Assert.Fail("deliberate failure to exercise skip propagation through configuration"); } } [TestClass] public sealed class StepOne { [TestMethod] public void First() => OrderProbe.Record("First"); } """; } }