The MSTest 4 Guide: TestClass, DataRow/DynamicData, the Assert API, TestContext, Parallelism, Analyzers and a Quick Look at TUnit
[ExpectedException], removed in MSTest 4, counted an exception of the same type on any line of the method as success; during the upgrade, Assert.ThrowsExactly and a ParamName check exposed a test that had been green for the wrong reason for years and a rule that was never written. MSTest.Sdk and MTP, the decimal pitfall in DataRow, DynamicData, the Assert API, TestContext, parallelism, analyzers, a quick look at TUnit and the close of the series.
Version note: The code in this post was built and run with .NET 10, MSTest 4.5.3 (MSTest.Sdk; 3.11.1 for comparison) and TUnit 1.85.0.
Predict First
The last post of the series looks at MSTest, Microsoft's own test framework, which you often run into in enterprise projects, especially older projects that started from Visual Studio templates. Bookstore, the online bookstore used as the shared example across this site's series, has an old MSTest project that tests campaign rules. With MSTest 3.11.1:
[TestClass]
public class CampaignTests
{
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Discount_above_half_is_rejected()
{
Campaign.StartNow("BLACKFRIDAY", 0.60m, TimeSpan.FromDays(3), TimeProvider.System);
}
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Campaign_longer_than_90_days_is_rejected()
{
Campaign.StartNow("SUMMER", 0.55m, TimeSpan.FromDays(120), TimeProvider.System);
}
}
Both tests are green; the build gives only two analyzer warnings:
CampaignTests.cs(10,17): warning MSTEST0006: Prefer 'Assert.ThrowsExactly/ThrowsExactlyAsync' over '[ExpectedException]' (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0006)
CampaignTests.cs(17,17): warning MSTEST0006: Prefer 'Assert.ThrowsExactly/ThrowsExactlyAsync' over '[ExpectedException]' (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0006)
Campaigns are created with Campaign.StartNow in the Catalog module: the discount cannot exceed 50%, and a campaign cannot last longer than 90 days. The project is about to be upgraded to MSTest 4.5.3. Does the second test really protect the 90-day rule?
Answer: ExpectedException Does Not Know Which Line Threw, or Why
After moving to MSTest 4.5.3, the project does not compile; the ExpectedException attribute was removed in 4.0:
CampaignTests.cs(9,6): error CS0246: The type or namespace name 'ExpectedException' could not be found (are you missing a using directive or an assembly reference?)
CampaignTests.cs(16,6): error CS0246: The type or namespace name 'ExpectedException' could not be found (are you missing a using directive or an assembly reference?)
As MSTEST0006's code fix does, we moved the method body into Assert.ThrowsExactly, and both tests stayed green. But now we can capture the exception in a variable; asking the second test which parameter was rejected:
failed Campaign_longer_than_90_days_is_rejected (5ms)
Assertion failed. Expected strings to be equal.
Strings have different lengths (expected: 8, actual: 12) and differ at 1 location(s). First difference at index 1.
expected: "duration"
actual: "discountRate"
difference: --^
Assert.AreEqual("duration", ex.ParamName)
The exception came from the discount, not the duration: when the test was copied from the first one, the discount stayed at 0.55. With the discount set to 0.20, the real situation showed up:
failed Campaign_longer_than_90_days_is_rejected (2ms)
Assertion failed. Expected exception of exact type ArgumentOutOfRangeException but no exception was thrown.
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => Campaign.StartNow("SUMMER", 0.20m, TimeSpan.FromDays(120), TimeProvider.System))
The 90-day rule was never written; the test had been green for years for the wrong reason. [ExpectedException] accepts an exception of the same type thrown from anywhere in the method, for any reason; an exception thrown on an arrange line makes the test pass too. Assert.ThrowsExactly looks for the exception only inside the lambda you give it, and because it returns it, you can check the message, the parameter name or any property. The rule was added to production:
public static readonly TimeSpan MaximumDuration = TimeSpan.FromDays(90);
public static Campaign StartNow(string code, decimal discountRate, TimeSpan duration, TimeProvider clock)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(discountRate, 0.5m);
ArgumentOutOfRangeException.ThrowIfGreaterThan(duration, MaximumDuration);
DateTimeOffset now = clock.GetUtcNow();
return new Campaign(code, discountRate, now, now + duration);
}
ThrowIfGreaterThan fills in the parameter name itself via CallerArgumentExpression; that is why the test's "duration" expectation works. Like the skipped test in the NUnit 5 post, this is a production gap found by the upgrade.
MSTest.Sdk and Microsoft.Testing.Platform
MSTest 4.5.3 was released on November 11, 2026, under the MIT license. The recommended way to set up a test project is MSTest.Sdk, an MSBuild project SDK:
<Project Sdk="MSTest.Sdk/4.5.3">
<PropertyGroup>
<MSTestAnalysisMode>Recommended</MSTestAnalysisMode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Bookstore.Catalog\Bookstore.Catalog.csproj" />
</ItemGroup>
</Project>
There are no package references: the SDK adds MSTest's framework, adapter, analyzers and the Microsoft.Testing.Platform (MTP) runner itself, and makes the project an executable. The version is in the Sdk attribute; even though central package management is on in this solution, no extra setting was needed. The default runner is MTP; <UseVSTest>true</UseVSTest> switches back to VSTest. Since 4.0, the SDK no longer adds the Microsoft.NET.Test.Sdk package in MTP mode; if you need to run with vstest.console, add that package by hand. One detail: the dotnet new mstest template of the .NET 10 SDK (10.0.404) uses the MSTest metapackage at version 4.0.2 instead of MSTest.Sdk; upgrade the version in projects that start from the template.
MSTestAnalysisMode chooses which analyzer rules run and at what severity. Recommended raises the rules that are "info" by default to warnings; we will see examples below.
TestClass, TestMethod, DataRow and DynamicData
The basic structure: a class marked with [TestClass], methods marked with [TestMethod]. Like xUnit, MSTest creates a new instance of the class for every test; the shared-instance problem of NUnit does not exist here. For data-driven tests, [DataRow]:
// Attribute arguments cannot be decimal: prices in kurus, rates in percent
[TestMethod]
[DataRow(1250, 10, 1125)]
[DataRow(990, 15, 842)]
[DataRow(5, 50, 3)]
public void Discount_is_rounded_away_from_zero(int priceKurus, int ratePercent, int expectedKurus)
{
Campaign campaign = Campaign.StartNow("AUTUMN26", ratePercent / 100m, TimeSpan.FromDays(7), Clock);
Assert.AreEqual(expectedKurus / 100m, campaign.Apply(priceKurus / 100m));
}
In C#, an attribute argument cannot be a decimal. On my first attempt I made the parameters decimal and wrote [DataRow(12.50, 0.10, 11.25)]; the analyzer warned, and the test did not run:
warning MSTEST0014: DataRow argument types do not match method parameter types. Parameter 'price' expects type 'decimal', but the provided value has type 'double'; ...
failed Decimal_rows (12.5,0.1,11.25) (4ms)
Cannot run test method 'Bookstore.Catalog.Pricing.Tests.Scratch21.Decimal_rows': Test data doesn't match method parameters. Either the count or types are different.
Test expected 3 parameter(s), with types 'Decimal, Decimal, Decimal',
but received 3 argument(s), with types 'Double, Double, Double'.
Taking a string and converting it with decimal.Parse is the second idea that comes to mind, but decimal.Parse("12.50") depends on the culture: on a Windows machine with Turkish regional settings, the dot counts as the thousands separator and the result is 1250. Giving money amounts as whole numbers of kuruş, a hundredth of a lira, removes both problems; if the amounts really must be decimal, use [DynamicData].
[DynamicData] takes data from a method, a property or a field; in 4.x it detects the kind of source by itself and is written type-safely with a ValueTuple:
public static IEnumerable<(DateTimeOffset At, bool Active)> BlackFridayMoments =>
[
(new DateTimeOffset(2026, 11, 26, 23, 59, 59, TimeSpan.Zero), false),
(new DateTimeOffset(2026, 11, 27, 0, 0, 0, TimeSpan.Zero), true),
(new DateTimeOffset(2026, 11, 29, 23, 59, 59, TimeSpan.Zero), true),
(new DateTimeOffset(2026, 11, 30, 0, 0, 0, TimeSpan.Zero), false),
];
[TestMethod]
[DynamicData(nameof(BlackFridayMoments))]
public void Campaign_is_active_from_start_until_just_before_the_end(DateTimeOffset at, bool active)
{
Campaign blackFriday = Campaign.StartNow("BLACKFRIDAY", 0.30m, TimeSpan.FromDays(3), Clock);
Assert.AreEqual(active, blackFriday.IsActive(at));
}
If each row needs a display name, a category or a reason to skip, the source returns TestDataRow<T> (the counterpart of xUnit's TheoryDataRow). [CombinatorialData], added in 4.4, produces every combination of parameter values, much like NUnit's [Combinatorial].
The Assert API
There are two methods for exceptions: Assert.ThrowsExactly<T> accepts exactly type T only, Assert.Throws<T> accepts T and its subtypes; their async counterparts are ThrowsExactlyAsync and ThrowsAsync. The old Assert.ThrowsException was removed in 4.0. Most of the other methods were added between 3.8 and 4.4 and replace checks that older projects wrote with Assert.IsTrue(...):
// Static and takes a TestContext: runs once before the first test of this class
[ClassInitialize]
public static void LoadCalendar(TestContext context)
{
FixedClock clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
calendar =
[
Campaign.StartNow("WINTER26", 0.20m, TimeSpan.FromDays(60), clock),
Campaign.StartNow("READING-WEEK", 0.10m, TimeSpan.FromDays(7), clock),
Campaign.StartNow("BOOKFAIR", 0.25m, TimeSpan.FromDays(10), clock),
];
context.WriteLine($"Calendar loaded with {calendar.Count} campaigns");
}
[TestMethod]
public void Calendar_codes_follow_the_naming_rule()
{
Assert.HasCount(3, calendar);
Assert.ContainsSingle(c => c.Code == "BOOKFAIR", calendar);
foreach (Campaign campaign in calendar)
{
Assert.MatchesRegex("^[A-Z0-9-]+$", campaign.Code);
}
}
[TestMethod]
public void Discounts_stay_within_the_allowed_range()
{
foreach (Campaign campaign in calendar)
{
Assert.IsInRange(0.05m, 0.50m, campaign.DiscountRate);
}
}
Watch the parameter orders: in MSTest the expected value comes first (AreEqual(expected, actual), HasCount(3, calendar)), the predicate comes before the collection (ContainsSingle(predicate, collection), the reverse of xUnit's Assert.Single(collection, predicate)), the range before the value (IsInRange(min, max, value)), and the pattern before the value (MatchesRegex(pattern, value)). When I wrote ContainsSingle out of xUnit habit as (calendar, c => ...), the build stopped with CS1503.
As you saw in the Answer section, 4.x failure messages show the assertion's source code (CallerArgumentExpression) and where two strings first differ. CollectionAssert and StringAssert are still there, but according to the documentation CollectionAssert may be deprecated in the future; in new code use their counterparts on Assert. To collect several failures into one report there is Assert.Scope(), but it is an experimental API: using it produces the MSTESTEXP diagnostic, and its shape may change.
Lifecycle and TestContext
[AssemblyInitialize]/[AssemblyCleanup] run once per assembly, [ClassInitialize]/[ClassCleanup] once per class, and [TestInitialize]/[TestCleanup] (or the constructor and Dispose) once per test. Initialize methods must be public static and take a single TestContext parameter. When the signature is wrong, the result in 4.x is heavier than you might expect. I wrote ClassInitialize as an instance method with no parameters:
Zero tests ran
Error output: Unhandled exception. Microsoft.VisualStudio.TestTools.UnitTesting.MSTestException: Discovery failed for source 'bin/Debug/net10.0/Bookstore.Catalog.Pricing.Tests.dll' with 1 errors:
MSTestAdapter failed to discover tests in class 'Bookstore.Catalog.Pricing.Tests.Scratch21' of assembly 'bin/Debug/net10.0/Bookstore.Catalog.Pricing.Tests.dll' because Method Bookstore.Catalog.Pricing.Tests.Scratch21.LoadCalendar has wrong signature. The method must be static, public, does not return a value and should take a single parameter of type TestContext. Additionally, if you are using async-await in method then return-type must be 'Task' or 'ValueTask'..
Test run summary: Zero tests ran
A mistake in a single class kept every test in the project from running. In 4.0, the default of TreatDiscoveryWarningsAsErrors became true; in 3.x the same mistake could slip by as a warning. The build's MSTEST0010 warning ("ClassInitialize method 'LoadCalendar' signature is invalid") had already said so.
TestContext gives information about the test. Since 3.6 it can be taken through the constructor; since 3.11 it has a CancellationToken property, which is cancelled when the test times out or the run is aborted:
[TestClass]
public sealed class CampaignCalendarTests(TestContext testContext)
{
...
[TestMethod]
public async Task Test_context_carries_the_name_and_the_cancellation_token()
{
await Task.Delay(10, testContext.CancellationToken);
Assert.AreEqual(nameof(Test_context_carries_the_name_and_the_cancellation_token), testContext.TestName);
Assert.StartsWith("Bookstore.Catalog.Pricing", testContext.FullyQualifiedTestClassName!);
}
}
In 4.x, TestContext throws when it is used at the wrong stage: for example, reading TestName or FullyQualifiedTestClassName inside AssemblyInitialize, or TestName inside ClassInitialize, is now an error.
From 3.x to 4.x: Changes, Analyzers and Parallelism
The "Migrate from MSTest v3 to v4" page on Microsoft Learn lists the changes; the ones you will run into most when upgrading a project:
<ul>
<li><code>[ExpectedException]</code> and <code>Assert.ThrowsException</code> were removed; use <code>Assert.ThrowsExactly</code>/<code>Throws</code> instead (the MSTEST0006 and MSTEST0039 code fixes do the conversion on 3.x).</li>
<li>Assert overloads that take both a <code>message</code> and <code>params object[]</code> were removed; write the message with string interpolation.</li>
<li><code>ClassCleanupBehavior</code> was removed: <code>ClassCleanup</code> now always runs at the end of the class. <code>TestContext.Properties</code> became a type-safe <code>IDictionary<string, object></code>, and the <code>TestTimeout</code> enum was removed.</li>
<li>Behavior changes: discovery warnings are errors by default, AppDomain isolation is off by default under MTP, and the way test IDs are generated changed (which can break test history in Azure DevOps).</li>
<li>Targets from .NET Core 3.1 to .NET 7 were dropped; the minimum is .NET 8 and .NET Framework 4.6.2. Libraries compiled against 3.x must be recompiled against 4.x.</li>
</ul>
The analyzers are MSTest's strongest side. In Recommended mode, a deliberately badly written test file:
warning MSTEST0014: DataRow argument types do not match method parameter types. Parameter 'price' expects type 'decimal', but the provided value has type 'double'; ...
warning MSTEST0017: Assertion arguments should be passed in the correct order. 'actual' and 'expected'/'notExpected' arguments have been swapped. (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0017)
warning MSTEST0037: Use 'Assert.AreEqual' instead of 'Assert.IsTrue' (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0037)
warning MSTEST0049: Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken' (https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0049)
MSTEST0017 noticed that in Assert.AreEqual(campaign.Apply(12.50m), 11.25m) the constant is in the actual position; the build-time counterpart in MSTest of the last pitfall in the NUnit post. MSTEST0037 and MSTEST0049 stay at "info" in the default mode, so without Recommended they did not appear in the build output.
MSTest runs tests sequentially by default. We turned on parallelism at assembly level:
// Test methods may run in parallel, across and within classes
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
ExecutionScope.ClassLevel runs only classes in parallel; Workers (default 0, meaning the processor count) limits the number of concurrent tests. A class or method that touches a shared resource is left out with [DoNotParallelize]. MSTEST0001 (info) recommends that the assembly state its parallelism intent explicitly with [Parallelize] or [DoNotParallelize]; in enterprise projects, the answer to "why are the tests slow" is often this one line.
A Quick Look at TUnit and "Which Framework?"
TUnit is the youngest .NET test framework: it discovers tests at compile time with a source generator instead of reflection, runs only on MTP (no VSTest support), and its assertions are written with await. The latest version on the day of writing is 1.85.0 (November 14, 2026), licensed under MIT. A small project for the ISBN rule in Catalog:
public sealed class Isbn13Tests
{
[Test]
[Arguments("9780441172719", true)]
[Arguments("9780441172718", false)]
[Arguments("978044117271", false)]
[Arguments("1234567890123", false)]
public async Task Isbn_is_validated(string isbn, bool expected)
{
await Assert.That(Isbn13.IsValid(isbn)).IsEqualTo(expected);
}
[Test]
public async Task Prefix_must_have_twelve_digits()
{
await Assert.That(() => Isbn13.CheckDigit("978044117")).ThrowsExactly<ArgumentException>();
}
}
The message of a test that deliberately expects the wrong check digit:
failed Wrong (18ms)
[Test Failure] AssertionException: Expected to be 8
but found 9
TUnit's test project is an ordinary Microsoft.NET.Sdk project with a single package reference, TUnit; thanks to the MTP setting in global.json, it ran with the other projects in the solution under the same dotnet test command. With its default settings it also writes an HTML report to the TestResults folder. Async assertions bring TUnit's version of the NUnit 5 pitfall: an Assert.That(...) whose await is forgotten verifies nothing. TUnit prevents this up front; an assertion without await did not compile: error TUnitAssertions0002: Assert statements must be awaited - all TUnit assertions return Task.
The same job in four frameworks:
| Task | xUnit v3 | NUnit 5 | MSTest 4 | TUnit 1.85 |
|---|---|---|---|---|
| Test | [Fact] | [Test] | [TestMethod] | [Test] |
| Inline data | [InlineData] | [TestCase] | [DataRow] | [Arguments] |
| Equality | Assert.Equal(e, a) | Assert.That(a, Is.EqualTo(e)) | Assert.AreEqual(e, a) | await Assert.That(a).IsEqualTo(e) |
| Exact exception type | Assert.Throws<T> | Throws.TypeOf<T>() | Assert.ThrowsExactly<T> | ThrowsExactly<T>() |
| Exception or subtype | Assert.ThrowsAny<T> | Throws.InstanceOf<T>() | Assert.Throws<T> | Throws<T>() |
| Before each test | constructor | [SetUp] | [TestInitialize] / constructor | [Before(Test)] |
| Once per class | IClassFixture<T> | [OneTimeSetUp] | [ClassInitialize] (static) | [Before(Class)] (static) |
Watch the meaning of Assert.Throws<T>: the exact type in xUnit, the type and its subtypes in MSTest. When moving tests between the two frameworks, this is the difference that slips by most easily.
Which framework? In an existing project, stay with the framework it uses: both the NUnit and the MSTest upgrades in this blog showed the value of keeping it current more than of switching. In a new project, let the team's habits and toolchain decide; all four run on MTP, with the same dotnet test command and the same CI step. xUnit is one of the most widely used in the .NET ecosystem and the choice of this series; MSTest is strong in Microsoft-heavy teams with its analyzers and Visual Studio integration; NUnit in long-lived enterprise code with its constraint model and rich attribute set. Consider TUnit for a new project if Native AOT or discovery time in very large test suites matters; it is a young project, so releases come often (two days between 1.84.0 and 1.85.0), which makes version pinning even more important.
In Production
Start the upgrade on the last 3.x release. Clearing MSTEST0006, MSTEST0039 and the other warnings on 3.11.1 reduces the move to 4.x from a list of build errors to a version number change. As in Predict First, after the mechanical conversion ask every exception test "why": the parameter name, the message or a property.
Set MSTestAnalysisMode to Recommended and turn warnings into errors in CI. Rules like MSTEST0017 (swapped arguments) and MSTEST0049 (an async call that cannot be cancelled) catch at build time mistakes that slip through code review.
If you track test history in Azure DevOps, be aware of the test ID change in 4.0: after the upgrade, tests may show up in the history as new tests.
Common Pitfalls
[ExpectedException] left over from v2. It does not compile on 4.x, but the real problem is what it does while it compiles on 3.x: it counts an exception of the same type on any line of the method as success. The test in Predict First "verified" a rule that did not exist for years.
The ClassInitialize signature. public static, a single TestContext parameter, return type void, Task or ValueTask. A wrong signature stops discovery for the whole assembly on 4.x; do not ignore the MSTEST0010 warning.
decimal and culture in DataRow. An attribute argument cannot be a decimal; converting from a string with decimal.Parse depends on regional settings. Use whole numbers of kuruş or DynamicData.
Using Assert.Throws<T> with its xUnit meaning. In MSTest it also accepts subtypes; for the exact type use ThrowsExactly<T>.
A Small Experiment
★☆☆ Extend Campaign_longer_than_90_days_is_rejected to three durations with [DataRow]: 90 days (valid), 90 days and 1 second, and 365 days. Would you check the valid duration in the same test or in a separate one?
★★☆ Rewrite BlackFridayMoments to return TestDataRow<(DateTimeOffset, bool)> and give each row a DisplayName. What changed in the test results?
★★★ Move CampaignTests to xUnit, NUnit and TUnit (using the table above). Which assertion changed meaning depending on the framework, and in which test was the table not enough?
This post completes the Unit Test series. The question we asked in the first post, what a "unit" is, found its answer in the concept posts: tests that check behavior, survive refactoring, and are fast and readable; mocks only at the system boundary; design that makes the test's job easier. The last eight posts made the same ideas concrete with the tools used every day: xUnit's Assert API, AwesomeAssertions and Shouldly, NSubstitute and Moq, HttpClient, Bogus and AutoFixture, NUnit and MSTest. What should remain from these posts is less the names of the tools than a habit: asking what each test verifies, and why. Writing the test before the code is the topic of the TDD series; tests that work with a real database and HTTP belong to the Integration and E2E Testing series; and how to choose the values for your test data is covered in the upcoming Test Design Techniques series.