Unit testing in .NET is the practice of verifying a single unit of behavior, typically a method or a class, in isolation from the database, the network and the rest of the system. Done well, it turns a risky change into a five-second feedback loop instead of a manual regression pass. This guide is for C# developers who are new to structured testing in .NET or want to sharpen existing habits: choosing between xUnit, NUnit and MSTest, structuring tests with Arrange-Act-Assert, writing theories and parameterized tests, mocking with NSubstitute and Moq, picking an assertion library now that FluentAssertions has changed its license, building test data with builders, and testing time-dependent code with TimeProvider.

What Is Unit Testing and Why It Matters in .NET#

A unit test exercises one unit of behavior with no external dependencies: no real database, no HTTP call, no file system. That isolation is what makes unit tests fast enough to run on every save and deterministic enough to trust when one fails. Broader tests that exercise a real database, a running ASP.NET Core app or several components together are integration tests, covered in Integration Testing ASP.NET Core with WebApplicationFactory and Testcontainers; both layers matter, but unit tests should be the largest and fastest layer in the suite.

Beyond catching regressions, writing unit tests first or alongside the code pushes you toward better design almost as a side effect. A class that is hard to unit test is usually a class with too many responsibilities or hidden dependencies, and the fix, typically introducing an interface and injecting it, is exactly what Dependency Injection in .NET: The Complete Guide recommends for its own reasons. Tests also double as executable documentation: a well-named test describes a behavior more precisely, and more durably, than a comment does.

How .NET Testing Works: Frameworks, Runners and Microsoft.Testing.Platform#

Two separate concerns are bundled into "which testing framework do I use": the test framework (xUnit, NUnit or MSTest) that provides attributes such as [Fact] or [Test] and an assertion API, and the test runner that discovers and executes those tests and reports results. For years, the runner was VSTest, invoked through vstest.console or the dotnet test command. Microsoft.Testing.Platform (MTP) is a newer, lightweight runner that is compiled directly into the test executable, with no external console host process, and it is open source in the microsoft/testfx repository. As of the .NET 10 SDK, dotnet test has built-in support for running tests on MTP, and .NET 11's built-in xUnit v3 project templates default to it, with NUnit and MSTest both offering MTP support as well. In practice, this mostly changes how tests are invoked in CI rather than how you write them, so the rest of this guide applies whichever runner your project uses.

Getting Started: Your First xUnit Test#

A minimal xUnit test project references the xunit.v3 package, which pulls in the core framework, the assertion library and a runner. A test method needs only the [Fact] attribute and an assertion.

C#
public class DiscountCalculatorTests
{
    [Fact]
    public void ApplyDiscount_PremiumCustomer_Returns10PercentOff()
    {
        // Arrange
        var calculator = new DiscountCalculator();
        var order = new Order(Total: 200m, IsPremiumCustomer: true);

        // Act
        decimal discounted = calculator.ApplyDiscount(order);

        // Assert
        Assert.Equal(180m, discounted);
    }
}

dotnet test discovers and runs every [Fact] and [Theory] in the project, whether through VSTest or MTP, and reports pass/fail counts along with any assertion failure messages.

Structuring Tests: Arrange-Act-Assert and Naming#

The Arrange-Act-Assert (AAA) shape in the example above is worth keeping consistent across a codebase: set up the inputs and collaborators, perform the one action under test, then assert on the outcome. Mixing these phases, such as asserting partway through a long arrange block, makes a failing test much harder to read at a glance. A descriptive naming convention such as MethodUnderTest_Scenario_ExpectedResult pays for itself the first time a test fails in CI, since the test name alone should tell you what broke without opening the file.

Keep each test independent of every other test's execution order and of any shared mutable state; xUnit creates a new instance of the test class for every test method specifically to make accidental state sharing harder. When setup is genuinely expensive to repeat, such as spinning up an in-memory database once, use IClassFixture<T> to share it safely across the tests in a class instead of a mutable static field.

Theories and Parameterized Tests#

A [Theory] runs the same test body once per supplied data row, which removes near-duplicate [Fact] methods that only differ by input and expected output.

