Unit Testing (Examples in C# with MSTest)
Unit tests are programs that automatically verify that each part of your code works as expected.
This guide presents the fundamental concepts of unit testing, illustrated with MSTest, a testing framework for C#. However, the principles remain applicable to all languages (see end of page).
Why Write Unit Tests?
- Quality: Code coverage is the percentage of code tested by unit tests and ensures that the code works as expected and that future changes do not break existing features. Having a high coverage rate is a sign of code quality and robustness.
- Quick bug detection: Unit tests allow you to quickly detect errors in the code, reducing debugging time.
- Living documentation: Unit tests serve as living documentation for the code, showing how each part is supposed to work.
- Easier code modification: Unit tests allow you to modify code with confidence, as they can ensure that changes haven’t broken anything.
Creating and Configuring an MSTest Project
Here’s how to properly set up a unit test project with MSTest for a C# .NET project:
Prerequisites
Have an existing C# project (for example, MonProjet
). Otherwise:
mkdir MyProjectSolution # Create a folder for the project if needed
cd MyProjectSolution # Go into the project folder
dotnet new sln -n MyProjectSolution # Create a .sln solution file
dotnet new console -o src/MyProject # Create a console project in src/MyProject
dotnet sln add src/MyProject/MyProject.csproj # Add the project to the solution
For the following, we will use this project structure:
MyProjectSolution/
├── MyProjectSolution.sln
└── src/
└── MyProject/
├── MyProject.csproj
└── Program.cs
Adapt the paths if you have a different structure.
Configure the test project
In your solution folder:
dotnet new mstest -o tests/MyProject.Tests
dotnet sln add tests/MyProject.Tests/MyProject.Tests.csproj
dotnet add tests/MyProject.Tests reference src/MyProject/MyProject.csproj
This creates a test project named MyProject.Tests
in the tests
folder, then links the test project to the solution and the main project.
That’s it! You can now write your tests in tests/MyProject.Tests
. Organize the test files following the structure of the main project (e.g., a file MaClasseTests.cs
for MaClasse.cs
).
Here is the final structure:
MyProjectSolution/
├── MyProjectSolution.sln
├── src/
│ └── MyProject/
│ ├── MyClass.cs
│ ├── MyProject.csproj
│ └── Program.cs
└── tests/
└── MyProject.Tests/
├── MyClassTests.cs
├── MyProject.Tests.csproj
└── MSTestSettings.cs
For more details: Microsoft docs - Create an MSTest test project
Structure of a Unit Test (AAA pattern)
- Arrange: Prepare the data and objects needed to test the method.
- Act: Call the method to test.
- Assert: Verify the result obtained.
Realistic example with MSTest
Suppose a class that contains methods to calculate VAT and the total price (including VAT) for a given amount:
namespace MyNamespace
{
public static class Calculator
{
public static decimal CalculateVAT(decimal amount, decimal rate)
{
if (rate < 0)
{
throw new ArgumentException("The rate cannot be negative.");
}
return amount * rate;
}
public static decimal CalculateTotalPrice(decimal amount, decimal rate)
{
return amount + CalculateVAT(amount, rate);
}
}
}
Associated unit tests:
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace MyNamespace.Tests
{
[TestClass]
public class CalculatorTests
{
[TestMethod]
public void CalculateVAT_WithValidAmountAndRate_ReturnsExpectedResult()
{
// Arrange
decimal amount = 100;
decimal rate = 0.2;
// Act
decimal result = Calculator.CalculateVAT(amount, rate);
// Assert
Assert.AreEqual(20, result, "The VAT should be correct.");
}
[TestMethod]
public void CalculateTotalPrice_WithValidAmountAndRate_ReturnsCompletePrice()
{
// Arrange
decimal amount = 100;
decimal rate = 0.2;
// Act
decimal result = Calculator.CalculateTotalPrice(amount, rate);
// Assert
Assert.AreEqual(120, result, "The total price should be the amount plus VAT.");
}
}
}
Writing Unit Tests with MSTest
The Assert
Class
Assert
is used to verify expected results in your tests.
Here are the most common methods:
Assert.AreEqual(expected, actual, message)
: Checks that two values are equal.Assert.AreNotEqual(expected, actual, message)
: Checks that two values are not equal.Assert.IsTrue(condition, message)
: Checks that a condition is true.Assert.IsFalse(condition, message)
: Checks that a condition is false.Assert.IsNull(object, message)
: Checks that an object is null.Assert.IsNotNull(object, message)
: Checks that an object is not null.
If a test fails, the (optional) message is displayed, which saves time.
Parameterized Tests
To test a method with several sets of data, use the [DataTestMethod]
and [DataRow]
attributes.
[DataTestMethod]
[DataRow(100, 0.2, 20)]
[DataRow(50, 0.1, 5)]
public void CalculateVAT_WithVariousValues_ReturnsExpectedResult(decimal amount, decimal rate, decimal expected)
{
Assert.AreEqual(expected, Calculator.CalculateVAT(amount, rate));
}
Exception Handling
To test that a method throws an expected exception, use the [ExpectedException]
attribute or the Assert.ThrowsException<T>()
method.
// With ExpectedException
[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public void CalculateVAT_NegativeRate_ThrowsException()
{
Calculator.CalculateVAT(100, -0.2);
}
// With Assert.ThrowsException
[TestMethod]
public void CalculateVAT_NegativeRate_ThrowsException()
{
var exception = Assert.ThrowsException<ArgumentException>(() => Calculator.CalculateVAT(100, -0.2));
Assert.AreEqual("The rate cannot be negative.", exception.Message);
}
Running Tests
How you run tests depends on your development environment:
- Visual Studio: "Test" > "Test Explorer", Ctrl+E, T or right-click on the test project.
- Visual Studio Code: "Testing" tab or right-click on the test project.
- JetBrains Rider: "Tests" tab and "Test Coverage".
- Terminal:
dotnet test
Best Practices
-
Segment: One test = one test method.
-
Explicit naming: Indicate what is being tested and the expected result. Two naming conventions are common:
Method_Should...
:Method_ShouldReturnExpectedResult
Given..._When..._Then...
:Method_Condition_ExpectedResult
-
Isolation: Tests should not depend on each other.
-
Limit side effects: Clean up resources if needed (
[TestCleanup]
,[TestInitialize]
,[AssemblyInitialize]
,[ClassInitialize]
, …).private List<string> _list;
[TestInitialize]
public void Setup()
{
// Common arrange for all tests: initialization
_list = new List<string> { "A", "B" };
}
[TestCleanup]
public void Cleanup()
{
// Cleanup after each test
_list.Clear();
}
[TestMethod]
public void List_ShouldContainA()
{
// Act
bool containsA = _list.Contains("A");
// Assert
Assert.IsTrue(containsA);
}
Common Mistakes
- Forgetting
[TestMethod]
or[TestClass]
: the test is not detected - Forgetting to build before testing (
dotnet build
) - Testing several behaviors in the same test
- Dependency between tests (e.g., modifying a static variable)
- Incorrect use of
Assert
(e.g., swapping expected/actual) - Not cleaning up resources (files, connections, etc.)
- Not testing edge cases or exceptions
- Leaving dead/unused code in tests
- Not running tests regularly
Going Further
- Async tests:
[TestMethod]
public async Task CalculAsync_ShouldReturnResult()
{
var result = await CalculateurAsync.CalculAsync(10, 2);
Assert.AreEqual(20, result);
}
- Mocks: To isolate dependencies (see Moq, NSubstitute)
- Configure MSTest: Parallelization, global timeout, etc. by editing MSTestSettings.cs file. See the official MSTest documentation.
Unit Testing in Other Languages
The principles of unit testing are universal. Here are some popular frameworks:
- Python: pytest, unittest
- Java: JUnit
- JavaScript/TypeScript: Jest, Mocha
- Go: testing
- Rust: Built-in tests
- PHP: PHPUnit
- Ruby: RSpec
Each language has its specifics, but the AAA logic and philosophy remain the same.
Other Examples
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace LivInParis.Tests
{
[TestClass]
public class NodeTests
{
[TestMethod]
public void Constructor_ShouldCreateNodeWithUniqueName()
{
// Arrange
var nodeName = "TestNode";
// Act
var node = new Node(nodeName);
// Assert
Assert.AreEqual(nodeName, node.Name);
Assert.AreNotEqual(-1, node.Id);
}
}
}
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ExampleNamespace.Tests
{
[TestClass]
public class ExampleTests
{
[TestMethod]
public void Addition_ShouldReturnCorrectSum()
{
// Arrange
int a = 2;
int b = 3;
// Act
int result = Sum(a, b);
// Assert
Assert.AreEqual(5, result, "The sum should be 5");
}
}
}
namespace LivInParis.Tests
{
[TestClass]
public class NodeTests
{
[TestMethod]
public void Equals_ShouldReturnTrueForSameId()
{
// Arrange
var node1 = new Node("EqualsNode1");
var node2 = new Node("EqualsNode2");
// Act
bool areEqual = node1 == node1;
bool areNotEqual = node1 == node2;
// Assert
Assert.IsTrue(areEqual);
Assert.IsFalse(areNotEqual);
}
}
}
namespace Boggle.Tests;
[TestClass]
public class PlayerTests
{
[TestMethod]
[DataRow("J2", "en")]
[DataRow("J3", "fr")]
public void Name_ShouldReturnCorrectName(string name, string language)
{
// Arrange
Language.Initialize(language);
Player player = new Player(name);
// Act
string playerName = player.Name;
// Assert
Assert.AreEqual(name, playerName);
}
}