Unit Testing in C#: xUnit, NUnit, MSTest and Moq
A unit test in C# checks one method or class in isolation, in milliseconds, without starting the application. To write one, create a test project with dotnet new xunit (or nunit, or mstest), reference the project under test, write test methods in the Arrange-Act-Assert pattern, replace databases and APIs with mocks, and run dotnet test on your machine and in CI. This guide walks through each step on .NET 10, the current LTS release.
Why unit test
Unit tests give you fast feedback: run the suite after a change and you know within seconds whether you broke something. That is what makes refactoring safe.
- Refactoring. Clean up a messy method and the tests catch any change in behavior.
- New features. Build on tested code without breaking existing behavior somewhere else.
- Debugging. A failing test points at the method that broke.
Tests also document behavior. A test named CalculateDiscount_Should_ReturnZero_When_OrderTotalIsNegative says what the code does with negative totals, and unlike a comment, it fails when that stops being true.
Choosing a C# unit testing framework
.NET has three main test frameworks: MSTest, NUnit and xUnit.net. All three are NuGet packages, all three have a dotnet new template (mstest, nunit, xunit), and Visual Studio’s Test Explorer and dotnet test run all three.
MSTest
MSTest is Microsoft’s framework. Test classes carry [TestClass], tests carry [TestMethod], and [DataRow] supplies parameters. Microsoft maintains it alongside .NET, and it now has most of the features the other two have.
NUnit
NUnit is the oldest of the three and has the largest set of attributes and assertions: [Test], [TestCase] for parameters, [SetUp] and [TearDown] for per-test setup, and a constraint-style Assert.That(actual, Is.EqualTo(expected)). With that many options, agree on a style as a team.
By default NUnit creates one instance of a test class (a fixture) and runs all its tests on it, so fields carry over from one test to the next. Add [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] to get a new instance per test.
xUnit
xUnit.net is designed to push you toward isolated tests. It uses [Fact] for a test and [Theory] with [InlineData] for a parameterized test, and setup goes in the constructor instead of a [SetUp] method.
xUnit makes a new instance per test, so tests share no instance fields. MSTest does the same; NUnit does it only when you opt in. The dotnet new xunit template in the .NET 10 SDK creates an xUnit v2 project; for xUnit v3, install the templates with dotnet new install xunit.v3.templates and use dotnet new xunit3. The code in this guide runs on both.
MSTest vs NUnit vs xUnit at a glance
| MSTest | NUnit | xUnit | |
|---|---|---|---|
| Test attribute | [TestMethod] |
[Test] |
[Fact] |
| Parameterized test | [DataRow] |
[TestCase] |
[Theory] + [InlineData] |
| Install | NuGet (dotnet new mstest) |
NuGet (dotnet new nunit) |
NuGet (dotnet new xunit) |
| Test class instances | One per test | One per fixture (opt-in per test) | One per test |
| Best suited for | Teams that want Microsoft’s framework | Suites that need many attribute options | Teams that want isolated tests by default |
There’s no wrong choice. The rest of this guide uses xUnit; the patterns carry over to the other two.
Writing your first C# unit test
The example is an order processor that gives premium customers a 10% discount.
The code under test
Start with a class library that holds an Order and the OrderProcessor to test:
namespace Shop;
public class Order
{
public int Id { get; set; }
public decimal TotalAmount { get; set; }
public bool IsPremiumCustomer { get; set; }
}
namespace Shop;
public class OrderProcessor
{
public decimal CalculateFinalPrice(Order order)
{
if (order.IsPremiumCustomer)
{
return order.TotalAmount * 0.9m; // 10% discount
}
return order.TotalAmount;
}
}
CalculateFinalPrice is the unit: its logic depends on nothing outside the method.
Create the test project
Keep tests in their own project that references the code under test. From the command line:
dotnet new sln -n Shop
dotnet new classlib -n Shop -o src/Shop
dotnet new xunit -n Shop.Tests -o tests/Shop.Tests
dotnet sln add src/Shop/Shop.csproj tests/Shop.Tests/Shop.Tests.csproj
dotnet add tests/Shop.Tests reference src/Shop
In Visual Studio, the same steps are Add > New Project with the xUnit Test Project template, then Add > Project Reference from the test project.
The Arrange-Act-Assert pattern
Write each test in three parts:
- Arrange: create the objects, data and mocks the test needs.
- Act: call the one method you are testing.
- Assert: check the result.
Keeping the three apart makes every test read the same way. Here is a test for the premium discount:
using Shop;
public class OrderProcessorTests
{
[Fact]
public void CalculateFinalPrice_Should_Apply10PercentDiscount_When_CustomerIsPremium()
{
// Arrange
var order = new Order { TotalAmount = 100m, IsPremiumCustomer = true };
var processor = new OrderProcessor();
// Act
var finalPrice = processor.CalculateFinalPrice(order);
// Assert
Assert.Equal(90m, finalPrice);
}
}
[Fact] marks the method as a test. Assert.Equal(expected, actual) fails the test if the two values differ. Name the test after the behavior it checks, so a failure in CI tells you what broke.
To cover several inputs with one method, use a [Theory]:
[Theory]
[InlineData(100, true, 90)]
[InlineData(100, false, 100)]
[InlineData(0, true, 0)]
public void CalculateFinalPrice_Should_ApplyDiscountOnlyForPremium(decimal total, bool isPremium, decimal expected)
{
var order = new Order { TotalAmount = total, IsPremiumCustomer = isPremium };
var finalPrice = new OrderProcessor().CalculateFinalPrice(order);
Assert.Equal(expected, finalPrice);
}
Run the tests with dotnet test from the solution folder, or with Test > Test Explorer > Run All Tests in Visual Studio.
Isolating code with mocks

