Tutorials / Build a REST API with C# and .NET · Lesson 12 of 15
On this page
Unit Testing Basics
You have a shop's price code. Someone asks you to change how tax is added. You make the change, press build, and it compiles. Is the change safe?
This lesson is about how to answer that question with confidence.
Why test at all
Picture this. You join a project with thousands upon thousands of lines of code and many complex features. Nobody wrote any tests. Your first job is to change one small thing.
You change it. The code compiles. But you do not know if you broke something in another part of the app, in another feature, written by someone who left years ago. Compiling only proves the code is written correctly. It does not prove the code does the right thing.
Your options are all bad:
- Click through every feature by hand after every change. That takes days.
- Cross your fingers and ship it.
- Stay scared, and avoid changing anything.
Now picture the same project with a few thousand tests. A test is a small piece of code that checks another piece of code. You make your change and run all the tests. A few seconds later, you see either "all passed" or "this one broke". You find out right away, and you know where.
When you run the tests, there are two possible results:
- All tests pass. Your change did not break anything the tests check. That gives you a good chance the change is safe. It is not a promise, because tests only check what someone thought to test, and QA still looks at the whole app. But it is far better than hoping.
- One test fails. This is the useful result. The failing test's name tells you exactly which logic or feature you broke. You go straight to that code and investigate. You do not have to guess, and you do not have to click through the whole app to find the problem.
Tests are an investment
Be honest about the start. Writing tests feels annoying. You write the code, and then you write more code to check the code. It slows you down.
Think of it as an investment. You pay a little now, and it pays you back later. When the project has hundreds or thousands of tests and they all pass, you can change things with confidence. The more code the project has, the more that confidence is worth.
Tests do not replace QA (the people who test the whole app as a user would). Both are needed. This lesson covers only the tests that developers write.
What a unit test is
A unit test checks one small piece of code, called a unit, on its own. In this tutorial a unit is usually one method of one class.
A good unit test is:
- Small. It checks one thing.
- Fast. It runs in a few milliseconds, so you can run thousands.
- On its own. It does not need a web server, a database or an internet connection.
Later lessons write unit tests for the real bookshop API. First we practise on a tiny example.
Where tests live: src/ and tests/
Put the test code in its own project, away from the real code. There are two good reasons.
- Test code is never shipped. Tests are only for the team. Your users never see them, and they are not part of the app you publish. They exist so that you can check the code before it goes out. If the tests lived inside the real project, they would travel with the app. A separate project keeps them behind.
- The folders stay easy to read. You can see at a glance what is real code and what is test code.
Think of a car factory. The crash tests happen at the factory before the car is sold. The customer gets the car, not the crash-test equipment. Unit tests are the same: they protect the product, but they are not part of it.
The first idea is to put everything side by side:
BookShop/
BookShop.sln
BookShop.Pricing/
BookShop.Pricing.Tests/
BookShop.Orders/
BookShop.Orders.Tests/
BookShop.Api/
...
It works for two projects. With twenty projects, the real code and the test code are mixed in one long list, and you cannot tell them apart at a glance.
The better habit is two folders:
BookShop/
BookShop.slnx
src/
BookShop.Pricing/
BookShop.Orders/
tests/
BookShop.Pricing.Tests/
BookShop.Orders.Tests/
src/holds the real code.tests/holds the test projects. Nothing intests/is shipped.- A test project is named after the project it tests, plus
.Tests. - The test project references the real project, as you learned in lesson 01. That is how a test can use the class it checks.
Build a small practice project
We make a small practice project to learn the steps. It is separate from the real bookshop API, which starts in a later lesson. You need the .NET SDK, and you can use a terminal or Visual Studio.
The test framework
A test framework is a package that gives you the tools to write tests and a program that finds and runs them. We use MSTest:
- It is simple, so it is a good one to start with.
- It is built into Visual Studio, so tests show up in its Test Explorer with no extra setup.
Other frameworks exist, such as xUnit and NUnit. They work in the same way. Once you understand one, you can learn another in an afternoon, so there is no need to worry about the choice now.
Create the solution and the two projects
These commands create the layout from above:
dotnet new sln -n BookShop
dotnet new classlib -n BookShop.Pricing -o src/BookShop.Pricing
dotnet new mstest -n BookShop.Pricing.Tests -o tests/BookShop.Pricing.Tests
dotnet add tests/BookShop.Pricing.Tests reference src/BookShop.Pricing
dotnet sln add src/BookShop.Pricing tests/BookShop.Pricing.Tests
dotnet new slnmakes the solution file.dotnet new classlibmakes the project for the real code.dotnet new mstestmakes the test project.dotnet add ... referencelets the test project use the real project.dotnet sln addadds both projects to the solution.
Delete the sample files the templates made: Class1.cs in src/ and Test1.cs in tests/.
The code to test
In lesson 05 we met PriceCalculator. Here it is again, with one more method. Save it as src/BookShop.Pricing/PriceCalculator.cs:
namespace BookShop.Pricing;
public class PriceCalculator
{
public decimal AddTax(decimal price)
{
return price * 1.1m;
}
public decimal ApplyDiscount(decimal price, decimal percent)
{
if (percent < 0 || percent > 100)
{
throw new ArgumentOutOfRangeException(nameof(percent), "Percent must be between 0 and 100.");
}
return price - price * percent / 100;
}
}
AddTaxadds 10% tax.ApplyDiscounttakes a percentage off. A discount below 0% or above 100% makes no sense, so it throws an error.
Your first test
Save this as tests/BookShop.Pricing.Tests/PriceCalculatorTests.cs:
namespace BookShop.Pricing.Tests;
[TestClass]
public class PriceCalculatorTests
{
[TestMethod]
public void AddTax_PriceOf100_Returns110()
{
// Arrange
var calculator = new PriceCalculator();
// Act
var result = calculator.AddTax(100m);
// Assert
Assert.AreEqual(110m, result);
}
}
What each piece does:
[TestClass]tells MSTest that this class holds tests.[TestMethod]tells MSTest that this method is one test.Assert.AreEqual(expected, actual)checks that two values are the same. If they are not, the test fails.
Arrange, Act, Assert
Almost every test has the same three steps. The comments in the code mark them:
- Arrange: set things up. Here, create the calculator.
- Act: do the one thing you are testing. Here, call
AddTax. - Assert: check the result. Here, is it 110?
Keeping to these three steps makes a test easy to read. Anyone can see at once what is set up, what is done and what is expected.
The test name
Give every test a name that is a full, clear sentence. Read the name alone, and you should know exactly what the test does. You should not need to open the code.
A good name says three things: what is tested, with which input, and what should happen. We join them with underscores:
Method_Input_ExpectedResult
Here are the two tests from above:
AddTax_PriceOf100_Returns110
ApplyDiscount_NegativePercent_ThrowsException
Read them out loud. "Add tax, price of 100, returns 110." "Apply discount, negative percent, throws exception." Nothing is hidden.
Now compare with names that tell you nothing:
| Weak name | Why it is weak | Fluent name |
|---|---|---|
Test1 |
Says nothing | AddTax_PriceOf100_Returns110 |
AddTaxTest |
Which input? What result? | AddTax_PriceOfZero_ReturnsZero |
DiscountWorks |
Works how? | ApplyDiscount_10PercentOff200_Returns180 |
ApplyDiscountError |
Which error, and when? | ApplyDiscount_NegativePercent_ThrowsException |
Why spend the effort? Two reasons:
- A failing test explains itself. Imagine you run 2,000 tests and one fails. If the report says
Test1 failed, you have to open the code and work out what it was checking. If it saysApplyDiscount_NegativePercent_ThrowsException failed, you already know: a negative discount no longer throws an error. - The list of tests becomes documentation. Open a test class, or the test list in Visual Studio, and read only the names:
AddTax_PriceOf100_Returns110
AddTax_PriceOfZero_ReturnsZero
ApplyDiscount_10PercentOff200_Returns180
ApplyDiscount_NegativePercent_ThrowsException
You have just learned how PriceCalculator behaves, without reading any of its code. A new teammate can do the same. Tests with good names are the most honest documentation in a project, because they are checked every time the tests run.
A long name is fine here. Nobody calls a test method by hand, so it does not need to be short. It needs to be clear.
Two small notes:
- Other teams use other styles, such as
Adding_tax_to_100_returns_110. That is fine. Pick one style for the whole project and keep to it. The rule that matters is the same: the name alone says what the test does. - If the name gets too long and messy, the test is doing too much.
ApplyDiscount_When10PercentAnd200AndNoCoupon_Returns180is trying to describe several things at once. Split it into smaller tests, one idea each. This is the "one idea per test" rule from below.
Run it
From the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 33 ms - BookShop.Pricing.Tests.dll (net10.0)
In Visual Studio, open Test Explorer from the Test menu and press Run All Tests. Green means passed, red means failed.
(Your duration will differ.)
Watch a test fail
A test that has never failed has never proved it can catch anything. So break the code on purpose. In AddTax, change 1.1m to 1.2m, as if someone made a typing mistake, and run the test again:
dotnet test
Failed AddTax_PriceOf100_Returns110 [36 ms]
Error Message:
Assert.AreEqual failed. Expected:<110>. Actual:<120.0>.
Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 67 ms - BookShop.Pricing.Tests.dll (net10.0)
(The real output has more lines. This is the useful part.)
The code compiled, yet the test caught the mistake. It named the test that failed and showed the expected and actual values. Change it back to 1.1m and the test passes again.
This is the whole point of the lesson. Imagine the same typo made in a project with thousands of lines of code. The test finds it in seconds.
Rules of thumb
Test one class at a time
A unit test looks at one class. When PriceCalculator has a test, that test uses only PriceCalculator. If a test fails, you know where to look.
Test the public methods only
Test what a class offers to the outside: its public methods. Do not test the private ones.
The reason is that private methods are the class's inner details. You may rewrite them tomorrow. If your tests depend on the details, they break every time you tidy up, even when the behaviour is the same. A test that checks what the class does, and not how, lets you change the inside freely. The private methods still get tested, because the public methods call them.
One idea per test
A test checks one thing, and has one reason to fail. If a test checks five things, you do not know which one broke. Write five small tests.
Think like a QA
The easy test is the happy path: the normal input where everything goes right. It is not enough. A QA person asks, "How can I break this?" Do the same. Before you write tests, make a short list of scenarios.
For AddTax and ApplyDiscount, the list could be:
| Scenario | Why it matters |
|---|---|
| Price of 100 | the normal case |
| Price of 0 | the edge: a free item |
| 10% off 200 | the normal case for the discount |
| Discount of -5% | invalid input: it should fail clearly |
| Discount of 100% | the edge: the biggest valid discount |
Pick scenarios that tell you something. Ten tests that all check the normal case are worth less than four tests that cover the normal case, the edges and the bad input. Good tests come from good scenarios, not from a high count.
Here are three more tests from that list. Add them to the same class:
[TestMethod]
public void AddTax_PriceOfZero_ReturnsZero()
{
var calculator = new PriceCalculator();
var result = calculator.AddTax(0m);
Assert.AreEqual(0m, result);
}
[TestMethod]
public void ApplyDiscount_10PercentOff200_Returns180()
{
var calculator = new PriceCalculator();
var result = calculator.ApplyDiscount(200m, 10m);
Assert.AreEqual(180m, result);
}
[TestMethod]
public void ApplyDiscount_NegativePercent_ThrowsException()
{
var calculator = new PriceCalculator();
Assert.Throws<ArgumentOutOfRangeException>(() => calculator.ApplyDiscount(200m, -5m));
}
The last test is different. We expect an error, so Assert.Throws says: "this code must throw this kind of exception, or the test fails". The part after => is the code that should throw.
Run dotnet test again. There are now four tests, and all of them pass.
Recap
- Compiling does not prove the code works. Tests check the behaviour.
- Tests are an investment. They slow you down at first, and they pay back when the project grows, because you can change code with confidence.
- A unit test checks one small piece of code, on its own, and runs fast. Tests do not replace QA.
- Keep the real code in
src/and the tests intests/. Tests are for the team only and are never shipped. A test project is named<Project>.Testsand references the project it tests. - MSTest is a simple framework to start with, and it works in Visual Studio. Others, like xUnit and NUnit, work in a similar way.
- A test has three steps: Arrange, Act, Assert. Name it as
Method_Input_ExpectedResult, so the name alone says what the test does. - Test one class at a time, only its public methods, and one idea per test.
- Think like a QA: cover the normal case, the edges and the bad input.
Questions to think about
The answers are not given here on purpose, so check yourself against the lesson.
- A teammate says, "Writing tests only slows me down." Your code also compiles with no errors. What would you say to them about why that is not enough?
- A test is called
Test1. Why is that a problem when it fails? Rename it to something better forAddTax(0). - You write a method
IsEligibleForFreeShipping(decimal orderTotal)that returns true when the total is 50 or more. Which input values would you test? Think like a QA, and give each test a name. - A class has a private method that you want to test directly. What would you do instead, and why?
- Your
PriceCalculatornow needs to read the tax rate from a database. How could you still test it on its own, without a real database? (Lesson 17 answers this.)
Next lesson: a typical web app workflow, frontend and API.