π 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).
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:
| Assertion | Passes 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:
- Set up a class library and an xUnit test project (see the setup commands above).
- In the library, write a
StringUtilsclass withstring Reverse(string input)that returns the reversed string, and throwsArgumentNullExceptionifinputis null. - Write a
[Fact]that reverses"hello"to"olleh". - Write a
[Theory]with several inputs (including an empty string and a single character). - Write a
[Fact]asserting thatReverse(null)throwsArgumentNullException. - Run
dotnet testand 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
- Testing in .NET β Microsoft Docs
- Unit testing C# with xUnit β tutorial
- xUnit.net β official site
π 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!