Every .NET team asks the same question: xUnit, NUnit, or MSTest?
The answer depends on your team, your codebase, and what you value in a testing framework. This article gives you a decision framework, not an opinion.
Common questions this answers
- Which testing framework should my team use?
- How do I set up integration tests with WebApplicationFactory?
- What is the difference between xUnit, NUnit, and MSTest attributes?
- How do I test async code?
- How do I isolate tests that share dependencies?
Definition (what this means in practice)
Testing strategies encompass framework selection, test organization, and patterns for reliable test execution. The right strategy enables fast feedback, catches regressions early, and maintains developer confidence in the test suite.
In practice, this means choosing a framework that fits your team, structuring tests for maintainability, and using integration tests strategically without making the suite slow or flaky.
Terms used
- SUT (System Under Test): the code being tested.
- Test fixture: a class containing test methods (called differently in each framework).
- Test isolation: ensuring tests do not affect each other's results.
- Parameterized test: a single test method executed with different input values.
- WebApplicationFactory: ASP.NET Core's in-memory test server for integration tests.
Reader contract
This article is for:
- Teams choosing a testing framework for a new project.
- Engineers setting up integration tests for ASP.NET Core.
- Developers improving existing test suites.
You will leave with:
- A scoring rubric for framework selection.
- Copy-paste setup for xUnit, NUnit, and MSTest.
- Integration testing patterns with WebApplicationFactory.
This is not for:
- UI testing (Playwright, Selenium).
- Performance testing (BenchmarkDotNet).
- E2E testing across multiple services.
Quick start (10 minutes)
If you do nothing else, do this:
Verified on: .NET 10.
- Create a test project with your chosen framework:
# xUnit (recommended for new projects)
dotnet new xunit -n MyApp.Tests
# NUnit
dotnet new nunit -n MyApp.Tests
# MSTest
dotnet new mstest -n MyApp.Tests
- Add a reference to your main project:
dotnet add MyApp.Tests reference MyApp
- Write your first test following the Arrange-Act-Assert pattern:
// xUnit
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.Equal(5, result);
}
}
- Run tests:
dotnet test
Framework comparison
Attribute comparison
| Concept | xUnit | NUnit | MSTest |
|---|---|---|---|
| Test class | (none) | [TestFixture] |
[TestClass] |
| Test method | [Fact] |
[Test] |
[TestMethod] |
| Parameterized test | [Theory] + [InlineData] |
[TestCase] |
[DataRow] |
| Setup per test | Constructor | [SetUp] |
[TestInitialize] |
| Teardown per test | IDisposable |
[TearDown] |
[TestCleanup] |
| Setup per class | IClassFixture<T> |
[OneTimeSetUp] |
[ClassInitialize] |
| Teardown per class | IClassFixture<T> |
[OneTimeTearDown] |
[ClassCleanup] |
| Skip test | [Fact(Skip = "reason")] |
[Ignore("reason")] |
[Ignore] |
Assertion style comparison
// xUnit - static Assert methods
Assert.Equal(expected, actual);
Assert.True(condition);
Assert.Throws<InvalidOperationException>(() => sut.Method());
// NUnit - constraint-based with Assert.That
Assert.That(actual, Is.EqualTo(expected));
Assert.That(condition, Is.True);
Assert.That(() => sut.Method(), Throws.TypeOf<InvalidOperationException>());
// MSTest - static Assert methods
Assert.AreEqual(expected, actual);
Assert.IsTrue(condition);
Assert.ThrowsException<InvalidOperationException>(() => sut.Method());
Parameterized test comparison
// xUnit
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
var result = new Calculator().Add(a, b);
Assert.Equal(expected, result);
}
// NUnit
[TestCase(1, 2, 3)]
[TestCase(0, 0, 0)]
[TestCase(-1, 1, 0)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
var result = new Calculator().Add(a, b);
Assert.That(result, Is.EqualTo(expected));
}
// MSTest
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0)]
[DataRow(-1, 1, 0)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
var result = new Calculator().Add(a, b);
Assert.AreEqual(expected, result);
}
Decision framework
Use this scoring rubric to choose a framework. Score each criterion 1-3 based on importance to your team.
| Criterion | xUnit | NUnit | MSTest | Notes |
|---|---|---|---|---|
| Microsoft ecosystem alignment | 3 | 2 | 3 | xUnit is used in ASP.NET Core docs and samples |
| Parallelization by default | 3 | 2 | 2 | xUnit runs test classes in parallel by default |
| Test isolation philosophy | 3 | 2 | 2 | xUnit creates new instance per test, enforcing isolation |
| Mature ecosystem | 2 | 3 | 2 | NUnit has longest history and most extensions |
| Constraint-based assertions | 1 | 3 | 1 | NUnit's Assert.That is most expressive |
| Visual Studio integration | 2 | 2 | 3 | MSTest has tightest VS integration |
| Learning curve for team | 2 | 2 | 3 | MSTest most familiar to legacy .NET developers |
Recommendations by scenario
New ASP.NET Core project: xUnit. It is used in Microsoft's official samples and documentation, runs tests in parallel by default, and enforces test isolation through constructor-based setup.
Team familiar with NUnit: Stay with NUnit. The migration cost outweighs any framework benefits. NUnit's constraint assertions are expressive and well-documented.
Enterprise with Visual Studio-centric workflow: MSTest. Best Visual Studio integration, Microsoft support, and familiar to developers from Windows Forms/WPF background.
Mixed or uncertain: xUnit. It is the safest default for modern .NET development.
Unit test patterns
Naming convention
Use the three-part naming standard:
// Pattern: MethodName_Scenario_ExpectedBehavior
[Fact]
public void Add_NegativeNumbers_ReturnsCorrectSum()
[Fact]
public void Validate_EmptyEmail_ThrowsArgumentException()
[Fact]
public void GetUser_NonExistentId_ReturnsNull()
Arrange-Act-Assert
Every test should have three distinct sections:
[Fact]
public void ProcessOrder_ValidOrder_ReturnsConfirmation()
{
// Arrange - set up dependencies and test data
var orderService = new OrderService(new FakeInventory());
var order = new Order { ProductId = 1, Quantity = 5 };
// Act - perform the action being tested
var result = orderService.Process(order);
// Assert - verify the expected outcome
Assert.NotNull(result);
Assert.Equal(OrderStatus.Confirmed, result.Status);
}
Avoid logic in tests
Tests should not contain conditionals or loops. Use parameterized tests instead:
// BAD: Logic in test
[Fact]
public void Validate_InvalidInputs_ThrowsException()
{
var inputs = new[] { "", null, " " };
foreach (var input in inputs)
{
Assert.Throws<ArgumentException>(() => validator.Validate(input));
}
}
// GOOD: Parameterized test
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
public void Validate_InvalidInput_ThrowsArgumentException(string input)
{
Assert.Throws<ArgumentException>(() => validator.Validate(input));
}
Integration testing with WebApplicationFactory
Integration tests verify that components work together. ASP.NET Core provides WebApplicationFactory for in-memory integration tests.
Basic setup
// Add package: Microsoft.AspNetCore.Mvc.Testing
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetProducts_ReturnsSuccessStatusCode()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
}
}
Expose Program class
Make your Program class accessible to tests:
// At the end of Program.cs
public partial class Program { }
Custom factory with test database
Replace the production database with a test database:
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove production database registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// Add test database
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
});
builder.UseEnvironment("Testing");
}
}
Testing authenticated endpoints
Create a test authentication handler:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "testuser"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "TestScheme");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Usage in test
[Fact]
public async Task GetSecureEndpoint_WithAuth_ReturnsSuccess()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"TestScheme", options => { });
});
}).CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("TestScheme");
var response = await client.GetAsync("/api/admin/users");
response.EnsureSuccessStatusCode();
}
Test isolation strategies
xUnit: Constructor and IDisposable
xUnit creates a new instance for each test. Use the constructor for setup and IDisposable for cleanup:
public class OrderServiceTests : IDisposable
{
private readonly AppDbContext _context;
private readonly OrderService _sut;
public OrderServiceTests()
{
// Runs before each test
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
_sut = new OrderService(_context);
}
public void Dispose()
{
// Runs after each test
_context.Dispose();
}
[Fact]
public void CreateOrder_ValidData_SavesOrder()
{
// Test uses isolated database instance
}
}
Shared fixture for expensive setup
For expensive setup (like WebApplicationFactory), share across tests in a class:
public class IntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public IntegrationTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
// All tests share the same factory instance
}
Collection fixture for sharing across classes
Share expensive resources across multiple test classes:
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
public class DatabaseFixture : IDisposable
{
public AppDbContext Context { get; }
public DatabaseFixture()
{
// Expensive setup once for all tests in collection
}
public void Dispose() { }
}
[Collection("Database")]
public class OrderTests
{
private readonly DatabaseFixture _fixture;
public OrderTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
}
[Collection("Database")]
public class ProductTests
{
private readonly DatabaseFixture _fixture;
public ProductTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
}
Async test patterns
Async test methods
All frameworks support async test methods:
// xUnit
[Fact]
public async Task GetUserAsync_ExistingId_ReturnsUser()
{
var service = new UserService();
var user = await service.GetUserAsync(1);
Assert.NotNull(user);
}
// NUnit
[Test]
public async Task GetUserAsync_ExistingId_ReturnsUser()
{
var service = new UserService();
var user = await service.GetUserAsync(1);
Assert.That(user, Is.Not.Null);
}
// MSTest
[TestMethod]
public async Task GetUserAsync_ExistingId_ReturnsUser()
{
var service = new UserService();
var user = await service.GetUserAsync(1);
Assert.IsNotNull(user);
}
Testing async exceptions
// xUnit
[Fact]
public async Task DeleteAsync_NonExistentId_ThrowsNotFoundException()
{
var service = new UserService();
await Assert.ThrowsAsync<NotFoundException>(
() => service.DeleteAsync(999));
}
// NUnit
[Test]
public void DeleteAsync_NonExistentId_ThrowsNotFoundException()
{
var service = new UserService();
Assert.ThrowsAsync<NotFoundException>(
async () => await service.DeleteAsync(999));
}
Avoid async void in tests
Never use async void in test methods. The test runner cannot await completion:
// BAD: Test may pass before async work completes
[Fact]
public async void GetUser_ReturnsUser()
{
var user = await service.GetUserAsync(1);
Assert.NotNull(user);
}
// GOOD: Test runner awaits completion
[Fact]
public async Task GetUser_ReturnsUser()
{
var user = await service.GetUserAsync(1);
Assert.NotNull(user);
}
Mocking patterns
Using Moq
// Add package: Moq
[Fact]
public async Task ProcessOrder_CallsInventoryService()
{
// Arrange
var mockInventory = new Mock<IInventoryService>();
mockInventory
.Setup(x => x.ReserveAsync(It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(true);
var orderService = new OrderService(mockInventory.Object);
var order = new Order { ProductId = 1, Quantity = 5 };
// Act
await orderService.ProcessAsync(order);
// Assert
mockInventory.Verify(
x => x.ReserveAsync(1, 5),
Times.Once);
}
Using NSubstitute
// Add package: NSubstitute
[Fact]
public async Task ProcessOrder_CallsInventoryService()
{
// Arrange
var inventory = Substitute.For<IInventoryService>();
inventory.ReserveAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(true);
var orderService = new OrderService(inventory);
var order = new Order { ProductId = 1, Quantity = 5 };
// Act
await orderService.ProcessAsync(order);
// Assert
await inventory.Received(1).ReserveAsync(1, 5);
}
When to mock
Mock external dependencies that:
- Are slow (databases, HTTP calls)
- Have side effects (email, payments)
- Return non-deterministic results (DateTime.Now, random)
Do not mock:
- The system under test
- Simple value objects
- Stable internal dependencies
Copy/paste artifact: test project setup
xUnit project
<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageReference Include="Moq" Version="4.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp\MyApp.csproj" />
</ItemGroup>
</Project>
NUnit project
<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="4.*" />
<PackageReference Include="NUnit.Analyzers" Version="4.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageReference Include="Moq" Version="4.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp\MyApp.csproj" />
</ItemGroup>
</Project>
MSTest project
<!-- MyApp.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="MSTest" Version="3.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageReference Include="Moq" Version="4.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp\MyApp.csproj" />
</ItemGroup>
</Project>
Copy/paste artifact: test checklist
Test Suite Checklist
1. Framework selection
- [ ] Team aligned on framework choice
- [ ] Test project created with correct packages
- [ ] CI pipeline runs tests on every commit
2. Unit tests
- [ ] Follow naming convention: Method_Scenario_Expected
- [ ] Use Arrange-Act-Assert structure
- [ ] No logic (if/for/while) in tests
- [ ] Parameterized tests for multiple inputs
- [ ] External dependencies mocked
3. Integration tests
- [ ] WebApplicationFactory configured
- [ ] Test database isolated from production
- [ ] Authentication handled for protected endpoints
- [ ] Tests do not depend on execution order
4. Test isolation
- [ ] Each test can run independently
- [ ] Tests do not share mutable state
- [ ] Database reset between tests or unique per test
5. Async tests
- [ ] All async methods return Task, not void
- [ ] Async exceptions tested with ThrowsAsync
- [ ] CancellationToken passed where applicable
Common failure modes
- Tests depend on execution order: one test sets up data another test expects. Fix with proper test isolation.
- Shared mutable state: static fields or singletons leak between tests. Fix with constructor-based setup in xUnit.
- Slow integration tests: every test creates full WebApplicationFactory. Fix with IClassFixture sharing.
- Flaky database tests: tests fail intermittently due to database state. Fix with unique database per test or proper cleanup.
- Testing implementation details: tests break when refactoring even though behavior is unchanged. Fix by testing public behavior, not private methods.
Checklist
- Framework chosen based on team needs, not opinion.
- Test naming follows Method_Scenario_Expected convention.
- Arrange-Act-Assert structure in all tests.
- Integration tests use WebApplicationFactory.
- Test database isolated from production.
- No async void test methods.
FAQ
Which framework is fastest?
All three frameworks have similar performance. xUnit runs test classes in parallel by default, which may make the overall suite faster. Test execution time is dominated by test code, not framework overhead.
Can I migrate from one framework to another?
Yes, but it requires changing attributes and assertions throughout. Automated tools can help, but expect manual review. Most teams stay with their current framework unless there is a compelling reason to migrate.
Should I use InMemoryDatabase or SQLite for tests?
SQLite is more realistic because it enforces constraints that InMemoryDatabase ignores. Use SQLite in-memory mode (DataSource=:memory:) for isolated tests with real database behavior.
How many integration tests should I have?
Integration tests are slower than unit tests. Test critical paths (authentication, main workflows) with integration tests. Test business logic with unit tests. A common ratio is 10:1 unit to integration tests.
Should I mock everything?
No. Mock external dependencies (HTTP, database, file system) and things with side effects. Do not mock the system under test or simple internal dependencies. Over-mocking leads to tests that pass but do not verify real behavior.
How do I test private methods?
Do not test private methods directly. Test them through the public API that uses them. If a private method is complex enough to need its own tests, consider extracting it to a separate class with a public interface.
What to do next
If starting a new project, use xUnit with the project setup above. If you have an existing project, focus on improving test isolation and reducing flakiness rather than switching frameworks.
For more on async patterns that affect testing, read Async/Await Pitfalls: The Deadlocks That Ship to Production.
If you want help improving your test strategy, reach out via Contact.
References
- Unit testing in .NET
- Integration tests in ASP.NET Core
- Unit testing best practices
- Unit testing with NUnit
- Unit testing with MSTest
- xUnit documentation
Author notes
Decisions:
- Recommend xUnit as default for new projects. Rationale: used in Microsoft samples, parallel by default, enforces isolation.
- Include all three frameworks in comparisons. Rationale: teams have existing investments and preferences.
- Focus on WebApplicationFactory for integration tests. Rationale: it is the official ASP.NET Core testing approach.
Observations:
- Teams that pick a framework based on benchmarks often regret it; team familiarity matters more.
- Over-mocking leads to tests that pass but do not catch real bugs.
- Flaky tests are usually caused by shared state, not framework issues.
- Integration test suites that are too large become a bottleneck in CI.