Loading...
Loading...
Best practices for writing MSTest 3.x/4.x unit tests. Use when the user needs to write, improve, or review MSTest tests, including modern assertions, data-driven tests, test lifecycle, and common anti-patterns. Covers MSTest.Sdk, sealed classes, Assert.Throws, DynamicData with ValueTuples, TestContext, and conditional execution.
npx skill4agent add dotnet/skills writing-mstest-testsrun-tests| Input | Required | Description |
|---|---|---|
| Code under test | No | The production code to be tested |
| Existing test code | No | Current tests to review or improve |
| Test scenario description | No | What behavior the user wants to test |
MSTest.Sdk<Sdk Name="MSTest.Sdk">MSTestMSTest.TestFrameworkMSTest.TestAdapter<!-- Option 1: MSTest SDK (simplest, recommended for new projects) -->
<Project Sdk="MSTest.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
</Project>MSTest.Sdkglobal.json{
"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>sealed[TestClass][TestMethod]MethodName_Scenario_ExpectedBehavior[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);
}
}AssertStringAssertCollectionAssertAssert.AreEqual(expected, actual); // Value equality
Assert.AreSame(expected, actual); // Reference equality
Assert.IsNull(value);
Assert.IsNotNull(value);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));Assert.Throws<T>TAssert.ThrowsExactly<T>TAssert.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
var single = Assert.ContainsSingle(collection); // Returns the single element
Assert.HasCount(3, collection);
Assert.IsEmpty(collection);Assert.Contains("expected", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber);// 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);Assert.IsGreaterThan(lowerBound, actual);
Assert.IsLessThan(upperBound, actual);
Assert.IsInRange(actual, low, high);[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));
}ValueTupleIEnumerable<object[]>[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),
];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" },
];readonly[TestInitialize][TestCleanup]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();
}[AssemblyInitialize][ClassInitialize]TestContextTestContext[TestInitialize]TestContextTestContext[TestInitialize][TestCleanup]DisposeAsyncDispose[ClassCleanup][AssemblyCleanup]TestContext.CancellationToken[Timeout][TestMethod]
[Timeout(5000)]
public async Task FetchData_ReturnsWithinTimeout()
{
var result = await _client.GetDataAsync(_testContext.CancellationToken);
Assert.IsNotNull(result);
}[TestMethod]
[Retry(3)]
public void ExternalService_EventuallyResponds() { }[TestMethod]
[OSCondition(OperatingSystems.Windows)]
public void WindowsRegistry_ReadsValue() { }
[TestMethod]
[CICondition(ConditionMode.Exclude)]
public void LocalOnly_InteractiveTest() { }[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)]
[TestClass]
[DoNotParallelize] // Opt out specific classes
public sealed class DatabaseIntegrationTests { }| Pitfall | Solution |
|---|---|
| Always put expected first: |
| Use |
| Use |
Hard cast | Use |
| Use |
Sync setup in | Initialize in the constructor instead — enables |
| Use |
| Drop the |
| Remove |
| Non-sealed test classes | Seal test classes by default for performance |