Unit Testing - Part 1: Getting started with unit tests in .NET

Unit Testing - Part 1: Getting started with unit tests in .NET

I feel like unit testing is very important and very beneficial in a lot of ways. It helps with not breaking existing functionality and thereby also making you more confident in changing code without fear of breaking important stuff. Often it also makes the code less coupled because highly coupled code can be difficult to test.

But most people I know really don't write a lot of unit tests or are not very good at it - which might be the reason they don't do it to begin with.

So I've been wanting to do a series of blog post about the whole process of writing unit tests in .NET (C#), how to get going and how to make it easier and faster to write good, maintainable unit tests.

Choosing a test framework

Before getting started you will need to choose a test framework. The two most popular ones are xUnitand NUnit.

I personally much prefer xUnit and feel it's more intuitive and requires less scaffolding. xUnit is also used internally by Microsoft and for the unit tests of the .NET code base itself.

Creating a test project

For this blog series I've created a simple solution named Blog.Commerce to use as an example and build upon during the series.

Normally you would create a test project per library/web project you want to add unit tests for.

In Visual Studio you can simply add a new xUnit test project like shown below or you can create it with dotnet new xunit. There's no set rule on what to name your test project or where to place it. Personally I usually name it {OtherProjectName}.Tests, which in this case would then be Blog.Commerce.Tests, and place it next to the existing project. Some people prefer to keep it separate in different folders. The default basic project file for a test project should look close to this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- You can change this to 'netcoreapp3.1' if working with .NET Standard or .NET Core -->
    <TargetFramework>net5.0</TargetFramework>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="3.0.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

</Project>

Tip: You can change the <TargetFramework> to <TargetFrameworks> and add multiple targets to run your tests for multiple frameworks, for example <TargetFrameworks>netcoreapp3.1.;net5.0</TargetFrameworks>

There you go. That wasn't so complicated - let's get started with writing some tests.

Setting the scene

For this example I've created a simple Basket with a list of BasketItems.

using System.Collections.Generic;
using System.Linq;

namespace Blog.Commerce
{
    public class Basket
    {
        public IList<BasketItem> Items { get; set; } = new List<BasketItem>();

        public void AddItem(string itemNo, int quantity)
        {
            if (TryGetItem(itemNo, out var basketItem))
            {
                basketItem.Quantity += quantity;
            }
            else
            {
                Items.Add(new BasketItem
                {
                    ItemNo = itemNo,
                    Quantity = quantity
                });
            }
        }

        private bool TryGetItem(string itemNo, out BasketItem basketItem)
        {
            basketItem = Items.SingleOrDefault(x => x.ItemNo == itemNo);
            return basketItem != null;
        }
    }
}
namespace Blog.Commerce
{
    public class BasketItem
    {
        public string ItemNo { get; set; }

        public int Quantity { get; set; }
    }
}

I have some simple expectations (or requirements) for this basket that I would like to verify with some simple unit tests:

  1. A new basket has an non-null, empty list of items
  2. Adding a new item will add a new item to the list of items
  3. Adding an item that already exist will add to the existing quantity and not add a new item to the list

Creating a test

I want to create a test for my Basket.cs class, so I make a new class (following the same folder structure of the class I want to test) and naming it {Class}Tests.cs which in this cases becomes BasketTests.cs.

For testing my 1. expectation above I create a new method called Ctor_ItemsIsEmpty (with Ctormeaning Constructor) and add the [Fact] attribute above it (this marks it as a test that should be executed by the xUnit test runner).

In the method I then create a new basket and makes some assertions to verify the outcome I expect of the test.

using System.Collections.Generic;
using Xunit;

namespace Blog.Commerce.Tests
{
    public class BasketTests
    {
        [Fact]
        public void Ctor_ItemsIsEmpty()
        {
            var basket = new Basket();

            Assert.NotNull(basket.Items);
            Assert.Empty(basket.Items);
        }
    }
}

Above you see the finished code for our first test. We simply create a new basket and then assert that the items is neither null nor empty. When the test is run those assertions will throw an exception and make the test fail if they are not correct.

