Skip to main content

πŸ“ Lesson 5.2: Unit Testing

How do you know your code actually works β€” and keeps working as you change it? You write tests: small programs that automatically check your code and shout if anything breaks. This is how professionals build with confidence.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what unit testing is and why it's valuable
  • Create a test project and run tests with dotnet test
  • Write tests with xUnit's [Fact] and [Theory]
  • Structure tests with the Arrange-Act-Assert pattern
  • Assert results, including that code throws the right exception

Estimated Time: 60 minutes

Project: Write a suite of unit tests for a small class.

In This Lesson

What Is Unit Testing?

A unit test is a small, automated check that verifies one piece of your code β€” usually a single method β€” does what it should. You run your whole suite of tests in seconds, and each one either passes or fails.

πŸ“– Definition

Unit test: code that calls a "unit" (typically a method) with known inputs and asserts that the output matches what you expect. Many tests together form a test suite.

Why bother, when you could just run the program and eyeball it?

  • Confidence to change code. After editing, run the tests; if they still pass, you probably didn't break anything.
  • Catch regressions. A bug you fix once, you write a test for β€” so it can never silently return.
  • Living documentation. Tests show exactly how code is meant to behave.
  • Better design. Code that's easy to test tends to be well-structured (small, focused methods).
graph LR A["Write / change code"] --> B["Run tests"] B --> C{"All pass?"} C -->|"Yes"| D["Ship with confidence"] C -->|"No"| E["Fix the code"] E --> B style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Setting Up a Test Project

Tests live in their own project that references the code being tested. The most popular test framework for C# is xUnit. Here's the typical layout and commands:

# Create the code being tested (a class library)
dotnet new classlib -o MyApp

# Create an xUnit test project
dotnet new xunit -o MyApp.Tests

# Let the test project see the app project
dotnet add MyApp.Tests reference MyApp

# Run all tests
dotnet test

πŸ’‘ What dotnet test does

It builds your projects, discovers every test method, runs them, and prints a summary: how many passed, failed, or were skipped. In Visual Studio, the Test Explorer gives the same results with a clickable UI. xUnit isn't the only choice (NUnit and MSTest are similar) β€” the concepts here transfer to all of them.

Your First Test (Arrange-Act-Assert)

Say we're testing this simple class in the MyApp project:

public class Calculator
{
    public int Add(int a, int b) => a + b;
}

A test is a method marked with the [Fact] attribute. The body follows the Arrange-Act-Assert (AAA) pattern β€” set up, do the thing, check the result:

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        // Arrange β€” set up the objects and inputs
        var calc = new Calculator();

        // Act β€” call the method under test
        int result = calc.Add(3, 4);

        // Assert β€” verify the result is what we expect
        Assert.Equal(7, result);
    }
}

βœ… Name tests descriptively

A common convention is MethodName_Scenario_ExpectedResult, like Add_TwoNumbers_ReturnsSum. When a test fails, its name alone should tell you what broke β€” no need to read the body.

πŸ’‘ One behavior per test

Keep each test focused on a single behavior with (ideally) one assertion. Small, focused tests pinpoint exactly what failed, rather than leaving you guessing which of five checks broke.

Common Assertions

The Assert class provides methods to check outcomes. If an assertion is false, the test fails. The ones you'll use most:

AssertionPasses when…
Assert.Equal(expected, actual)the two values are equal
Assert.True(condition) / Assert.False(...)the condition is true / false
Assert.Null(x) / Assert.NotNull(x)the value is null / not null
Assert.Contains(item, collection)the collection contains the item
Assert.Throws<TException>(() => ...)the code throws that exception
[Fact]
public void Assertions_Examples()
{
    Assert.Equal(10, 5 + 5);
    Assert.True(4 % 2 == 0);
    Assert.NotNull("hello");

    var list = new List<int> { 1, 2, 3 };
    Assert.Contains(2, list);
}

⚠️ Argument order for Assert.Equal

The expected value comes first, the actual value second: Assert.Equal(expected, actual). Getting them backwards still works, but failure messages ("expected X but got Y") will read confusingly.

Data-Driven Tests

Often you want to test the same logic with many inputs. Instead of copying a [Fact] repeatedly, use a [Theory] with [InlineData] β€” the test runs once per data row:

public class MathTests
{
    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    [InlineData(100, 200, 300)]
    public void Add_VariousInputs_ReturnsSum(int a, int b, int expected)
    {
        var calc = new Calculator();
        int result = calc.Add(a, b);
        Assert.Equal(expected, result);
    }
}

This counts as four separate tests β€” each row is reported individually, so you can see exactly which input failed.

βœ… Test the edges

