Your First Unit Test
By the end of this lesson
Create a test project, write a test, and run it.
A unit test is a small program that calls your code and checks the answer. Nothing more mysterious than that.
It lives in its own project, separate from the application, and a test runner finds it, runs it, and reports pass or fail. This lesson gets you from an empty folder to a passing test, and then to a failing one, because you cannot trust a test you have never seen fail.
Three terms used constantly from here on:
- Test project
- A normal class library that also references a testing framework and a runner. It is compiled alongside your solution but never shipped with the application.
- Test framework
- The library that provides the markers and the assertion methods. This course uses xUnit, one of the three common choices for .NET alongside NUnit and MSTest. The ideas transfer; the attribute names differ.
- Test runner
- The tool that discovers tests, executes them and reports results. For .NET it is built into the SDK and invoked with dotnet test.
Set up the projects
Two projects: one holding the code under test, one holding the tests.
Create the solution and the library
The library is where PriceCalculator will live. Keeping it separate from the test project is what forces you to make the code reachable from outside, which is a useful pressure.
Create the test project
dotnet new xunit produces a project with the framework, the runner and a sample test already wired together. You do not have to assemble those packages yourself.
Add a project reference
The test project needs to see the library. The reference points from tests to code, never the other way round — the application must not depend on its tests.
Run the suite
dotnet test builds everything and runs every test it finds. Run it now, before you have written anything, so you know the plumbing works.
# A folder to hold both projects
mkdir Pricing && cd Pricing
dotnet new sln --name Pricing
# The code under test
dotnet new classlib --name Pricing.Domain
dotnet sln add Pricing.Domain
# The tests
dotnet new xunit --name Pricing.Domain.Tests
dotnet sln add Pricing.Domain.Tests
# Tests reference the code, not the reverse
dotnet add Pricing.Domain.Tests reference Pricing.Domain
# Build everything and run whatever tests exist
dotnet test- dotnet new sln creates a solution file, which is the list of projects that belong together. It is what lets one dotnet test command cover the whole suite.
- dotnet new xunit is the step that saves the most effort. It adds the xUnit framework, the runner and the test SDK at versions known to work together.
- dotnet add reference records the dependency in the test project's file. Without it the test code cannot see PriceCalculator and will not compile.
- The naming pattern — ProjectName.Tests next to ProjectName — is a convention rather than a requirement, but it means anyone can find the tests for a project without looking.
The code under test
namespace Pricing.Domain;
public class PriceCalculator
{
public decimal ApplyDiscount(decimal price, int percentage)
{
if (percentage < 0 || percentage > 100)
{
throw new ArgumentOutOfRangeException(nameof(percentage));
}
decimal reduction = price * percentage / 100m;
return decimal.Round(price - reduction, 2);
}
}- The class and the method are public. A test project is separate code, so anything it calls has to be reachable from outside the library.
- decimal rather than double, because money needs exact decimal arithmetic. A double cannot represent 0.1 precisely and the error surfaces in totals.
- The guard clause throws for a percentage outside 0 to 100. That is behaviour, so it deserves a test of its own later in this module.
Write a green test
using Pricing.Domain;
using Xunit;
namespace Pricing.Domain.Tests;
public class PriceCalculatorTests
{
[Fact]
public void ApplyDiscount_WithTwentyPercentOffOneHundred_ReturnsEighty()
{
var calculator = new PriceCalculator();
decimal result = calculator.ApplyDiscount(100m, 20);
Assert.Equal(80m, result);
}
}- [Fact] marks a method as a test that takes no parameters. It is how the runner recognises the method — a public method without it is ignored.
- A test method returns void and takes no arguments. Anything it needs, it creates itself.
- The name has three parts: the method under test, the scenario, and the expected result. When this fails in a pipeline you will read the name and nothing else, so it has to carry the information.
- Assert.Equal compares expected with actual, in that order. Getting the order wrong does not change pass or fail, but it does reverse the failure message, which is confusing at the worst moment.
dotnet test
# Only the tests in one project
dotnet test Pricing.Domain.Tests
# Only tests whose name contains a string
dotnet test --filter ApplyDiscount
# One line per test, including the ones that passed
dotnet test --logger "console;verbosity=normal"- By default the output is quiet about successes and loud about failures, which is what you want in a pipeline and occasionally frustrating locally.
- --filter matches on the fully qualified test name, so it also accepts a class name or a namespace. It is how you run one test while working on it.
- A pass prints a count and nothing else. If you want to see each test listed, raise the verbosity.
Now make one fail on purpose
A test that has only ever passed proves very little. It might be asserting nothing useful. It might not be running at all.
So break it deliberately. Change the expected value to 79m and run the suite again.
Failed PriceCalculatorTests.ApplyDiscount_WithTwentyPercentOffOneHundred_ReturnsEighty
Assert.Equal() Failure: Values differ
Expected: 79
Actual: 80- The test name tells you the scenario. The two values tell you the gap. Between them you can usually locate the problem before opening the file.
- Put the expected value back to 80m and confirm it passes again. You have now seen both states, which is the point of the exercise.
Summary
- Tests live in their own project, which references the code under test and never the reverse
- dotnet new xunit assembles the framework, runner and SDK at compatible versions
- [Fact] marks a test; without it the method is silently ignored
- A test name should state the method, the scenario and the expected result, because that is all you get in a pipeline
- Watch every new test fail once, or you do not know whether it can
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Add two more tests
Add a test for a zero percent discount, which should leave the price unchanged, and one for a hundred percent discount, which should return zero.
Run the suite. Then break each one deliberately, watch it fail, and fix it again.
Show solution
These two are the boundaries of the allowed range. Boundaries are where off-by-one mistakes live, so they earn their place more than a second test somewhere in the middle would.
Writing them separately rather than as one test with two assertions means a failure names the case that broke. That distinction is the subject of the next lesson.
[Fact]
public void ApplyDiscount_WithZeroPercent_ReturnsOriginalPrice()
{
var calculator = new PriceCalculator();
decimal result = calculator.ApplyDiscount(49.99m, 0);
Assert.Equal(49.99m, result);
}
[Fact]
public void ApplyDiscount_WithOneHundredPercent_ReturnsZero()
{
var calculator = new PriceCalculator();
decimal result = calculator.ApplyDiscount(49.99m, 100);
Assert.Equal(0m, result);
}Think about it
Why two projects?
The tests could have gone in the same project as PriceCalculator. What do you gain by separating them, and what does it cost?
Show solution
You gain two things. The test framework and its packages stay out of the shipped application, so nothing test-related reaches production. And because the test project sits outside, it can only use the public surface of your code — the same surface real callers use.
The cost is a second project, a reference to keep correct, and slightly more ceremony when you add a new library. For anything beyond a throwaway experiment that trade is worth making, which is why the separate test project is the near-universal convention in .NET.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.