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
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 buildThis 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
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
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
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
dotnet add CalculatorApp.Tests/CalculatorApp.Tests.csproj package Moq
dotnet add CalculatorApp.Core/CalculatorApp.Core.csproj package Moq
dotnet buildMoq 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
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.