Overview
Unit testing is a vital part of modern software development, ensuring that code works as expected. It offers a robust and flexible way to implement unit tests in the .NET ecosystem. C# 13 and .NET 9 introduce new language features and platform improvements that make testing more efficient and maintainable.
This article aims to provide a detailed guide to leveraging NUnit for unit testing in C# 13 and .NET 9. Industry standards and best practices are followed throughout.
Key Features of C# 13 and .NET 9 Relevant to Testing
A number of features in C# 13 and .NET 9 are designed to make writing tests easier, including:
- Lambda enhancements: Logic for assertions is simplified in Lambda enhancements.
- Raw string literals: Improvements in readability for complex input data using raw string literals.
- Pattern matching improvements: Enhance pattern matching by enabling concise assertions in test cases.
- Performance improvements in .NET 9: .NET 9 provides faster test execution performance.
Best Practices for NUnit Testing
- Following the Arrange-Act-Assert (AAA) Pattern Structure tests clearly into three phases:
- Arrange: The data and dependencies for the test should be set up.
- Act: Testing is to perform the operation.
- Assert: That the results have been verified.
- Write Descriptive Test Names: Using a naming convention that reflects the test scenario, e.g., MethodName_StateUnderTest_ExpectedBehavior.
- Test One Thing at a Time: To make failures easier to diagnose. Each test case should focus on one responsibility.
- Use Test Fixtures for Reusability: Use NUnit's [SetUp] and [TearDown] attributes to share setup code across tests or [OneTimeSetUp] for one-time setup.
- Mock Dependencies: Isolate the unit under test from external dependencies by using mocking frameworks like Moq.
- Parameterize Tests: By using [TestCase] and [TestCaseSource] to cover multiple scenarios.
- Run Tests in Isolation: To ensure reliability and reusable code, avoid dependencies between tests.
- Leverage Custom Assertions: To make assertions reusable and meaningful by extending NUnit's assertion framework.
- Continuous Integration and Code Coverage: Integrate your tests into CI/CD pipelines to ensure adequate code coverage.
Setting Up NUnit in .NET 9
You will need to install NUnit and NUnit3TestAdapter
Make sure your test project contains the following packages:
dotnet add package NUnit
dotnet add package NUnit3TestAdapter
Add Mocking Library (Optional)
To mock dependencies:
dotnet add package Moq
Create the Test Project
With the .NET command line interface:
dotnet new nunit -n CalculatorApp.Tests
C# 13 Features and NUnit Test Suite Example
Here is a practical example demonstrating how to use the new features in .NET 9 and C# 13 with NUnit tests in order to develop software.
Code Under Test
namespace CalculatorApp
{
public class Calculator
{
public int Add(int a, int b) => a + b;
public int Divide(int dividend, int divisor)
{
if (divisor == 0) throw new DivideByZeroException();
return dividend / divisor;
}
}
}
namespace CalculatorApp.Tests;
[TestFixture]
public class CalculatorTests
{
private Calculator _calculator;
[SetUp]
public void Setup()
{
_calculator = new Calculator();
}
[Test]
public void Add_ValidInputs_ReturnsSum()
{
// Arrange
int a = 5;
int b = 3;
// Act
int result = _calculator.Add(a, b);
// Assert
Assert.That(result, Is.EqualTo(8), "It is expected that the Add method will return the sum of two integers.");
}
[Test]
public void Divide_ValidInputs_ReturnsQuotient()
{
// Arrange
int dividend = 10;
int divisor = 2;
// Act
int result = _calculator.Divide(dividend, divisor);
// Assert
Assert.That(result, Is.EqualTo(5), "It is expected that the Divide method returns the quotient of the division.");
}
[Test]
public void Divide_DivisorIsZero_ThrowsDivideByZeroException()
{
// Arrange
int dividend = 10;
int divisor = 0;
// Act & Assert
Assert.Throws<DivideByZeroException>(() => _calculator.Divide(dividend, divisor));
}
[TestCase(1, 2, 3)]
[TestCase(-1, -2, -3)]
[TestCase(0, 0, 0)]
public void Add_MultipleInputs_ReturnsExpectedResults(int a, int b, int expected)
{
// Act
int result = _calculator.Add(a, b);
// Assert
Assert.That(result, Is.EqualTo(expected), $"The add method failed for inputs {a} and {b}.");
}
}
Advanced Features
C# 13 introduces several powerful features that can simplify the testing process and make test cases more expressive and maintainable. We explore two advanced features in this section: using raw string literals as test data and creating custom assertions for domain-specific validation logic that can be reused over and over.
Using Raw String Literals for Test Data
The raw string literals introduced in recent C# versions are a game-changer for handling multi-line or structured data in test cases. In your code, you can include JSON, XML, and other data formats without worrying about escape characters.
Why Use Raw String Literals in Testing?
- Improved readability: Maintain the structure of complex strings like JSON or SQL queries for improved readability.
- Ease of debugging: Avoid escape sequences that obscure the actual content for ease of debugging.
- Direct integration: Tests can be directly integrated with real-world examples of structured data.
Code User Test JSON Processor
In the CalculatorApp namespace, the JsonProcessor class converts JSON strings into strongly-typed objects. A ProcessedData object defined in the CalculatorApp.Models namespace is generated by deserializing JSON input using System.Text.Json. In applications requiring structured processing, this functionality ensures robust and type-safe handling of JSON data. It throws an ArgumentException if the input JSON is null or empty.
JsonProcessor.cs
using CalculatorApp.Models;
using System.Text.Json;
namespace CalculatorApp;
public class JsonProcessor
{
public static ProcessedData Process(string json)
{
if (string.IsNullOrEmpty(json))
throw new ArgumentException("Input JSON cannot be null or empty.");
return JsonSerializer.Deserialize<ProcessedData>(json);
}
}
ProcessedData.cs
namespace CalculatorApp.Models;
public class ProcessedData
{
public string Name { get; set; }=string.Empty;
public int Value { get; set; }
}
Example: Testing JSON Input
The following example shows how raw string literals can be used to test a method that processes JSON.
using System.Text.Json;
namespace CalculatorApp.Tests;
[TestFixture]
public class JsonProcessorTests
{
[Test]
public void ProcessJson_ValidInput_ReturnsProcessedData()
{
// Arrange
var inputJson = """
{
"Name": "test",
"Value": 42
}
"""; // Raw string literal for structured JSON data
// Act
var result = JsonProcessor.Process(inputJson);
// Assert
Assert.Multiple(() =>
{
Assert.That(result, Is.Not.Null, "A null result is not acceptable for a valid JSON input.");
Assert.That(result.Name, Is.EqualTo("test"), "There should be a match between the name field in the output and the input JSON..");
Assert.That(result.Value, Is.EqualTo(42), "There needs to be a match between the 'value' field and the input JSON.");
});
}
[Test]
public void ProcessJson_InvalidInput_ThrowsException()
{
// Arrange
var invalidJson = "{ invalid json }";
// Act & Assert
Assert.Throws<JsonException>(() => JsonProcessor.Process(invalidJson), "JSONException should be thrown when invalid JSON input is received");
}
[Test]
public void ProcessJson_EmptyInput_ThrowsArgumentException()
{
// Arrange
var emptyJson = "";
// Act & Assert
Assert.Throws<ArgumentException>(() => JsonProcessor.Process(emptyJson), "If the JSON input is empty, it should throw an ArgumentException.");
}
}
With raw string literals, the JSON structure remains intact and highly readable, preserving its natural form. Using assertions effectively validates the parsing logic, ensuring that the test reflects the behavior of the code under test accurately. Moreover, structured data makes it much easier to work with, since test cases are easier to write, understand, and maintain, resulting in fewer updates or debugging efforts in the future.
Custom Assertions
With custom assertions, you can create reusable validation methods tailored to your domain. Custom assertions are particularly useful when the same assertion is repeated across a number of test cases.
Why Use Custom Assertions?
- Reusability: It is important to avoid duplicating validation logic across tests in order to maximize reusability.
- Expressiveness: Achieve greater expressivity in tests by aligning them with business logic and making them more readable.
- Maintainability: Make it easier to update validation logic by centralized it.
Asserting positive values as an example
In order to validate that a numeric value is positive, let's create a custom assertion.
namespace CalculatorApp.Tests;
public static class CustomAssertions
{
public static void AssertIsPositive(int value)
{
Assert.That(value, Is.GreaterThan(0), "Value should be positive.");
}
}
Using Custom Assertions in Tests
Using custom assertions simplifies the process of writing tests, as shown in this example:
[Test]
public void Value_IsPositive()
{
// Arrange
int value = 5;
// Act & Assert
CustomAssertions.AssertIsPositive(value);
}
The purpose of custom assertions is to emphasize the intention of a test rather than the technical implementation, allowing it to read more like natural language. By centralizing assertion logic, they eliminate redundancy and simplify updates when requirements change, enhancing maintainability. In addition, custom assertions provide context-specific error messages that make it easier to diagnose test failures and understand them.
Combining These Features
This scenario illustrates how raw string literals and custom assertions enhance the clarity and maintainability of your tests.
[Test]
public void ProcessAndValidateJson_ValidInput_ReturnsPositiveValue()
{
// Arrange
var inputJson = """
{
"Name": "test",
"Value": 42
}
""";
// Act
var result = JsonProcessor.Process(inputJson);
// Assert
Assert.That(result, Is.Not.Null, "Result should not be null.");
CustomAssertions.AssertIsPositive(result. Value);
}
With advanced features like raw string literals and custom assertions, developers are able to write cleaner, more expressive tests. They reduce boilerplate code, improve readability, and make maintaining test suites easier as applications change. With NUnit testing, you can achieve higher levels of quality and clarity if you incorporate these techniques.
Integrating Tests into CI/CD
- Add a Test Runner to Your Pipeline
For Azure DevOps:
- task: DotNetCoreCLI@2
inputs:
command: 'test'
projects: '**/*CalculatorApp.Tests.csproj'
Generate Code Coverage
Use a coverage tool like Coverlet:
dotnet test /p:CollectCoverage=true
Analyze and Report
Integrate coverage results into tools like SonarQube for code quality analysis.
Summary
The latest advancements in C# 13 and .NET 9 make NUnit a more productive and expressive way to write unit tests. By following industry standards and leveraging modern language features, developers can write tests that are robust, maintainable, and high-performance.
The time you invest in setting up a strong test foundation will pay dividends in the form of reliable software and faster iteration cycles.
I hope you found my article helpful. Please click the like button and connect with me on https://www.linkedin.com/in/ziggyrafiq/. The article's code examples are available in my GitHub Repository.