Tutorial 2: MSBuild, .NET CLI and Unit Testing#

When developing larger applications, we rarely work with a single source file. Larger applications may consist of several projects, such as libraries, executable applications, and projects containing tests. Therefore, we need tools that allow us to manage the project structure and the build process.

In .NET, we use, among others:

  • .NET CLI - a tool that allows us to create, build, run, and test projects

  • MSBuild - the system responsible for the project build process

  • solution - a file that groups related projects

  • NuGet - the package manager for .NET

Projects and Solutions#

A larger .NET application may consist of several separate projects. Each project can be treated as a single component of a larger application. For example, one project may be responsible for the application logic, while another may contain unit tests. Each of these projects has its own .csproj file, which describes, among other things, how the project is built and what dependencies it has.

A solution is used to group projects belonging to the same application. A solution specifies which projects are logically related, but it does not itself define dependencies between them.

.NET CLI#

When working with .NET, we are not limited to the actions available in Visual Studio or Rider. Applications are often built in server environments without a graphical interface, for example in CI/CD processes (Continuous Integration and Continuous Delivery). For this reason, you need to know how to perform these operations directly from the terminal.

This is what the .NET CLI is used for. You can display the list of available commands using dotnet --help. Help for individual commands can be displayed using:

dotnet <command> --help

The .NET CLI allows us to perform various operations on a project. For example, we can build a project using dotnet build, while dotnet run can be used to build and run our application. In turn, dotnet test builds test projects and runs the tests they contain.

In the following parts of the lab, we will use the .NET CLI, among other things, to create a solution, add several projects to it, build the application, and run unit tests.

Creating a Solution#

A new solution can be created using:

dotnet new sln -n <SolutionName>

The dotnet new command creates a new element based on one of the templates available in the .NET SDK. In this case, we use the sln template, which is intended for creating solutions.

Creating a Library#

One of the project types available in .NET is a library. Unlike a console application, it is not intended to be run independently. It contains code that can be used by other projects. A library can be created as follows:

dotnet new classlib -n <LibraryName>.Lib

The command creates a new <LibraryName>.Lib directory together with the files:

<LibraryName>.Lib.csproj and Class1.cs.

Class1.cs is an example class generated by the template.

Implementing the Library#

A library should contain code that can be used independently by other parts of the application.

The library project created earlier contains the default Class1.cs file. It is only part of the template, so we can remove it and replace it with a class that matches the needs of our application.

For example, we can create a class responsible for basic rectangle operations:

namespace GeometryTools.Lib; 

public static class RectangleUtils 
{ 
    public static double CalculateArea(double width, double height)
    { 
        return width * height; 
    } 
}

Elements that should be accessible from other projects must be exposed appropriately, for example using the public modifier.

You can check whether the library compiles correctly using:

dotnet build GeometryTools.Lib

Adding a Project to a Solution#

Creating a project in the same directory as the solution file does not automatically add it to the solution.

The project must be added separately using:

dotnet sln add <LibraryName>.Lib

You can then check the contents of the solution:

dotnet sln list

The following should then appear in the list:

<LibraryName>.Lib

Creating a Console Application Project#

To create a console application, you can use:

dotnet new console -n <ProjectName>.App

Remember to add it to the solution:

dotnet sln add <ProjectName>.App

References Between Projects#

If the code of one project uses classes located in another project, we need to explicitly define such a dependency.

To add a reference to another project, you can use:

dotnet add <Project> reference <ReferencedProject>

After that, <Project> will be able to use the code located in <ReferencedProject>.

Using the Library in a Console Application#

After adding the reference, we can use public classes from the library in the console application.

For example, if the library uses the GeometryTools.Lib namespace, we can import it at the beginning of our application:

using GeometryTools.Lib;

We can then use methods provided by the library:

double area = RectangleUtils.CalculateArea(5,4);
Console.WriteLine(area);

The application can then be run using:

dotnet run --project GeometryTools.App

Building a Project#

C# source code must be compiled before it can be executed. The dotnet build command is used to compile the entire solution. If we want to compile only a specific project, we can use dotnet build <ProjectName>.

Dependencies are also taken into account during the build process, so projects are built in the correct order.

Task 1 - Temperature Converter#

In this task, you will create a TemperatureConverter application consisting of two projects: TemperatureConverter.Lib and TemperatureConverter.App. It will be an application that converts temperatures from Celsius to Fahrenheit.

