Skills writing-mstest-tests
Best practices for writing MSTest 3.x/4.x unit tests. Use when the user needs to write, improve, fix, or review MSTest tests, including modern assertions, data-driven tests, test lifecycle, and common anti-patterns. Also use when fixing test issues like swapped Assert.AreEqual arguments, incorrect assertion usage, or modernizing legacy test code. Covers MSTest.Sdk, sealed classes, Assert.Throws, DynamicData with ValueTuples, TestContext, and conditional execution.
git clone https://github.com/dotnet/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-test/skills/writing-mstest-tests" ~/.claude/skills/dotnet-skills-writing-mstest-tests && rm -rf "$T"
plugins/dotnet-test/skills/writing-mstest-tests/SKILL.mdWriting MSTest Tests
Help users write effective, modern unit tests with MSTest 3.x/4.x using current APIs and best practices.
When to Use
- User wants to write new MSTest unit tests
- User wants to improve or modernize existing MSTest tests
- User asks about MSTest assertion APIs, data-driven patterns, or test lifecycle
- User needs targeted help fixing or modernizing MSTest tests
When Not to Use
- User needs to run or execute tests (use the
skill)run-tests - User needs to upgrade from MSTest v1/v2 to v3 (use
)migrate-mstest-v1v2-to-v3 - User needs to upgrade from MSTest v3 to v4 (use
)migrate-mstest-v3-to-v4 - User needs to review or audit existing tests for anti-patterns or test quality (use
)test-anti-patterns - User needs CI/CD pipeline configuration
- User is using xUnit, NUnit, or TUnit (not MSTest)
Inputs
| Input | Required | Description |
|---|---|---|
| Code under test | No | The production code to be tested |
| Existing test code | No | Current tests to improve or modernize |
| Test scenario description | No | What behavior the user wants to test |
Workflow
Step 1: Determine project setup
Check the test project for MSTest version and configuration:
- If using
(MSTest.Sdk
): modern setup, all features available<Sdk Name="MSTest.Sdk"> - If using
metapackage: modern setup (MSTest 3.x+)MSTest - If using
+MSTest.TestFramework
: check version for feature availabilityMSTest.TestAdapter
Recommend MSTest.Sdk or the MSTest metapackage for new projects:
<!-- Option 1: MSTest SDK (simplest, recommended for new projects) --> <Project Sdk="MSTest.Sdk"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> </PropertyGroup> </Project>
When using
MSTest.Sdk, put the version in global.json instead of the project file so all test projects get bumped together:
{ "msbuild-sdks": { "MSTest.Sdk": "3.8.2" } }
<!-- Option 2: MSTest metapackage --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="MSTest" Version="3.8.2" /> </ItemGroup> </Project>
Step 2: Write test classes following conventions
Apply these structural conventions:
- Seal test classes with
for performance and design claritysealed - Use
on the class and[TestClass]
on test methods[TestMethod] - Follow the Arrange-Act-Assert (AAA) pattern
- Name tests using
MethodName_Scenario_ExpectedBehavior - Use separate test projects with naming convention
[ProjectName].Tests
[TestClass] public sealed class OrderServiceTests { [TestMethod] public void CalculateTotal_WithDiscount_ReturnsReducedPrice() { // Arrange var service = new OrderService(); var order = new Order { Price = 100m, DiscountPercent = 10 }; // Act var total = service.CalculateTotal(order); // Assert Assert.AreEqual(90m, total); } }
Step 3: Use modern assertion APIs
Use the correct assertion for each scenario. Prefer
Assert class methods over StringAssert or CollectionAssert where both exist.
Equality and null checks
Assert.AreEqual(expected, actual); // Value equality Assert.AreSame(expected, actual); // Reference equality Assert.IsNull(value); Assert.IsNotNull(value);
Exception testing -- use Assert.Throws
instead of [ExpectedException]
Assert.Throws[ExpectedException]// Synchronous var ex = Assert.ThrowsExactly<ArgumentNullException>(() => service.Process(null)); Assert.AreEqual("input", ex.ParamName); // Async var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>( async () => await service.ProcessAsync(null));
matchesAssert.Throws<T>
or any derived typeT
matches only the exact typeAssert.ThrowsExactly<T>T
Collection assertions
Assert.Contains(expectedItem, collection); Assert.DoesNotContain(unexpectedItem, collection); var single = Assert.ContainsSingle(collection); // Returns the single element Assert.HasCount(3, collection); Assert.IsEmpty(collection); Assert.IsNotEmpty(collection);
Replace generic
Assert.IsTrue with specialized assertions -- they give better failure messages:
| Instead of | Use |
|---|---|
| |
| |
| |
+ | |
| |
String assertions
Assert.Contains("expected", actualString); Assert.StartsWith("prefix", actualString); Assert.EndsWith("suffix", actualString); Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber);
Type assertions
// MSTest 3.x -- out parameter Assert.IsInstanceOfType<MyHandler>(result, out var typed); typed.Handle(); // MSTest 4.x -- returns directly var typed = Assert.IsInstanceOfType<MyHandler>(result);
Comparison assertions
Assert.IsGreaterThan(lowerBound, actual); Assert.IsLessThan(upperBound, actual); Assert.IsInRange(actual, low, high);
Step 4: Use data-driven tests for multiple inputs
DataRow for inline values
[TestMethod] [DataRow(1, 2, 3)] [DataRow(0, 0, 0, DisplayName = "Zeros")] [DataRow(-1, 1, 0)] public void Add_ReturnsExpectedSum(int a, int b, int expected) { Assert.AreEqual(expected, Calculator.Add(a, b)); }
DynamicData with ValueTuples (preferred for complex data)
Prefer
ValueTuple return types over IEnumerable<object[]> for type safety:
[TestMethod] [DynamicData(nameof(DiscountTestData))] public void ApplyDiscount_ReturnsExpectedPrice(decimal price, int percent, decimal expected) { var result = PriceCalculator.ApplyDiscount(price, percent); Assert.AreEqual(expected, result); } // ValueTuple -- preferred (MSTest 3.7+) public static IEnumerable<(decimal price, int percent, decimal expected)> DiscountTestData => [ (100m, 10, 90m), (200m, 25, 150m), (50m, 0, 50m), ];
When you need metadata per test case, use
TestDataRow<T>:
public static IEnumerable<TestDataRow<(decimal price, int percent, decimal expected)>> DiscountTestDataWithMetadata => [ new((100m, 10, 90m)) { DisplayName = "10% discount" }, new((200m, 25, 150m)) { DisplayName = "25% discount" }, new((50m, 0, 50m)) { DisplayName = "No discount" }, ];
Step 5: Handle test lifecycle correctly
- Always initialize in the constructor -- this enables
fields and works correctly with nullability analyzers (fields are guaranteed non-null after construction)readonly - Use
only for async initialization, combined with the constructor for sync parts[TestInitialize] - Use
for cleanup that must run even on failure[TestCleanup] - Inject
via constructor (MSTest 3.6+)TestContext
[TestClass] public sealed class RepositoryTests { private readonly TestContext _testContext; private readonly FakeDatabase _db; // readonly -- guaranteed by constructor public RepositoryTests(TestContext testContext) { _testContext = testContext; _db = new FakeDatabase(); // sync init in ctor } [TestInitialize] public async Task InitAsync() { // Use TestInitialize ONLY for async setup await _db.SeedAsync(); } [TestCleanup] public void Cleanup() => _db.Reset(); }
Execution order
-- once per assembly[AssemblyInitialize]
-- once per class[ClassInitialize]- Per test:
- With
property injection: Constructor -> setTestContext
property ->TestContext[TestInitialize] - With constructor injection of
: Constructor (receivesTestContext
) ->TestContext[TestInitialize]
- With
- Test method
->[TestCleanup]
->DisposeAsync
-- per testDispose
-- once per class[ClassCleanup]
-- once per assembly[AssemblyCleanup]
Step 6: Apply cancellation and timeout patterns
Always use
TestContext.CancellationToken with [Timeout]:
[TestMethod] [Timeout(5000)] public async Task FetchData_ReturnsWithinTimeout() { var result = await _client.GetDataAsync(_testContext.CancellationToken); Assert.IsNotNull(result); }
Step 7: Use advanced features where appropriate
Retry flaky tests (MSTest 3.9+)
Use only for genuinely flaky external dependencies (network, file system), not to paper over race conditions or shared state issues.
[TestMethod] [Retry(3)] public void ExternalService_EventuallyResponds() { }
Conditional execution (MSTest 3.10+)
[TestMethod] [OSCondition(OperatingSystems.Windows)] public void WindowsRegistry_ReadsValue() { } [TestMethod] [CICondition(ConditionMode.Exclude)] public void LocalOnly_InteractiveTest() { }
Parallelization
[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] [TestClass] [DoNotParallelize] // Opt out specific classes public sealed class DatabaseIntegrationTests { }
Validation
- Test classes are
sealed - Test methods follow
namingMethodName_Scenario_ExpectedBehavior -
used instead ofAssert.ThrowsExactly<T>[ExpectedException] - Specialized assertions used instead of
(e.g.,Assert.IsTrue
,Assert.IsNotNull
)Assert.AreEqual - DynamicData uses ValueTuple return types instead of
IEnumerable<object[]> - Sync initialization done in the constructor, not
[TestInitialize] -
passed to async calls in tests withTestContext.CancellationToken[Timeout] - Project builds with zero errors and all tests pass
Common Pitfalls
| Pitfall | Solution |
|---|---|
-- swapped arguments | Always put expected first: . Failure messages show "Expected: X, Actual: Y" so wrong order makes messages confusing |
-- obsolete, cannot assert message | Use or |
-- unclear exception on failure | Use for better failure messages |
Hard cast -- unclear exception | Use |
for DynamicData | Use ValueTuples for type safety |
Sync setup in | Initialize in the constructor instead -- enables fields and satisfies nullability analyzers |
in async tests | Use for cooperative timeout |
| Drop the -- MSTest suppresses CS8618 for this property |
| Remove -- unnecessary, MSTest handles assignment |
| Non-sealed test classes | Seal test classes by default for performance |