How to name, organizing and structe your tests will be expanded on in Part 2 of this series.

Below is how my solution looks at this point.

Running your tests

In Visual Studio you can run your tests by going to the Test menu and selecting Run All Tests which will then open the Test Explorer window and run your tests. Or you can run your tests with dotnet test in your solution directory (or by specifying a .sln or .csproj file directly) which should give you this output: If for example we didn't initialize the list of items to an empty list, then the test would fail and it would look something like this:

Running the same test with different input

Another common case is to reuse the same test with different input, saving you time and lines of code.

To do that we add some parameters to the test method and instead of the [Fact] attribute we use a [Theory] attribute.

The simplest way to supply input data is by using multiple [InlineData] attributes for each set of input parameters you want to run the test with. There are a lot of other ways to supply data to a test like the [ClassData] and [MemberData] attribute - you can even create your own custom implementation.

So let's add an example of a test for (part of) our 3. case mentioned earlier - adding an item that already exists should add to the quantity of the existing item.

[Theory]
[InlineData(1, 1, 2)]
[InlineData(1, 3, 4)]
public void AddItem_ExistingItem_ShouldIncreaseQuantityOfExistingItem(
    int originalQuantity, int addQuantity, int expectedQuantity)
{
    // Arrange
    var itemNo = "qwerty";

    var basket = new Basket
    {
        Items = new List<BasketItem>
        {
            new BasketItem
            {
                ItemNo = itemNo,
                Quantity = originalQuantity
            }
        }
    };

    // Act
    basket.AddItem(itemNo, addQuantity);

    // Assert
    Assert.Equal(expectedQuantity, basket.Items[0].Quantity);
}

Here we have 3 input parameters: originalQuantity, addQuantity and expectedQuantity. We have added the [InlineData] twice, each with a value for every method parameter. The test will now be executed twice and with the specified inputs respectively.

Adding a few mere tests to cover my expectations mentioned earlier and I end up with the following final test class for now.

using System.Collections.Generic;
using Xunit;

namespace Blog.Commerce.Tests
{
    public class BasketTests
    {
        [Fact]
        public void Ctor_ItemsIsEmpty()
        {
            var basket = new Basket();

            Assert.NotNull(basket.Items);
            Assert.Empty(basket.Items);
        }

        [Fact]
        public void AddItem_NewItem_AddsNewItem()
        {
            // Arrange
            var basket = new Basket();
            Assert.Equal(0, basket.Items.Count);

            // Act
            basket.AddItem("qwerty", 1);

            // Assert
            Assert.Equal(1, basket.Items.Count);
        }

        [Fact]
        public void AddItem_ExistingItem_DoesNotAddNewItem()
        {
            // Arrange
            var itemNo = "qwerty";
            var basket = new Basket
            {
                Items = new List<BasketItem>
                {
                    new BasketItem
                    {
                        ItemNo = itemNo,
                        Quantity = 1
                    }
                }
            };
            Assert.Equal(1, basket.Items.Count);

            // Act
            basket.AddItem(itemNo, 1);

            // Assert
            Assert.Equal(1, basket.Items.Count);
        }

        [Theory]
        [InlineData(1, 1, 2)]
        [InlineData(1, 3, 4)]
        public void AddItem_ExistingItem_ShouldIncreaseQuantityOfExistingItem(
            int originalQuantity, int addQuantity, int expectedQuantity)
        {
            // Arrange
            var itemNo = "qwerty";

            var basket = new Basket
            {
                Items = new List<BasketItem>
                {
                    new BasketItem
                    {
                        ItemNo = itemNo,
                        Quantity = originalQuantity
                    }
                }
            };

            // Act
            basket.AddItem(itemNo, addQuantity);

            // Assert
            Assert.Equal(expectedQuantity, basket.Items[0].Quantity);
        }
    }
}

Coming up in Part 2

In the next part I'll go a bit more in-depth with how I prefer to name and structure my tests, what to test and how to write more readable assertions with more descriptive error messages in case things blow up ;)