Complete the following steps:

  • Create the TemperatureConverter solution and both projects, and add them to the solution.

  • Add the appropriate project reference so that the console application can use the library.

  • In the library, create a TemperatureUtils class containing the public method public static double CelsiusToFahrenheit(double temperature), which converts the temperature according to the formula: F = C * 9/5 + 32.

  • In the console application, use the library method to convert several example temperatures, such as -20, 0, 20, and 100.

  • Build the entire solution and then run the console application.

Complete this task both from the terminal using the .NET CLI and in an IDE of your choice, such as Visual Studio or Rider.

MSBuild and .csproj Files#

So far, we have performed most operations using dotnet commands. However, it is worth understanding where information about the project is stored and how it is used during the application build process.

Each .NET project has its own .csproj file. It is an XML file containing a description of the project, including its type, the version of .NET it uses, and dependencies on other projects or packages.

An example project file for a console application may look as follows:

<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <ProjectReference Include="..\GeometryTools.Lib\GeometryTools.Lib.csproj" />
  </ItemGroup>

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

The ItemGroup section stores elements associated with the project, such as ProjectReference, while PropertyGroup contains project properties.

Commands executed using the .NET CLI often modify the .csproj file. For example, adding a reference to another project causes an appropriate ProjectReference entry to be added.

MSBuild is responsible for reading the .csproj file and building the project. In practice, this means that the dotnet build command launches MSBuild, which analyzes the project file, takes its dependencies into account, and performs the steps required to build the application.

An MSBuild project file can contain several types of elements. The most important ones are Properties, Items, Targets, and Tasks.

Properties#

Properties store individual values used during the build process. They are grouped inside a PropertyGroup element.

For example:

<PropertyGroup>
    <ApplicationName>GeometryTools</ApplicationName>
</PropertyGroup>

A property value can be referenced using $(PropertyName).

We have already encountered Properties in the .csproj file. For example:

<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
</PropertyGroup>

Items#

Items are used to represent collections of elements used during the build process. Most commonly, these are files or project dependencies.

A collection of Items can be referenced using:

@(ItemName)

We have already used Items when adding a project reference:

<ItemGroup>
    <ProjectReference Include="..\GeometryTools.Lib\GeometryTools.Lib.csproj" />
</ItemGroup>

Targets#

A Target describes a specific stage of a process performed by MSBuild. A project may contain many such Targets, each responsible for a specific task. Targets may also declare dependencies between one another:

<Target Name="Prepare">
    <Message Text="Preparing..." />
</Target>

<Target Name="BuildApplication" DependsOnTargets="Prepare">
    <Message Text="Building..." />
</Target>

Running BuildApplication will first execute the Prepare Target. To run a target, you can use:

dotnet msbuild -target:BuildApplication

Tasks#

A Target itself only defines a stage of the process. The specific operations performed during that stage are called Tasks.

For example, if we want to create a directory and display a message after completing the operation, we can do the following:

<Target Name="Prepare">
    <MakeDir Directories="build" />
    <Message Text="Build directory prepared" />
</Target>

In this case, Prepare is a target, while MakeDir and Message are Tasks.

Here are some of the most common MSBuild Tasks:

<Message />   <!-- displays a message -->
<MakeDir />   <!-- creates a directory -->
<Copy />      <!-- copies files -->
<Delete />    <!-- deletes files -->

Debug and Release#

A .NET project can be built using different configurations. The most commonly used ones are:

Debug - intended for working on and developing the code.

Release - intended for building the final version of the application.

A project can be built using a selected configuration with:

dotnet build -c Release

or:

dotnet build -c Debug

The currently selected configuration is available in MSBuild as a Property:

$(Configuration)

The Condition attribute allows us to specify when a given MSBuild element should be used.

For example, we can define different property values depending on the configuration:

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
    <BuildType>Development</BuildType>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <BuildType>Production</BuildType>
</PropertyGroup>

NuGet#

When developing software, we rarely write everything from scratch. In everyday work, programmers often use ready-made libraries that solve common problems, such as parsing JSON files, logging errors, or communicating with databases.

NuGet, the official package manager for the .NET platform, is used to manage these external dependencies. The central repository nuget.org contains tens of thousands of ready-made, free packages, such as Newtonsoft.Json, Serilog, or EntityFramework, which we can add to our project in just a few seconds.

A package can be added to a project using:

dotnet add <Project> package <PackageName>

After adding the package, an entry similar to the following will appear in the .csproj file:

<ItemGroup>
    <PackageReference Include="<PackageName>" Version="<PackageVersion>" />
