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.

    Changing code without tests and with tests No tests My change Feature A ? Feature B ? Feature C ? Did I break something? Nobody knows. With tests My change Run all tests 1,199 passed 1 failed You know right away, and where.

    When you run the tests, there are two possible results:

    The two results of running the tests All tests pass 2,000 passed Good chance your change is safe Not a promise. QA still tests the whole app. One test fails 1 failed AddTax_PriceOf100_Returns110 Exactly the logic that broke: AddTax Investigate there. No guessing.
    • 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 cost a little now and pay back as the project grows Effort per change The project grows pays back No tests: harder every time With tests: steady

    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.
    A unit test checks one unit on its own The test expects 110 The unit AddTax Is it 110? pass or fail 100 110 Web server Database Internet None of these are needed.

    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.

    1. 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.
    2. The folders stay easy to read. You can see at a glance what is real code and what is test code.
    Only src ships. Tests stay with the team. Your solution src/ the real code tests/ the checks The published app what your users get Team only your computer, build server build and publish run, never shipped

    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 in tests/ 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.
    Each test project references the project it tests tests/ src/ BookShop.Pricing.Tests BookShop.Pricing references BookShop.Orders.Tests BookShop.Orders references One test project for each project. The real code knows nothing about the tests.

    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 sln makes the solution file.
    • dotnet new classlib makes the project for the real code.
    • dotnet new mstest makes the test project.
    • dotnet add ... reference lets the test project use the real project.
    • dotnet sln add adds 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;
        }
    }
    
    • AddTax adds 10% tax.
    • ApplyDiscount takes 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:

    1. Arrange: set things up. Here, create the calculator.
    2. Act: do the one thing you are testing. Here, call AddTax.
    3. 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:

    1. 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 says ApplyDiscount_NegativePercent_ThrowsException failed, you already know: a negative discount no longer throws an error.
    2. 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_Returns180 is 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 in tests/. Tests are for the team only and are never shipped. A test project is named <Project>.Tests and 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.

    1. 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?
    2. A test is called Test1. Why is that a problem when it fails? Rename it to something better for AddTax(0).
    3. 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.
    4. A class has a private method that you want to test directly. What would you do instead, and why?
    5. Your PriceCalculator now 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.

    ← SQLite: A Database in One FileA Typical Web App Workflow: Frontend and API →