A mock replaces a real dependency (a database, an API, an email service) with an object you control. A test that talks to a live database is an integration test: slower, and it can fail because the network hiccuped rather than because your code is wrong. With a mock, the test runs in milliseconds and gives the same result every time.
Moq and NSubstitute
The two common mocking libraries are Moq and NSubstitute:
- Moq sets up and checks calls with lambdas:
Setup(),Returns()andVerify(). - NSubstitute reads more like a sentence:
gateway.Received().Send(...).
Both do the same job. The example uses Moq.
A Moq example
Say that after processing an order, the app sends a notification for premium customers. Put the sending behind an interface, so the class that processes orders doesn’t know how messages are delivered:
namespace Shop;
public interface INotificationGateway
{
void Send(string recipient, string message);
}
An OrderService takes the gateway through its constructor (dependency injection):
namespace Shop;
public class OrderService
{
private readonly INotificationGateway _notificationGateway;
public OrderService(INotificationGateway notificationGateway)
{
_notificationGateway = notificationGateway;
}
public void ProcessOrder(Order order)
{
// ... order processing logic ...
if (order.IsPremiumCustomer)
{
_notificationGateway.Send("support@example.com", $"Order {order.Id} processed.");
}
}
}
The test should confirm that a premium order calls Send once with the right arguments, without sending anything. Add Moq to the test project with dotnet add tests/Shop.Tests package Moq, then:
using Moq;
using Shop;
public class OrderServiceTests
{
[Fact]
public void ProcessOrder_Should_SendNotification_When_CustomerIsPremium()
{
// Arrange: a mock gateway and a premium order
var mockGateway = new Mock<INotificationGateway>();
var order = new Order { Id = 123, IsPremiumCustomer = true };
var service = new OrderService(mockGateway.Object);
// Act
service.ProcessOrder(order);
// Assert: Send was called once with these arguments
mockGateway.Verify(g => g.Send(
"support@example.com",
"Order 123 processed."
), Times.Once);
}
}
What each piece does:
new Mock<INotificationGateway>()creates a mock that implements the interface.mockGateway.Objectis the fake implementation you pass toOrderService, not theMock<>wrapper.mockGateway.Verify(...)checks that a call matching the lambda happened, with these argument values.Times.Oncerequires exactly one call: not zero, not two.
Add the opposite test too: a non-premium order should never call Send (Times.Never, with It.IsAny<string>() for the arguments).
Testing code that depends on the current time
Code that reads DateTime.Now or DateTime.UtcNow can’t be tested deterministically, because the value changes on every run. Since .NET 8, the framework has a built-in abstraction for this: TimeProvider. Inject it instead of reading the clock:
namespace Shop;
public class Coupon
{
private readonly TimeProvider _timeProvider;
public Coupon(DateTimeOffset expiresAt, TimeProvider timeProvider)
{
ExpiresAt = expiresAt;
_timeProvider = timeProvider;
}
public DateTimeOffset ExpiresAt { get; }
public bool IsExpired() => _timeProvider.GetUtcNow() >= ExpiresAt;
}
In production, pass TimeProvider.System. In tests, use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, which you can set and move forward:
using Microsoft.Extensions.Time.Testing;
using Shop;
public class CouponTests
{
[Fact]
public void IsExpired_Should_ReturnTrue_After_ExpiryTime()
{
// Arrange: a fake clock set to a fixed moment
var start = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(start);
var coupon = new Coupon(start.AddHours(1), clock);
Assert.False(coupon.IsExpired());
// Act: move the clock forward two hours
clock.Advance(TimeSpan.FromHours(2));
// Assert
Assert.True(coupon.IsExpired());
}
}
You don’t need your own IClock interface anymore; TimeProvider also covers timers and Task.Delay.
Running tests in GitHub Actions
Tests protect the team only when they run on every change. A CI workflow runs dotnet test on each pull request, and a failing test fails the check.
Put this in .github/workflows/dotnet.yml:
name: .NET CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
The workflow runs on pushes to main and on pull requests against it. It checks out the code, installs the .NET 10 SDK, then restores, builds and tests. .NET 10 is the current LTS release (November 2025, supported until November 2028); .NET 8 support ends on November 10, 2026.
If any test fails, dotnet test exits with an error, the job fails, and GitHub marks the pull request with a red X. To block the merge as well, make the job a required status check in a branch protection rule or ruleset. The test step’s log names the failing test. For other CI services, see our comparison of CI/CD platforms.
A checklist before you merge

