--- agent: 'agent' tools: ['changes', 'search/codebase', 'edit/editFiles', 'problems', 'search'] description: 'Get best practices for MSTest 3.x/4.x unit testing, including modern assertion APIs and data-driven tests' --- # MSTest Best Practices (MSTest 3.x/4.x) Your goal is to help me write effective unit tests with modern MSTest, using current APIs and best practices. ## Project Setup - Use a separate test project with naming convention `[ProjectName].Tests` - Reference MSTest 3.x+ NuGet packages (includes analyzers) - Consider using MSTest.Sdk for simplified project setup - Run tests with `dotnet test` ## Test Class Structure - Use `[TestClass]` attribute for test classes - **Seal test classes by default** for performance and design clarity - Use `[TestMethod]` for test methods (prefer over `[DataTestMethod]`) - Follow Arrange-Act-Assert (AAA) pattern - Name tests using pattern `MethodName_Scenario_ExpectedBehavior` ```csharp [TestClass] public sealed class CalculatorTests { [TestMethod] public void Add_TwoPositiveNumbers_ReturnsSum() { // Arrange var calculator = new Calculator(); // Act var result = calculator.Add(2, 3); // Assert Assert.AreEqual(5, result); } } ``` ## Test Lifecycle - **Prefer constructors over `[TestInitialize]`** - enables `readonly` fields and follows standard C# patterns - Use `[TestCleanup]` for cleanup that must run even if test fails - Combine constructor with async `[TestInitialize]` when async setup is needed ```csharp [TestClass] public sealed class ServiceTests { private readonly MyService _service; // readonly enabled by constructor public ServiceTests() { _service = new MyService(); } [TestInitialize] public async Task InitAsync() { // Use for async initialization only await _service.WarmupAsync(); } [TestCleanup] public void Cleanup() => _service.Reset(); } ``` ### Execution Order 1. **Assembly Initialization** - `[AssemblyInitialize]` (once per test assembly) 2. **Class Initialization** - `[ClassInitialize]` (once per test class) 3. **Test Initialization** (for every test method): 1. Constructor 2. Set `TestContext` property 3. `[TestInitialize]` 4. **Test Execution** - test method runs 5. **Test Cleanup** (for every test method): 1. `[TestCleanup]` 2. `DisposeAsync` (if implemented) 3. `Dispose` (if implemented) 6. **Class Cleanup** - `[ClassCleanup]` (once per test class) 7. **Assembly Cleanup** - `[AssemblyCleanup]` (once per test assembly) ## Modern Assertion APIs MSTest provides three assertion classes: `Assert`, `StringAssert`, and `CollectionAssert`. ### Assert Class - Core Assertions ```csharp // Equality Assert.AreEqual(expected, actual); Assert.AreNotEqual(notExpected, actual); Assert.AreSame(expectedObject, actualObject); // Reference equality Assert.AreNotSame(notExpectedObject, actualObject); // Null checks Assert.IsNull(value); Assert.IsNotNull(value); // Boolean Assert.IsTrue(condition); Assert.IsFalse(condition); // Fail/Inconclusive Assert.Fail("Test failed due to..."); Assert.Inconclusive("Test cannot be completed because..."); ``` ### Exception Testing (Prefer over `[ExpectedException]`) ```csharp // Assert.Throws - matches TException or derived types var ex = Assert.Throws(() => Method(null)); Assert.AreEqual("Value cannot be null.", ex.Message); // Assert.ThrowsExactly - matches exact type only var ex = Assert.ThrowsExactly(() => Method()); // Async versions var ex = await Assert.ThrowsAsync(async () => await client.GetAsync(url)); var ex = await Assert.ThrowsExactlyAsync(async () => await Method()); ``` ### Collection Assertions (Assert class) ```csharp Assert.Contains(expectedItem, collection); Assert.DoesNotContain(unexpectedItem, collection); Assert.ContainsSingle(collection); // exactly one element Assert.HasCount(5, collection); Assert.IsEmpty(collection); Assert.IsNotEmpty(collection); ``` ### String Assertions (Assert class) ```csharp Assert.Contains("expected", actualString); Assert.StartsWith("prefix", actualString); Assert.EndsWith("suffix", actualString); Assert.DoesNotStartWith("prefix", actualString); Assert.DoesNotEndWith("suffix", actualString); Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber); Assert.DoesNotMatchRegex(@"\d+", textOnly); ``` ### Comparison Assertions ```csharp Assert.IsGreaterThan(lowerBound, actual); Assert.IsGreaterThanOrEqualTo(lowerBound, actual); Assert.IsLessThan(upperBound, actual); Assert.IsLessThanOrEqualTo(upperBound, actual); Assert.IsInRange(actual, low, high); Assert.IsPositive(number); Assert.IsNegative(number); ``` ### Type Assertions ```csharp // MSTest 3.x - uses out parameter Assert.IsInstanceOfType(obj, out var typed); typed.DoSomething(); // MSTest 4.x - returns typed result directly var typed = Assert.IsInstanceOfType(obj); typed.DoSomething(); Assert.IsNotInstanceOfType(obj); ``` ### Assert.That (MSTest 4.0+) ```csharp Assert.That(result.Count > 0); // Auto-captures expression in failure message ``` ### StringAssert Class > **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains("expected", actual)` over `StringAssert.Contains(actual, "expected")`). ```csharp StringAssert.Contains(actualString, "expected"); StringAssert.StartsWith(actualString, "prefix"); StringAssert.EndsWith(actualString, "suffix"); StringAssert.Matches(actualString, new Regex(@"\d{3}-\d{4}")); StringAssert.DoesNotMatch(actualString, new Regex(@"\d+")); ``` ### CollectionAssert Class > **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains`). ```csharp // Containment CollectionAssert.Contains(collection, expectedItem); CollectionAssert.DoesNotContain(collection, unexpectedItem); // Equality (same elements, same order) CollectionAssert.AreEqual(expectedCollection, actualCollection); CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection); // Equivalence (same elements, any order) CollectionAssert.AreEquivalent(expectedCollection, actualCollection); CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection); // Subset checks CollectionAssert.IsSubsetOf(subset, superset); CollectionAssert.IsNotSubsetOf(notSubset, collection); // Element validation CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass)); CollectionAssert.AllItemsAreNotNull(collection); CollectionAssert.AllItemsAreUnique(collection); ``` ## Data-Driven Tests ### DataRow ```csharp [TestMethod] [DataRow(1, 2, 3)] [DataRow(0, 0, 0, DisplayName = "Zeros")] [DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+ public void Add_ReturnsSum(int a, int b, int expected) { Assert.AreEqual(expected, Calculator.Add(a, b)); } ``` ### DynamicData The data source can return any of the following types: - `IEnumerable<(T1, T2, ...)>` (ValueTuple) - **preferred**, provides type safety (MSTest 3.7+) - `IEnumerable>` - provides type safety - `IEnumerable` - provides type safety plus control over test metadata (display name, categories) - `IEnumerable` - **least preferred**, no type safety > **Note:** When creating new test data methods, prefer `ValueTuple` or `TestDataRow` over `IEnumerable`. The `object[]` approach provides no compile-time type checking and can lead to runtime errors from type mismatches. ```csharp [TestMethod] [DynamicData(nameof(TestData))] public void DynamicTest(int a, int b, int expected) { Assert.AreEqual(expected, Calculator.Add(a, b)); } // ValueTuple - preferred (MSTest 3.7+) public static IEnumerable<(int a, int b, int expected)> TestData => [ (1, 2, 3), (0, 0, 0), ]; // TestDataRow - when you need custom display names or metadata public static IEnumerable> TestDataWithMetadata => [ new((1, 2, 3)) { DisplayName = "Positive numbers" }, new((0, 0, 0)) { DisplayName = "Zeros" }, new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" }, ]; // IEnumerable - avoid for new code (no type safety) public static IEnumerable LegacyTestData => [ [1, 2, 3], [0, 0, 0], ]; ``` ## TestContext The `TestContext` class provides test run information, cancellation support, and output methods. See [TestContext documentation](https://learn.microsoft.com/dotnet/core/testing/unit-testing-mstest-writing-tests-testcontext) for complete reference. ### Accessing TestContext ```csharp // Property (MSTest suppresses CS8618 - don't use nullable or = null!) public TestContext TestContext { get; set; } // Constructor injection (MSTest 3.6+) - preferred for immutability [TestClass] public sealed class MyTests { private readonly TestContext _testContext; public MyTests(TestContext testContext) { _testContext = testContext; } } // Static methods receive it as parameter [ClassInitialize] public static void ClassInit(TestContext context) { } // Optional for cleanup methods (MSTest 3.6+) [ClassCleanup] public static void ClassCleanup(TestContext context) { } [AssemblyCleanup] public static void AssemblyCleanup(TestContext context) { } ``` ### Cancellation Token Always use `TestContext.CancellationToken` for cooperative cancellation with `[Timeout]`: ```csharp [TestMethod] [Timeout(5000)] public async Task LongRunningTest() { await _httpClient.GetAsync(url, TestContext.CancellationToken); } ``` ### Test Run Properties ```csharp TestContext.TestName // Current test method name TestContext.TestDisplayName // Display name (3.7+) TestContext.CurrentTestOutcome // Pass/Fail/InProgress TestContext.TestData // Parameterized test data (3.7+, in TestInitialize/Cleanup) TestContext.TestException // Exception if test failed (3.7+, in TestCleanup) TestContext.DeploymentDirectory // Directory with deployment items ``` ### Output and Result Files ```csharp // Write to test output (useful for debugging) TestContext.WriteLine("Processing item {0}", itemId); // Attach files to test results (logs, screenshots) TestContext.AddResultFile(screenshotPath); // Store/retrieve data across test methods TestContext.Properties["SharedKey"] = computedValue; ``` ## Advanced Features ### Retry for Flaky Tests (MSTest 3.9+) ```csharp [TestMethod] [Retry(3)] public void FlakyTest() { } ``` ### Conditional Execution (MSTest 3.10+) Skip or run tests based on OS or CI environment: ```csharp // OS-specific tests [TestMethod] [OSCondition(OperatingSystems.Windows)] public void WindowsOnlyTest() { } [TestMethod] [OSCondition(OperatingSystems.Linux | OperatingSystems.MacOS)] public void UnixOnlyTest() { } [TestMethod] [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] public void SkipOnWindowsTest() { } // CI environment tests [TestMethod] [CICondition] // Runs only in CI (default: ConditionMode.Include) public void CIOnlyTest() { } [TestMethod] [CICondition(ConditionMode.Exclude)] // Skips in CI, runs locally public void LocalOnlyTest() { } ``` ### Parallelization ```csharp // Assembly level [assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] // Disable for specific class [TestClass] [DoNotParallelize] public sealed class SequentialTests { } ``` ### Work Item Traceability (MSTest 3.8+) Link tests to work items for traceability in test reports: ```csharp // Azure DevOps work items [TestMethod] [WorkItem(12345)] // Links to work item #12345 public void Feature_Scenario_ExpectedBehavior() { } // Multiple work items [TestMethod] [WorkItem(12345)] [WorkItem(67890)] public void Feature_CoversMultipleRequirements() { } // GitHub issues (MSTest 3.8+) [TestMethod] [GitHubWorkItem("https://github.com/owner/repo/issues/42")] public void BugFix_Issue42_IsResolved() { } ``` Work item associations appear in test results and can be used for: - Tracing test coverage to requirements - Linking bug fixes to regression tests - Generating traceability reports in CI/CD pipelines ## Common Mistakes to Avoid ```csharp // ❌ Wrong argument order Assert.AreEqual(actual, expected); // ✅ Correct Assert.AreEqual(expected, actual); // ❌ Using ExpectedException (obsolete) [ExpectedException(typeof(ArgumentException))] // ✅ Use Assert.Throws Assert.Throws(() => Method()); // ❌ Using LINQ Single() - unclear exception var item = items.Single(); // ✅ Use ContainsSingle - better failure message var item = Assert.ContainsSingle(items); // ❌ Hard cast - unclear exception var handler = (MyHandler)result; // ✅ Type assertion - shows actual type on failure var handler = Assert.IsInstanceOfType(result); // ❌ Ignoring cancellation token await client.GetAsync(url, CancellationToken.None); // ✅ Flow test cancellation await client.GetAsync(url, TestContext.CancellationToken); // ❌ Making TestContext nullable - leads to unnecessary null checks public TestContext? TestContext { get; set; } // ❌ Using null! - MSTest already suppresses CS8618 for this property public TestContext TestContext { get; set; } = null!; // ✅ Declare without nullable or initializer - MSTest handles the warning public TestContext TestContext { get; set; } ``` ## Test Organization - Group tests by feature or component - Use `[TestCategory("Category")]` for filtering - Use `[TestProperty("Name", "Value")]` for custom metadata (e.g., `[TestProperty("Bug", "12345")]`) - Use `[Priority(1)]` for critical tests - Enable relevant MSTest analyzers (MSTEST0020 for constructor preference) ## Mocking and Isolation - Use Moq or NSubstitute for mocking dependencies - Use interfaces to facilitate mocking - Mock dependencies to isolate units under test