Skip to content
Learni
View all tutorials
.NET

How to Master NUnit for Unit Testing in 2026

Lire en français

Introduction

In 2026, with .NET 9 and ubiquitous CI/CD pipelines, NUnit remains the go-to unit testing framework for intermediate .NET developers. Unlike the more minimalist xUnit, NUnit provides a rich syntax with attributes like [TestCase] for parameterized data, [SetUp]/[TearDown] for initialization, and native integration with Visual Studio Test Explorer.

This tutorial walks you step-by-step through building a robust test project for a real calculator service, covering advanced assertions, Moq mocking, async tests, and CI setup. Imagine testing a financial API: a mishandled divide-by-zero could be costly—NUnit protects you with 100% coverage.

By the end, your tests will run via dotnet test, integrate with GitHub Actions, and scale for teams. Ready to boost your code reliability?

Prerequisites

  • .NET 8 SDK (or later; check with dotnet --version)
  • Visual Studio 2022 or VS Code with C# Dev Kit extension
  • Basic C# knowledge (classes, interfaces, async/await)
  • Git installed for project management
  • Command-line tools (PowerShell or Bash)

Create the Solution and Projects

terminal
dotnet new sln -n CalculatorApp

dotnet new classlib -n CalculatorApp.Core
 dotnet sln CalculatorApp.sln add CalculatorApp.Core/CalculatorApp.Core.csproj

dotnet new nunit -n CalculatorApp.Tests
 dotnet sln CalculatorApp.sln add CalculatorApp.Tests/CalculatorApp.Tests.csproj

dotnet add CalculatorApp.Core/CalculatorApp.Core.csproj package MathNet.Numerics

dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj package NUnit
 dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj package NUnit3TestAdapter
 dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj package Microsoft.NET.Test.Sdk
 dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj reference CalculatorApp.Core

dotnet build

This script sets up a solution with a Core class library project (for business logic) and a Tests project (official NUnit template). It adds MathNet for advanced math, references Core in Tests, and builds to verify. Skip manual templates: the nunit template includes essential packages, minimizing NuGet errors.

Implement the Service to Test

CalculatorApp.Core/IMathService.cs
using System;
using System.Threading.Tasks;

namespace CalculatorApp.Core;

public interface IMathService
{
    double Add(double a, double b);
    double Multiply(double a, double b);
    double Divide(double a, double b);
    Task<double> AsyncCompute(double a, double b);
}

public class MathService : IMathService
{
    public double Add(double a, double b) => a + b;

    public double Multiply(double a, double b) => a * b;

    public double Divide(double a, double b)
    {
        if (b == 0)
            throw new ArgumentException("Division par zéro interdite", nameof(b));
        return a / b;
    }

    public async Task<double> AsyncCompute(double a, double b)
    {
        await Task.Delay(100); // Simulation async
        return a + b;
    }
}

This IMathService/MathService exposes synchronous methods (Add, Multiply, Divide with exception) and an async one (AsyncCompute with delay for await testing). The interface enables mocking. Pitfall: Always throw named exceptions for precise asserts; no interface means no mocking.

Basic Test Structure

With the service ready, NUnit tests use [TestFixture] for the class, [Test] for each method, and [SetUp] to initialize before every test—like a fresh constructor per run, preventing state leaks. Assertions like Assert.AreEqual or Assert.Throws are chainable and descriptive. Run with dotnet test: the NUnit runner shows failures with precise stack traces.

Basic Tests and Assertions

CalculatorApp.Tests/MathServiceTests.cs
using NUnit.Framework;
using CalculatorApp.Core;

namespace CalculatorApp.Tests;

[TestFixture]
public class MathServiceTests
{
    private IMathService _service;

    [SetUp]
    public void SetUp()
    {
        _service = new MathService();
    }

    [Test]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Act
        var result = _service.Add(2, 3);

        // Assert
        Assert.AreEqual(5, result);
        Assert.That(result, Is.EqualTo(5));
    }

    [Test]
    public void Multiply_NegativeAndPositive_ReturnsNegative()
    {
        var result = _service.Multiply(-4, 2);
        Assert.AreEqual(-8, result, "Produit doit être négatif");
    }

    [Test]
    public void Divide_ByZero_ThrowsArgumentException()
    {
        Assert.Throws<ArgumentException>(() => _service.Divide(10, 0));
    }

These tests cover happy paths (Add), edge cases (negative Multiply), and exceptions (Divide). [SetUp] reinstantiates the service for automatic resets. Use Assert.That for modern fluent API; custom messages in AreEqual aid debugging. Copy-paste for 100% basic coverage.

Parameterized Tests with TestCase

