NUnit Testing Guide: Complete Tutorial for .NET Developers
NUnit is one of the oldest and most feature-rich .NET testing frameworks. It inspired xUnit.net and shares ideas with JUnit. This guide covers setup, writing tests, parameterized tests with TestCase, constraint-based assertions, setup and teardown, and integrating NUnit into CI pipelines.
Key Takeaways
NUnit uses [Test] and [TestCase] as the core attributes. [Test] is a single test, [TestCase] is a parameterized test. Both are simpler to scan than xUnit's [Fact]/[Theory] model.
Setup runs on the shared class instance. Unlike xUnit, NUnit reuses the same class instance across tests in a fixture. [SetUp] runs before each test, [TearDown] after. Shared state is intentional here, unlike xUnit where it's discouraged.
Assert.That is the modern assertion style. NUnit's constraint model — Assert.That(actual, Is.EqualTo(expected)) — reads like English and provides richer failure messages than classic-style Assert.AreEqual.
[TestFixture] is optional for most cases. Public classes with test methods are automatically detected. Use [TestFixture] only when you need parameterized fixture arguments.
NUnit has the richest constraint library. From Is.InRange to Has.Count to custom constraint extensions, NUnit's assertion syntax is the most expressive among .NET frameworks.
What Is NUnit?
NUnit is an open-source unit testing framework for .NET, ported from JUnit and one of the original xUnit-family frameworks. It predates MSTest and directly inspired xUnit.net. NUnit 3.x is the current major version and supports .NET Framework, .NET Core, and .NET 5+.
NUnit is known for:
- The most complete constraint-based assertion library in .NET
- Flexible parameterized testing with
[TestCase],[TestCaseSource],[ValueSource] [SetUp]/[TearDown]lifecycle hooks familiar to JUnit users- Broad IDE support (Rider, Visual Studio, VS Code)
Installing NUnit
dotnet add package NUnit
dotnet add package NUnit3TestAdapter
dotnet add package Microsoft.NET.Test.SdkOr create a new project:
dotnet new nunit -n MyProject.TestsYour .csproj:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>Writing Your First Test
using NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
[Test]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
// Arrange
var calc = new Calculator();
// Act
var result = calc.Add(3, 4);
// Assert
Assert.That(result, Is.EqualTo(7));
}
}Run:
dotnet testParameterized Tests
[TestCase] — Inline Data
[TestCase(2, 3, 5)]
[TestCase(0, 0, 0)]
[TestCase(-5, 5, 0)]
[TestCase(int.MaxValue, 0, int.MaxValue)]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
var calc = new Calculator();
Assert.That(calc.Add(a, b), Is.EqualTo(expected));
}[TestCase] with Expected Exception
[TestCase(0, typeof(DivideByZeroException))]
public void Divide_ByZero_ThrowsException(int divisor, Type exceptionType)
{
var calc = new Calculator();
Assert.That(() => calc.Divide(10, divisor), Throws.TypeOf(exceptionType));
}[TestCaseSource] — External Data
private static IEnumerable<TestCaseData> EmailValidationCases()
{
yield return new TestCaseData("user@example.com").Returns(true).SetName("valid email");
yield return new TestCaseData("not-an-email").Returns(false).SetName("no @ sign");
yield return new TestCaseData("@missing-user.com").Returns(false).SetName("missing user");
yield return new TestCaseData("user@").Returns(false).SetName("missing domain");
}
[TestCaseSource(nameof(EmailValidationCases))]
public bool IsValidEmail_VariousInputs_ReturnsExpected(string email)
{
return EmailValidator.IsValid(email);
}[Values] and [Range] for Combinatorial Tests
[Test]
public void Multiply_PositiveNumbers_IsPositive(
[Values(1, 2, 5, 10)] int a,
[Values(1, 2, 5, 10)] int b)
{
Assert.That(a * b, Is.Positive);
}
// Generates 4 × 4 = 16 tests automatically
[Test]
public void IsInRange_ValidInput_ReturnsTrue([Range(1, 100, 10)] int value)
{
Assert.That(RangeChecker.IsValid(value), Is.True);
}Setup and Teardown
NUnit reuses the class instance across tests in a fixture (unlike xUnit):
[TestFixture]
public class UserServiceTests
{
private UserService _service;
private Mock<IUserRepository> _mockRepo;
[SetUp]
public void SetUp()
{
// Runs before EACH test
_mockRepo = new Mock<IUserRepository>();
_service = new UserService(_mockRepo.Object);
}
[TearDown]
public void TearDown()
{
// Runs after EACH test
_service?.Dispose();
}
[OneTimeSetUp]
public void OneTimeSetUp()
{
// Runs once before ALL tests in this fixture
// Good for expensive setup: DB connection, HTTP client
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
// Runs once after ALL tests complete
}
[Test]
public void GetUser_ExistingId_ReturnsUser()
{
_mockRepo.Setup(r => r.FindById(1))
.Returns(new User { Id = 1, Name = "Alice" });
var user = _service.GetUser(1);
Assert.That(user.Name, Is.EqualTo("Alice"));
}
}Constraint-Based Assertions
NUnit's Assert.That uses a constraint model that reads like natural English:
Equality and Identity
Assert.That(result, Is.EqualTo(42));
Assert.That(result, Is.Not.EqualTo(0));
Assert.That(obj1, Is.SameAs(obj2)); // reference equality
Assert.That(obj1, Is.Not.SameAs(obj2));
Assert.That(value, Is.Null);
Assert.That(value, Is.Not.Null);Numeric
Assert.That(price, Is.GreaterThan(0));
Assert.That(discount, Is.LessThanOrEqualTo(100));
Assert.That(pi, Is.EqualTo(3.14159).Within(0.001)); // tolerance
Assert.That(ratio, Is.InRange(0.0, 1.0));
Assert.That(count, Is.Positive);
Assert.That(result, Is.Zero);Strings
Assert.That(name, Is.EqualTo("Alice").IgnoreCase);
Assert.That(message, Does.Contain("error"));
Assert.That(path, Does.StartWith("/api/"));
Assert.That(email, Does.EndWith("@example.com"));
Assert.That(code, Does.Match(@"^\d{3}-\d{4}$")); // regex
Assert.That(response, Is.Not.Empty);Collections
Assert.That(list, Is.Empty);
Assert.That(list, Has.Count.EqualTo(3));
Assert.That(list, Has.Count.GreaterThan(0));
Assert.That(list, Contains.Item("apple"));
Assert.That(list, Does.Not.Contain("banana"));
Assert.That(list, Is.Unique); // no duplicates
Assert.That(list, Is.Ordered);
Assert.That(list, Is.EquivalentTo(expected)); // same elements, any order
Assert.That(list, Is.SubsetOf(superset));
Assert.That(list, Has.Exactly(2).Items.GreaterThan(5));
Assert.That(list, Has.All.GreaterThan(0));Exceptions
// Throws exact type
Assert.That(() => method(), Throws.TypeOf<ArgumentNullException>());
// Throws type or subtype
Assert.That(() => method(), Throws.InstanceOf<Exception>());
// With message
Assert.That(() => method(), Throws.TypeOf<ArgumentException>()
.With.Message.Contains("cannot be null"));
// No exception
Assert.That(() => method(), Throws.Nothing);Type Assertions
Assert.That(animal, Is.InstanceOf<Dog>());
Assert.That(animal, Is.AssignableTo<IAnimal>());
Assert.That(obj, Is.TypeOf<string>());Async Testing
[Test]
public async Task FetchData_ValidEndpoint_ReturnsResult()
{
var client = new ApiClient("https://api.example.com");
var result = await client.GetAsync<Product>(1);
Assert.That(result, Is.Not.Null);
Assert.That(result.Id, Is.EqualTo(1));
}
[Test]
public async Task ProcessAsync_InvalidInput_ThrowsValidationException()
{
var service = new ProcessingService();
Assert.That(
async () => await service.ProcessAsync(null),
Throws.TypeOf<ArgumentNullException>()
);
}Organizing Tests
Categories
[Test]
[Category("Integration")]
public void Database_ConnectionTest() { }
[Test]
[Category("Slow")]
public void ProcessLargeFile_Performance() { }Run only fast tests:
dotnet test --filter <span class="hljs-string">"Category!=Integration&Category!=Slow"Explicit and Ignored Tests
[Test]
[Ignore("API not yet implemented — HEL-201")]
public void NewFeature_ShouldWork() { }
[Test]
[Explicit("Manual performance test — don't run in CI")]
public void LoadTest_HundredConcurrentUsers() { }Test Ordering
[TestFixture]
[Order(1)]
public class SetupTests { }
[TestFixture]
[Order(2)]
public class FeatureTests { }Inheritance and Abstract Fixtures
public abstract class RepositoryTestBase<T> where T : IEntity
{
protected IRepository<T> Repository;
[SetUp]
public virtual void SetUp()
{
Repository = CreateRepository();
}
protected abstract IRepository<T> CreateRepository();
[Test]
public void GetById_ExistingEntity_ReturnsEntity()
{
var entity = Repository.GetById(1);
Assert.That(entity, Is.Not.Null);
}
}
public class ProductRepositoryTests : RepositoryTestBase<Product>
{
protected override IRepository<Product> CreateRepository()
{
return new ProductRepository(InMemoryDbContext.Create());
}
}Running Tests
# Run all tests
dotnet <span class="hljs-built_in">test
<span class="hljs-comment"># Filter by test name
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"Name~Add_Two"
<span class="hljs-comment"># Filter by category
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"Category=Unit"
<span class="hljs-comment"># Parallel execution
dotnet <span class="hljs-built_in">test -- NUnit.NumberOfTestWorkers=4
<span class="hljs-comment"># Output format
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"nunit;LogFilePath=results.xml"CI/CD Integration
GitHub Actions
name: .NET Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore --configuration Release
- run: dotnet test --no-build --configuration Release --logger "nunit;LogFilePath=TestResults/results.xml"
- uses: actions/upload-artifact@v4
if: always()
with:
name: nunit-results
path: TestResults/results.xmlNUnit vs xUnit vs MSTest
| Feature | NUnit | xUnit | MSTest |
|---|---|---|---|
| Setup | [SetUp] / [OneTimeSetUp] | Constructor | [TestInitialize] |
| Parameterized | [TestCase] / [TestCaseSource] | [Theory] / [InlineData] | [DataRow] |
| Assertion style | Assert.That (constraint) | Assert.Equal etc. | Assert.AreEqual etc. |
| Instance per test | No (shared) | Yes (new per test) | No (shared) |
| Origins | Port of JUnit | Written post-NUnit | Microsoft |
| Combinatorial | [Values], [Range] | Limited | Limited |
NUnit's richer parameterized test options and constraint library make it a strong choice for data-heavy or domain-specific test suites. For greenfield .NET projects, xUnit is the current default, but NUnit remains actively maintained and is the right tool for teams that prefer its style.
End-to-End Coverage with HelpMeTest
NUnit excels at testing your .NET business logic in isolation. For end-to-end testing of the deployed application — UI flows, API contracts, cross-browser behavior — HelpMeTest complements your NUnit suite by running Robot Framework + Playwright tests against your live environment.
HelpMeTest's free tier supports up to 10 tests with 24/7 monitoring. The Pro plan ($100/month) adds unlimited tests and parallel execution.