The Basics of .NET Unit Testing

Matheus Xavier
.Net Programming
Published in
6 min readFeb 18, 2023

Unit tests seek to test code units, they are the simplest layer of tests, but many developers are still in doubt about what to test in a unit test, so in this post I will try to help answer some of these questions with practical examples, addressing the following points:

  • What to test
  • Test naming pattern
  • Structure of a unit tests
  • Unit test assertions using FluentAssertations
  • Writing some tests in a practical way

What to test

Usually the first doubt about writing tests is: Given a specific method, what scenarios should I test? So let’s say that we have a Product class that has the following properties:

  • Id (the unique identifier of this product)
  • Name (the name of this product)
  • Value (the value of this product)
  • Active (a flag that indicates if this product is enabled)

This class has an Updated method that is responsible for changing the current product name and value.

public class Product
{
public Guid Id { get; }

public string Name { get; private set; }

public double Value { get; private set; }

public bool Active { get; private set; }

public Product(Guid id, string name, double value, bool active)
{
Id = id;
Name = name;
Value = value;
Active = active;
}

public void Update(string name, double value)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
}

if (name.Length < 4)
{
throw new ArgumentException($"'{nameof(name)}' must be more than 3 characters.", nameof(name));
}

if (value <= 0)
{
throw new ArgumentException($"'{nameof(value)}' cannot be less or equal to zero", nameof(value));
}

Name = name;
Value = value;
}
}

The Update method has the following behaviors:

  • Checks if the product name is valid. A valid product name must not be empty or null and must be at least 4 characters.
  • Checks if the value of the product is valid. A product must be worth more than zero to be valid.
  • Update product name and value

So basically we have these scenarios to test:

By writing unit tests that validates these scenarios we ensure that we test all possible flows of our Update method.

Test naming pattern

An important step in writing a test is its name. Test naming will be extremely important for us to understand the scenarios being tested and even what behaviors to expect from a given method, in addition to the fact that a method with a good name will help us with future maintenance.

I recommend following Microsoft’s unit test naming convention described here, where our test name should consist of three parts:

  1. The name of the method being tested.
  2. The scenario under which it’s being tested.
  3. The expected behavior when the scenario is invoked.

Given this pattern our test names for the scenarios described above will be:

[Fact]
public void Update_WithNullName_ThrowArgumentException() {}

[Fact]
public void Update_WithEmptyName_ThrowArgumentException() {}

[Fact]
public void Update_NameWithInvalidCharacters_ThrowArgumentException() {}

[Fact]
public void Update_WithInvalidValue_ThrowArgumentException() {}

[Fact]
public void Update_WithValidValues_UpdateProductDataCorrectly() {}

Pay attention that just by reading the name of the tests it is extremely easy to know what they are testing and what to expect from these tests, if the test fails it will be easy to see which scenarios do not meet our expectations and correct them. The unit tests are not just to ensure that our code is working, they also provide documentation, just by looking at the tests that have been written we will be able to know the behavior of the method without even looking at the code.

Structure of unit tests

I like to use a pattern for writing unit tests which is quite common:

  • Arrange: Create and configure our objects.
  • Act: Calls the method being tested.
  • Assert: Validates whether everything went as expected.

Separating our tests into this structure will make it easy to read how he is testing a scenario which is one of the most important points when it comes to writing unit tests.

Our three test scenarios will be very similar, basically:

  • Arrange: We will create a new instance of the Product class
  • Act: We will call the Update method of the product
  • Assert: We will ensure that expectations have been met

Unit test assertions using FluentAssertions

In the Assert section I like to use the FluentAssertions nuget package that allows us to naturally specify the expected outcome of our unit tests. To demonstrate that I will give you a sample, let’s say that we going to update a product with a null name, the Assertation using Xunit Assert would be:

// Act
Action action = () => product.Update(name: null, value: 2);

// Assert
Assert.Throws<ArgumentException>(action);

With FluentAssertions we can validate the exception type including its message in a simple and more fluent way:

// Act
Action action = () => product.Update(name: null, value: 2);