Good test data covers more than the happy path: include zero, negatives, empty collections, boundary values, and other edge cases. Bugs love the edges β€” that's where careful tests earn their keep.

Testing Exceptions

Sometimes correct behavior is throwing an exception β€” like rejecting invalid input (Lesson 1.1). Verify that with Assert.Throws<T>, which passes only if the code throws the expected type:

public class BankAccount
{
    public decimal Balance { get; private set; }

    public void Withdraw(decimal amount)
    {
        if (amount > Balance)
        {
            throw new InvalidOperationException("Insufficient funds.");
        }
        Balance -= amount;
    }
}

public class BankAccountTests
{
    [Fact]
    public void Withdraw_MoreThanBalance_Throws()
    {
        // Arrange
        var account = new BankAccount();   // balance starts at 0

        // Act & Assert β€” the withdrawal should throw
        Assert.Throws<InvalidOperationException>(() => account.Withdraw(100m));
    }
}

πŸ’‘ Test both success and failure

For any method with rules, write tests for the cases that should work and the cases that should fail. A method is only fully covered when you've verified it does the right thing and refuses to do the wrong thing.

βœ… Testable code = good code

Notice these tests are easy because the methods are small, take clear inputs, and return clear outputs. If a method is hard to test (it depends on the clock, the network, or hidden state), that's often a sign it should be refactored β€” testing pressure nudges you toward cleaner design.

Exercise & Quiz

πŸ‹οΈ Exercise: Test a StringUtils Class

Objective: Write a small class and a suite of tests covering normal cases, edge cases, and an exception.

Instructions:

  1. Set up a class library and an xUnit test project (see the setup commands above).
  2. In the library, write a StringUtils class with string Reverse(string input) that returns the reversed string, and throws ArgumentNullException if input is null.
  3. Write a [Fact] that reverses "hello" to "olleh".
  4. Write a [Theory] with several inputs (including an empty string and a single character).
  5. Write a [Fact] asserting that Reverse(null) throws ArgumentNullException.
  6. Run dotnet test and confirm all tests pass.

Starter Code (the class):

public class StringUtils
{
    public string Reverse(string input)
    {
        if (input is null)
        {
            throw new ArgumentNullException(nameof(input));
        }
        char[] chars = input.ToCharArray();
        Array.Reverse(chars);
        return new string(chars);
    }
}
πŸ’‘ Hint

Reverse test: Assert.Equal("olleh", new StringUtils().Reverse("hello"));. For the theory, rows like [InlineData("", "")] and [InlineData("a", "a")]. For the exception: Assert.Throws<ArgumentNullException>(() => new StringUtils().Reverse(null!));.

βœ… Solution (the tests)
using Xunit;

public class StringUtilsTests
{
    [Fact]
    public void Reverse_Word_ReturnsReversed()
    {
        var utils = new StringUtils();
        Assert.Equal("olleh", utils.Reverse("hello"));
    }

    [Theory]
    [InlineData("", "")]
    [InlineData("a", "a")]
    [InlineData("ab", "ba")]
    [InlineData("racecar", "racecar")]
    public void Reverse_VariousInputs_Works(string input, string expected)
    {
        var utils = new StringUtils();
        Assert.Equal(expected, utils.Reverse(input));
    }

    [Fact]
    public void Reverse_Null_ThrowsArgumentNull()
    {
        var utils = new StringUtils();
        Assert.Throws<ArgumentNullException>(() => utils.Reverse(null!));
    }
}

Running dotnet test reports all tests (1 + 4 + 1 = 6) passing:

Passed!  - Failed: 0, Passed: 6, Skipped: 0

🎯 Quick Quiz

Question 1: What do the three A's in Arrange-Act-Assert stand for?

Question 2: When would you use [Theory] with [InlineData] instead of [Fact]?

Question 3: How do you verify that a method throws an exception?

Summary

πŸŽ‰ Key Takeaways

  • Unit tests are automated checks that verify a piece of code; they give you confidence to change code and catch regressions.
  • Tests live in their own project (e.g. xUnit); run them with dotnet test.
  • Mark tests with [Fact] and structure them Arrange-Act-Assert; name them descriptively.
  • Assert outcomes with Assert.Equal, Assert.True, Assert.Throws<T>, etc. (expected value first).
  • Use [Theory] + [InlineData] for many inputs; test edge cases and failure paths, not just the happy path.

πŸ“š Additional Resources

πŸš€ What's Next?

You now have every tool for professional C#. In the final lesson, Lesson 5.3: Capstone Project, you'll combine exceptions, generics, LINQ, records, files, JSON, async, web APIs, and tests into one complete application β€” the grand finale of the course.

πŸŽ‰ You test like a pro!

Automated tests are what separate hobby code from professional code. One lesson to go β€” the capstone!