NuGet Packages and Unit Testing

Two habits separate hobby code from professional code: reusing well-tested libraries instead of reinventing them, and writing automated tests for your own code. In .NET, libraries come as NuGet packages, and tests are ordinary C# methods run by a test framework such as xUnit.

NuGet packages

NuGet is .NET's package manager, and nuget.org hosts hundreds of thousands of free packages: JSON tools, database drivers, logging, testing, PDF generation and much more. Add one to a project with dotnet add package:

$
dotnet add package Humanizer

This downloads the package and records it in the project file:

XML
<ItemGroup>
  <PackageReference Include="Humanizer" Version="2.14.1" />
</ItemGroup>

The next build restores (downloads) any missing packages automatically, so you commit the project file — not the packages — to version control.

Packages in single-file apps

A single-file app references packages with a #:package directive at the top, giving the package name and version:

C#
#:package Humanizer@2.14.1
using Humanizer;

Console.WriteLine("customer_order_id".Humanize());
Console.WriteLine(TimeSpan.FromMinutes(135).Humanize(2));
Console.WriteLine(3.ToWords());
Console.WriteLine("person".Pluralize());
Console.WriteLine(TimeSpan.FromDays(16).Humanize());
Output
customer order id
2 hours, 15 minutes
three
people
2 weeks

Choosing packages wisely

Every package you add is code you're trusting. Before adding one, check on nuget.org that it's actively maintained, widely downloaded, from a known author, and has a licence that suits your project. Keep packages up to date — dotnet list package --outdated shows what's behind, and dotnet list package --vulnerable reports known security issues.

Why automated tests?

A unit test is a small method that calls your code with known inputs and checks the result. Run them after every change and you'll know within seconds whether you broke something. Tests also document how code is meant to be used, and they make refactoring safe.

Setting up a test project

Tests live in their own project that references the code under test. With the code in a class library called Pricing:

$
dotnet new classlib -o Pricing
dotnet new xunit -o Pricing.Tests
dotnet add Pricing.Tests reference Pricing

Here is the library code we'll test:

CSPROJ Pricing/Pricing.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
C# Pricing/PriceCalculator.cs
namespace Pricing;

public static class PriceCalculator
{
    public static decimal ApplyDiscount(decimal price, int percent)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(price);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(percent, 100);
        return Math.Round(price * (100 - percent) / 100, 2);
    }

    public static decimal Shipping(decimal orderTotal) => orderTotal switch
    {
        >= 50m => 0m,
        >= 20m => 3.99m,
        _ => 6.99m,
    };
}

The test project file that dotnet new xunit creates references the test framework packages, plus our reference to the library:

CSPROJ Pricing.Tests/Pricing.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Pricing\Pricing.csproj" />
  </ItemGroup>

</Project>

Writing tests

In xUnit, a test is a public method marked [Fact]. A [Theory] runs the same test with several sets of data supplied by [InlineData]. Assert methods check the results — if one fails, the test fails:

C# Pricing.Tests/PriceCalculatorTests.cs
using Pricing;

namespace Pricing.Tests;

public class PriceCalculatorTests
{
    [Fact]
    public void ApplyDiscount_TakesPercentageOff()
    {
        decimal result = PriceCalculator.ApplyDiscount(80m, 25);
        Assert.Equal(60m, result);
    }

    [Fact]
    public void ApplyDiscount_RejectsPercentOver100()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => PriceCalculator.ApplyDiscount(10m, 150));
    }

    [Theory]
    [InlineData(10, 6.99)]
    [InlineData(20, 3.99)]
    [InlineData(49.99, 3.99)]
    [InlineData(50, 0)]
    public void Shipping_DependsOnOrderTotal(decimal total, decimal expected)
    {
        Assert.Equal(expected, PriceCalculator.Shipping(total));
    }
}
$
dotnet test
Output
Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6

The four InlineData rows count as four separate tests, so six tests ran in total. Test names that describe the behaviour (Method_Scenario_ExpectedResult) make failures self-explanatory.

When a test fails

Suppose a change accidentally makes orders of exactly 50 pay shipping. The test run fails and reports which test, what was expected and what actually happened:

TXT
Failed Pricing.Tests.PriceCalculatorTests.Shipping_DependsOnOrderTotal(total: 50, expected: 0) [1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 0
Actual:   3.99

That's the value of tests: the bug is caught seconds after it's written, with a precise description, instead of by a customer.

The Arrange–Act–Assert pattern

Most tests follow the same three steps — set up the inputs, call the code, check the result:

C#
[Fact]
public void Withdraw_ReducesBalance()
{
    // Arrange
    var account = new BankAccount("Ana");
    account.Deposit(100m);

    // Act
    account.Withdraw(30m);

    // Assert
    Assert.Equal(70m, account.Balance);
}

Useful assertions

AssertionChecks that…
Assert.Equal(expected, actual)two values are equal
Assert.True(condition) / Assert.Falsea condition holds
Assert.Null(x) / Assert.NotNull(x)a value is (not) null
Assert.Contains(item, collection)a collection contains an item
Assert.Empty(collection)a collection is empty
Assert.Throws<T>(() => …)code throws a specific exception
await Assert.ThrowsAsync<T>(…)async code throws

Other test frameworks

xUnit is the most common choice for new .NET projects, but NUnit (dotnet new nunit) and Microsoft's MSTest (dotnet new mstest) are also widely used and work the same way in principle — only the attribute names differ ([Test], [TestMethod]). Editors such as VS Code with C# Dev Kit, Visual Studio and Rider show tests in a test explorer where you can run and debug them individually.

What makes a good unit test

  • Fast — milliseconds, so you can run hundreds after every change.
  • Independent — no test relies on another having run first.
  • Repeatable — no dependence on the clock, the network or random numbers. Pass those in (for example a TimeProvider, lesson 56) so tests can control them.
  • Focused — one behaviour per test, with a name that says which.

Try it yourself

Write the code under test first: a Password.IsStrong method. Then, in a test project, add a [Theory] with strong and weak examples.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
abc            False
password123    False
Tr0ub4dor&3    True
CorrectHorse9  True