</ItemGroup>

PackageReference indicates that the project depends on an external NuGet package.

Unit Tests#

The last type of project we will work with is a project containing unit tests. Tests allow us to automatically verify whether individual parts of our program work as expected.

A unit test checks a small part of an application, most commonly a single method or class. Thanks to this, after modifying the code, we can quickly check whether previously working functionality still behaves correctly.

In .NET, we can use several frameworks for writing unit tests, including:

  • MSTest

  • NUnit

  • xUnit

In this lab, we will use MSTest.

A test project is built similarly to a regular library. The resulting project is then used as input for a test runner, which searches the library for methods marked with the [TestMethod] attribute and runs them.

Creating a Test Project#

We will add a third project to the existing GeometryTools solution, add it to the solution, and create a reference using the following commands:

dotnet new mstest -n GeometryTools.Tests
dotnet sln add GeometryTools.Tests
dotnet add GeometryTools.Tests reference GeometryTools.Lib

Notice that it is the test project that depends on the library, never the other way around. The library should not know about the existence of its tests.

Your First Test#

In the newly created project, you will find an example test class. In MSTest, a class containing tests is marked with the [TestClass] attribute, while individual test methods are marked with [TestMethod].

We can create a test for the CalculateArea method written earlier:

using GeometryTools.Lib;

namespace GeometryTools.Tests;

[TestClass]
public sealed class RectangleUtilsTests
{
    [TestMethod]
    public void CalculateArea_ValidDimensions_ReturnsCorrectArea()
    {
        //Arrange
        double width = 5;
        double height = 4;
    
        //Act
        double result = RectangleUtils.CalculateArea(width, height);

        //Assert
        Assert.AreEqual(20, result, 1e-7);
    }
}

The test can be run using:

dotnet test

The command first builds the appropriate projects and then runs the tests it finds.

If the expected value matches the result returned by the method, the test will be marked as passed. If this condition is not met, the test will fail.

A good unit test follows the simple Arrange-Act-Assert (AAA) pattern:

  1. Arrange: Prepare the conditions and input data.

  2. Act: Call the method being tested.

  3. Assert: Check whether the result matches the expected value.

Assertions#

Assertions are used to check the result of a test. For example:

Assert.AreEqual(expected, actual);
Assert.AreEqual(expected, actual, delta); // for float comparisons
Assert.IsTrue(condition);
Assert.IsFalse(condition);
Assert.IsNull(value);
Assert.IsNotNull(value);

If the checked condition is not satisfied, the assertion causes the test to fail.

It is worth testing not only typical cases but also edge cases, such as 0, empty collections, or values at the boundary of the allowed range.

A test name should describe the tested case as precisely as possible. One popular convention is:

MethodName_Scenario_ExpectedResult

For example:

CalculateArea_ValidDimensions_ReturnsCorrectArea
CalculateArea_OneSideIsZero_ReturnsZero

This makes it easy to determine which case failed simply by looking at the output of dotnet test.

Characteristics of a Good Test#

A good unit test should be:

  • fast - a project may contain thousands of tests, so they should execute as quickly as possible,

  • independent - one test should not depend on the result of another test,

  • repeatable - running the test multiple times under the same conditions should always produce the same result,

  • simple - the test should clearly show the input data, the operation being performed, and the expected result.

A unit test should also not duplicate the logic of the method being tested. For example, if we are testing a method that calculates the area of a rectangle, we should not duplicate the same logic in the test.

Sometimes development begins by writing unit tests first, defining the expected behavior of functions before implementing the tested methods. The implementation is then written until all tests pass. This approach is called Test Driven Development (TDD).

Task 2 - Testing the Temperature Converter#

Extend the TemperatureConverter solution from the previous task by adding a project containing unit tests.

  • Create a TemperatureConverter.Tests project using MSTest and add it to the solution.

  • Add the appropriate reference so that the test project can use TemperatureConverter.Lib.

  • Create a TemperatureUtilsTests class.

  • Write tests for the CelsiusToFahrenheit method using several characteristic temperatures.

  • Check, among other things, whether:

    • 0°C gives 32°F,

    • 100°C gives 212°F,

    • -40°C gives -40°F.

  • Run all tests using dotnet test.

  • Intentionally modify the implementation of CelsiusToFahrenheit so that it is incorrect, and check the result after running the tests again.

  • Restore the correct implementation and make sure that all tests pass again.

Complete this task both from the terminal using the .NET CLI and in an IDE of your choice, such as Visual Studio or Rider.