C#
public class DiscountCalculatorTests
{
    [Theory]
    [InlineData(50, false, 50)]
    [InlineData(200, true, 180)]
    [InlineData(0, true, 0)]
    public void ApplyDiscount_VariousOrders_ReturnsExpectedTotal(
        decimal total, bool isPremium, decimal expected)
    {
        var calculator = new DiscountCalculator();
        var order = new Order(total, isPremium);

        decimal result = calculator.ApplyDiscount(order);

        Assert.Equal(expected, result);
    }
}

For data too complex for attribute arguments, [MemberData] and [ClassData] pull rows from a method or a class instead. NUnit's equivalent is [TestCase], and MSTest's is [DataRow] combined with [DynamicData] for computed data; the concept, one test body driven by a table of inputs, is the same across all three frameworks.

xUnit vs NUnit vs MSTest: Choosing a Framework#

All three frameworks are actively maintained, run comfortably on Microsoft.Testing.Platform, and can express the same tests; the choice usually comes down to team convention, existing codebase and a handful of ergonomic differences. xUnit v3 (the xunit.v3 package, currently in its 4.x releases) is the most opinionated of the three: no [SetUp]/[TearDown], using the constructor and IDisposable.Dispose instead, and test classes run in parallel by default. NUnit (currently 4.x, with a 5.0 pre-release in progress) has the longest history and the richest constraint-based Assert.That(value, Is.EqualTo(expected)) syntax. MSTest (currently 4.x) integrates tightly with Visual Studio and is a common default in enterprise .NET shops, and has closed most of the historical feature gap with xUnit and NUnit.

AspectxUnit v3NUnitMSTest
Test attribute[Fact] / [Theory][Test] / [TestCase][TestMethod] / [DataRow]
Setup and teardownConstructor / IDisposable[SetUp] / [TearDown][TestInitialize] / [TestCleanup]
Shared expensive contextIClassFixture<T>, ICollectionFixture<T>[OneTimeSetUp][ClassInitialize]
Parallelism defaultTest classes run in parallel by defaultSequential unless [Parallelizable] is setSequential unless opted in
Microsoft.Testing.PlatformDefault for new project templatesSupported via the NUnit MTP runnerSupported
Good fit forNew projects, OSS-style teamsLong-lived suites needing rich assertionsVisual Studio-centric enterprise teams

Mocking with NSubstitute and Moq#

Most classes worth unit testing depend on something else: a repository, an external API client, a clock. Mocking substitutes a lightweight fake for that dependency so the test can control its behavior and verify how the unit under test used it, which is only possible cleanly when the dependency is expressed as an interface behind dependency injection. NSubstitute favors a terse syntax that reads like calling the real member:

C#
[Fact]
public async Task PlaceOrderAsync_PaymentFails_DoesNotReserveStock()
{
    // Arrange
    var paymentGateway = Substitute.For<IPaymentGateway>();
    paymentGateway.ChargeAsync(Arg.Any<decimal>()).Returns(false);
    var inventory = Substitute.For<IInventoryService>();
    var sut = new OrderService(paymentGateway, inventory);

    // Act
    await sut.PlaceOrderAsync(new Order(Total: 50m, IsPremiumCustomer: false));

    // Assert
    await inventory.DidNotReceive().ReserveAsync(Arg.Any<Order>());
}

Moq, currently at version 4.21.0 and still the most widely used .NET mocking library, uses an explicit Setup/Returns/Verify API instead:

C#
[Fact]
public async Task PlaceOrderAsync_PaymentSucceeds_ReservesStock()
{
    // Arrange
    var paymentGateway = new Mock<IPaymentGateway>();
    paymentGateway.Setup(g => g.ChargeAsync(It.IsAny<decimal>())).ReturnsAsync(true);
    var inventory = new Mock<IInventoryService>();
    var sut = new OrderService(paymentGateway.Object, inventory.Object);

    // Act
    await sut.PlaceOrderAsync(new Order(Total: 50m, IsPremiumCustomer: false));

    // Assert
    inventory.Verify(i => i.ReserveAsync(It.IsAny<Order>()), Times.Once);
}