- Deterministic. The test passes every time, on any machine: no real clock, network, database or file system.
- One behavior per test. A test that checks several things is hard to debug when it fails.
- Public behavior only. Test what the class promises, not its private helpers, so you can refactor the internals.
- Readable. The name says the behavior, and the body follows Arrange-Act-Assert.
- Fast. If the suite gets slow, people stop running it locally; watch the run time.
- Green in CI. All tests pass on the pull request before you merge.
Frequently asked questions
What’s the difference between unit, integration and end-to-end tests?
A unit test checks one method or class with its dependencies replaced by mocks, so it’s fast and reliable. An integration test checks that real parts work together, such as your data layer against a real database. An end-to-end test drives the whole application the way a user would, through the UI. Most suites have many unit tests, fewer integration tests and a handful of end-to-end tests for the critical user flows.
Should I test private methods in C#?
No. Test private methods through the public methods that call them, because tests tied to private details break every time you refactor. If a private method seems to need its own tests, it is probably doing too much: move it into a separate class with a public method, and test that.
What is a good code coverage target?
There’s no universal number. Use coverage to find important code with no tests at all, and put your effort into the business logic that matters most. Chasing 100% tends to produce weak tests that execute lines without checking anything.
How do I test code that uses DateTime.Now?
Inject the built-in TimeProvider (.NET 8 and later) instead of reading DateTime.Now or DateTime.UtcNow directly. In production pass TimeProvider.System; in tests pass a FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, set to a fixed time, and call Advance() to move it forward.
Which is better: xUnit, NUnit or MSTest?
All three are maintained and work with dotnet test and Visual Studio. xUnit isolates tests by default, NUnit has the most attributes and assertion styles, and MSTest is Microsoft’s own framework. Pick one per solution and use it consistently.