To avoid duplication, [TestCase] injects multiple data sets: perfect for validating combinations like positives/negatives/zeros. It's more performant than loops in test code since NUnit parallelizes them. Analogy: Like table-driven testing in Go, but with declarative attribute syntax.

Tests with Parameterized Data

CalculatorApp.Tests/MathServiceTests.cs
using NUnit.Framework;
using CalculatorApp.Core;

namespace CalculatorApp.Tests;

[TestFixture]
public class MathServiceTests
{
    private IMathService _service;

    [SetUp]
    public void SetUp()
    {
        _service = new MathService();
    }

    [TestCase(2, 3, 5)]
    [TestCase(-1, 1, 0)]
    [TestCase(0, 0, 0)]
    public void Add_VariousInputs_ReturnsCorrectSum(double a, double b, double expected)
    {
        var result = _service.Add(a, b);
        Assert.AreEqual(expected, result, 0.001); // Tolérance pour floats
    }

    [TestCase(10, 2, 5)]
    [TestCase(10, -2, -5)]
    public void Divide_ValidInputs_ReturnsQuotient(double a, double b, double expected)
    {
        var result = _service.Divide(a, b);
        Assert.AreEqual(expected, result, 0.001);
    }

    [Test]
    public void Divide_ByZero_ThrowsArgumentException()
    {
        Assert.Throws<ArgumentException>(() => _service.Divide(10, 0));
    }

Extends the previous file: [TestCase] tests 3 Add scenarios in one test, with 0.001 tolerance for imprecise doubles. Add as many cases as needed; NUnit runs them independently. Pitfall: Forget tolerance on floats → false failures.

Add Moq for Mocking

terminal
dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj package Moq
 dotnet add CalculatorApp.Core/CalculatorApp.Core.csproj package Moq

dotnet build

Moq mocks interfaces like IMathService to isolate units. Added to both projects for potential shared mocks. Build validates dependencies; without it, mock tests fail at compile time.

Mocking and Dependencies

At intermediate level, isolate tests with Moq: mock IMathService to test consumers without real implementations. Setup defines behaviors, Verify asserts interactions. Ideal for TDD on coupled services.

Tests with Mocks and Async

CalculatorApp.Tests/MathServiceTests.cs
using NUnit.Framework;
using Moq;
using CalculatorApp.Core;
using System;
using System.Threading.Tasks;

namespace CalculatorApp.Tests;

[TestFixture]
public class MathServiceTests
{
    private IMathService _service;
    private Mock<IMathService> _mockService;

    [SetUp]
    public void SetUp()
    {
        _service = new MathService();
        _mockService = new Mock<IMathService>();
    }

    [Test]
    public async Task AsyncCompute_CalledOnce_ReturnsSum()
    {
        // Act
        var result = await _service.AsyncCompute(2, 3);

        // Assert
        Assert.AreEqual(5, result);
    }

    [Test]
    public void Consumer_UsesService_AddCalled()
    {
        // Arrange
        _mockService.Setup(s => s.Add(It.IsAny<double>(), It.IsAny<double>())).Returns(42);
        var consumer = new TestConsumer(_mockService.Object);

        // Act
        consumer.UseAdd();

        // Assert
        _mockService.Verify(s => s.Add(1, 2), Times.Once);
    }
}

public class TestConsumer
{
    private readonly IMathService _service;
    public TestConsumer(IMathService service) => _service = service;
    public void UseAdd() => _service.Add(1, 2);
}

Adds async test with await and mock for TestConsumer (dependency injected). It.IsAny matches any args, Times.Once verifies exact calls. Copy-paste ready; isolates units, speeds tests x10 vs E2E.

Best Practices

  • One assert per test: Avoids obscure error cascades; each test = one clear intent.
  • Descriptive naming: Add_TwoPositives_ReturnsSum > Test1, for quick CI debugging.
  • Use [OneTimeSetUp] for heavy setup (DB mocks), [SetUp] for per-test.
  • Coverage >80%: Target with dotnet test --collect:"XPlat Code Coverage", integrate with SonarQube.
  • Parallelize: Add in .csproj for x4 CI speed.

Common Errors to Avoid

  • Forget [SetUp]: Shared state → flaky tests; always reset.
  • No tolerance on double/float asserts: AreEqual(0.1 + 0.2, 0.3) fails (0.300000004 vs 0.3).
  • Unverified mocks: Misses interactions; always Verify() post-act.
  • Async tests without await: Silent exceptions; use Assert.ThrowsAsync.

Next Steps

  • Official docs: NUnit GitHub
  • Advanced Moq: Moq Quickstart
  • CI/CD: Integrate with GitHub Actions using dotnet test --logger "trx"
  • Expert training: Check our advanced .NET courses at Learni for TDD and clean architecture.
  • Tools: Coverlet for coverage, FluentAssertions for more readable asserts.