Moq briefly shipped a controversial telemetry component in one 2023 release that the maintainers reverted days later; it remains free, open source and the most common choice today. NSubstitute stays deliberately smaller in scope and appeals to teams who prefer its call-like syntax over explicit Setup lambdas. Either way, mock collaborators, not the type under test, and prefer verifying outcomes over verifying every internal call when a simpler assertion would do.

Assertion Libraries: The FluentAssertions Licensing Change and Alternatives#

FluentAssertions popularized chained, readable assertions such as result.Should().Be(expected) with detailed failure messages, and for years it was close to a default choice. That changed with version 8: FluentAssertions versions 8 and later require a paid commercial license, sold through Xceed, for commercial use, while remaining free for open-source projects and non-commercial use; versions 7.x and earlier stay under the original free license indefinitely. Teams that want the same fluent style without the commercial terms have two verified options: pin to the last 7.x release, or switch to AwesomeAssertions, a community fork created at the point of the license change that keeps an Apache-licensed, drop-in-compatible API and has continued development past version 9.

C#
// AwesomeAssertions (or FluentAssertions 7.x) β€” identical fluent syntax
decimal discounted = calculator.ApplyDiscount(order);

discounted.Should().Be(180m);
order.Should().NotBeNull();

If you would rather avoid a third-party assertion library entirely, each framework's own Assert class, NUnit's constraint-based Assert.That, or Shouldly, a smaller MIT-licensed library with its own fluent syntax and readable failure messages, all remain fully free options.

Test Data Builders#

Constructing a complex object for every test with a long constructor call obscures which fields the test actually cares about, and it breaks every existing test whenever the constructor gains a parameter. A test data builder wraps construction in a fluent API with sensible defaults, so each test sets only what matters to its scenario.

C#
public sealed class OrderBuilder
{
    private decimal _total = 100m;
    private bool _isPremiumCustomer;

    public OrderBuilder WithTotal(decimal total)
    {
        _total = total;
        return this;
    }

    public OrderBuilder AsPremiumCustomer()
    {
        _isPremiumCustomer = true;
        return this;
    }

    public Order Build() => new(_total, _isPremiumCustomer);
}

// Arrange, with only the relevant fields called out
Order order = new OrderBuilder().WithTotal(200m).AsPremiumCustomer().Build();

Builders scale well as a domain model grows and keep tests focused on intent rather than on plumbing.

Testable Time with TimeProvider#

Code that calls DateTime.UtcNow or DateTimeOffset.UtcNow directly is hard to test deterministically, because the clock keeps moving during the test. TimeProvider, an abstract class in the System namespace since .NET 8, fixes this by making time an injected dependency: production code takes a TimeProvider and calls GetUtcNow(), wired to the real clock through TimeProvider.System at startup, while tests substitute FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package to set and advance the clock explicitly.

C#
public sealed class SubscriptionService(TimeProvider timeProvider)
{
    public bool IsExpired(Subscription subscription) =>
        subscription.ExpiresAtUtc <= timeProvider.GetUtcNow();
}

