NUnit Testing Guide: Complete Tutorial for .NET Developers

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.Sdk

Or create a new project:

dotnet new nunit -n MyProject.Tests

Your .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 test

Parameterized 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.xml

NUnit 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.

Read more