// Assert
action.Should().Throw<ArgumentException>()
.WithMessage("'name' cannot be null or empty. (Parameter 'name')");

Writing some tests in a practical way

Finally we are going to write our tests. I like to start writing the test validating the first scenario until we reach the last one, which is usually when things go right. The Update method starts validating that name is not null nor empty, so let’s start writing these tests. It is interesting to note that are two scenarios: The first when name is empty and the second when it is null, we can test it in two ways, the first would be writing two tests and the second would be a test that receives these two values through [InlineData], I will follow the first approach, writing a specific test for each.

[Fact]
public void Update_WithNullName_ThrowArgumentException()
{
// Arrange
API.Domain.Entities.Product product = new(
id: Guid.NewGuid(),
name: "Soap",
value: 1.5,
active: true
);

// Act
Action action = () => product.Update(name: null, value: 2);

// Assert
action.Should().Throw<ArgumentException>()
.WithMessage("'name' cannot be null or empty. (Parameter 'name')");
}

And then validating an empty name scenario.

[Fact]
public void Update_WithEmptyName_ThrowArgumentException()
{
// Arrange
API.Domain.Entities.Product product = new(
id: Guid.NewGuid(),
name: "Soap",
value: 1.5,
active: true
);

// Act
Action action = () => product.Update(name: string.Empty, value: 2);

// Assert
action.Should().Throw<ArgumentException>()
.WithMessage("'name' cannot be null or empty. (Parameter 'name')");
}

After the Updated method validates that the name is not empty or null it will check if the name has more than 3 characters, this is the next scenario we are going to test. In this case a name with one, two or three characters is invalid, so let’s use [InlineData] to test different entries for the same scenario.

[Theory]
[InlineData("a")]
[InlineData("aa")]
[InlineData("aaa")]
public void Update_NameWithInvalidCharacters_ThrowArgumentException(string newName)
{
// Arrange
API.Domain.Entities.Product product = new(
id: Guid.NewGuid(),
name: "Soap",
value: 1.5,
active: true
);

// Act
Action action = () => product.Update(name: newName, value: 2);

// Assert
action.Should().Throw<ArgumentException>()
.WithMessage("'name' must be more than 3 characters. (Parameter 'name')");
}

If the name is neither null nor empty and has more than 3 characters, we consider that the name is valid and the method will check the product value parameter, a valid value is a value greater than zero, the test will be:

[Theory]
[InlineData(-1)]
[InlineData(0)]
public void Update_WithInvalidValue_ThrowArgumentException(double value)
{
// Arrange
API.Domain.Entities.Product product = new(
id: Guid.NewGuid(),
name: "Soap",
value: 1.5,
active: true
);

// Act
Action action = () => product.Update(name: "Soap", value);

// Assert
action.Should().Throw<ArgumentException>()
.WithMessage("'value' cannot be less or equal to zero (Parameter 'value')");
}

And the last test, as I said above, is a test where everything works out, in this scenario we will use a valid name and value for this product and it must update the properties correctly, I also validate if the Id and Active fields continue with the same values, this is to guarantee that the Update is not changing these values.

[Theory]
[InlineData("Kit Kat", 2.0)]
[InlineData("M&M's", 0.0001)]
[InlineData("Snickers", 10)]
public void Update_WithValidValues_UpdateProductDataCorrectly(string name, double value)
{
// Arrange
API.Domain.Entities.Product product = new(
id: Guid.NewGuid(),
name: "Soap",
value: 1.5,
active: true
);

// Act
product.Update(name, value);

// Assert
product.Id.Should().Be(product.Id);
product.Name.Should().Be(name);
product.Value.Should().Be(value);
product.Active.Should().BeTrue();
}

Conclusion

Unit tests are cheap, they run extremely fast and are generally easy to write, yet many developers still end up having some doubts about which scenarios they should test, how to do it, or end up naming their tests in a way that is not so cool, I hope I’ve contributed by showing a little bit of how I usually write my tests.

Thank you for reading and feel free to contact me if you have any questions.

--

--