[Fact]
public void IsExpired_ClockPastExpiry_ReturnsTrue()
{
    var fakeTime = new FakeTimeProvider(startTime: DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
    var sut = new SubscriptionService(fakeTime);
    var subscription = new Subscription(ExpiresAtUtc: DateTimeOffset.Parse("2026-01-02T00:00:00Z"));

    fakeTime.Advance(TimeSpan.FromDays(2));

    Assert.True(sut.IsExpired(subscription));
}

This removes every Thread.Sleep and every flaky, timing-dependent assertion from a test suite, and it works the same way whether the code under test schedules a timer, checks an expiry or computes a duration.

Best Practices#

  • Keep unit tests fast and isolated, with no shared mutable static state and no real I/O; push anything that needs a database or the network into integration tests.
  • Name tests for the behavior they verify, not the line of code, so a CI failure is readable without opening the file.
  • Use builders or factory methods for complex test objects instead of repeating long constructor calls.
  • Prefer asserting behavior over verifying every internal call, which keeps tests resilient to safe refactors.
  • Inject TimeProvider into any class that reads the clock, rather than calling DateTime.UtcNow directly.
  • Run the full unit test suite on every pull request, as described in CI/CD for .NET with GitHub Actions and Azure DevOps, so regressions are caught before merge.
  • Choose one assertion library per codebase and use it consistently, rather than mixing Assert, FluentAssertions-style syntax and Shouldly in the same project.

Common Pitfalls#

  • Testing implementation details instead of observable behavior, which makes tests break on every safe refactor even when behavior is unchanged.
  • Sharing mutable state between tests, such as a static counter or a shared in-memory list, which causes order-dependent failures that vanish when tests run alone.
  • Over-mocking, where nearly every collaborator is a mock and the test ends up re-asserting its own setup rather than exercising real logic.
  • Forgetting to await an async call inside a test, which can let a test pass even though the awaited operation actually threw or never completed.
  • Blurring unit and integration tests in one project without a trait or category to separate them, which slows the fast feedback loop that unit tests exist to provide; see Integration Testing ASP.NET Core with WebApplicationFactory and Testcontainers for keeping the two apart.
  • Staying on FluentAssertions 8 or later in a commercial codebase without checking the license terms, or upgrading blindly without a plan for the cost.

Frequently Asked Questions#

Should I use xUnit, NUnit or MSTest for a new .NET project?#

Any of the three is a reasonable default in 2026, since all are actively maintained and run on Microsoft.Testing.Platform. xUnit v3 is a common default for new, greenfield projects because of its opinionated conventions and default parallelism; MSTest fits naturally in Visual Studio-centric enterprise teams; NUnit suits teams that want its richer constraint-based assertions or already have NUnit expertise.

What is Microsoft.Testing.Platform and do I need to migrate to it?#

Microsoft.Testing.Platform (MTP) is a lightweight test runner built into the test executable itself, replacing the external VSTest console host. dotnet test supports it directly as of the .NET 10 SDK, and new xUnit v3 templates in .NET 11 default to it. Existing projects on VSTest keep working without changes; migrating mainly matters if you want faster, more portable test execution in CI or IDEs.

Is FluentAssertions still free to use?#

Versions 7.x and earlier remain under the original free license indefinitely. Versions 8 and later require a paid commercial license, sold through Xceed, for commercial use, while staying free for open-source and non-commercial projects. Teams that want to avoid the cost can pin to 7.x, switch to the AwesomeAssertions fork, which keeps the same fluent API under an open license, or use Shouldly or each framework's built-in assertions instead.

What is the difference between a mock, a stub and a fake?#

A stub returns canned data with no behavior of its own, used to feed the unit under test. A mock additionally records how it was called so the test can verify interactions, such as confirming a method was called exactly once. A fake is a working, simplified implementation, such as an in-memory repository backed by a List<T> instead of a database. NSubstitute and Moq can produce all three depending on how you configure them.

Do I need Moq or NSubstitute if I only test simple classes?#

No. A class with no external dependencies, such as a pure calculation or a value object, needs no mocking library at all; just construct it directly and assert on its output. Reach for a mocking library only once a class depends on something you need to control or verify, such as a repository, an API client or a clock.

Summary#

  • Unit tests verify one unit of behavior in isolation, fast enough to run on every save, and are the base of a healthy test pyramid alongside integration tests.
  • xUnit v3, NUnit and MSTest are all solid choices in 2026 and all run on Microsoft.Testing.Platform; pick based on team convention rather than a single "best" answer.
  • Use [Theory]/[TestCase]/[DataRow] to cover multiple scenarios without duplicating test bodies.
  • NSubstitute and Moq both isolate a unit from its collaborators; mock behavior, not every internal call.
  • FluentAssertions requires a commercial license from version 8 onward; AwesomeAssertions and Shouldly remain free fluent alternatives.
  • Inject TimeProvider instead of calling DateTime.UtcNow directly to keep time-dependent tests deterministic.

Further Reading#