Day 28: Unit Testing Basics - Writing Code That Tests Your Code
arrow_back All Posts
April 06, 2026 12 min read .NET/C#

Day 28: Unit Testing Basics - Writing Code That Tests Your Code

Yesterday we did a deep dive into collections — dictionaries, hash sets, stacks, queues, and all those data structures that make you feel like a proper computer scientist at dinner parties. Today we're doing something completely different. We're going to talk about testing — specifically, how to write code that checks whether your other code actually works. Welcome to Day 28: Unit Testing Basics.

Here's a confession: you've been testing your code this entire series. Every time you hit F5, squinted at the console output, and thought "yeah, that looks right" — that was a test. A manual, unreliable, will-definitely-miss-something-eventually test. Automated unit tests do the same thing, except they run in milliseconds, they never get bored, and they never forget to check that one edge case you swore you'd "get to later."

1. Why Test? — Your Future Self Will Thank You

Imagine you're building a calculator. You write the Add method, run the app, type 2 + 3, see 5, and think "ship it." Three weeks later, you refactor something, and now Add returns 4. You don't notice because you didn't manually re-test addition — why would you? You fixed a completely different method.

This is where unit tests come in. A unit test is a small piece of code that calls your method, gives it specific inputs, and checks that the output matches what you expect. You write it once, and it runs every time you change anything. If Add(2, 3) suddenly returns 4, the test screams at you in bright red before that bug ever reaches production.

Think of unit tests as smoke detectors for your code. You don't stand in every room sniffing for smoke — you install detectors once, and they alert you automatically. Sure, installing them takes a few minutes. But the alternative is your house burning down at 3 AM.

2. Setting Up xUnit — Your Testing Toolkit

.NET has several testing frameworks: MSTest, NUnit, and xUnit. We're going with xUnit because it's the most popular in the .NET ecosystem, it's what most open-source projects use, and its syntax is clean.

Here's how to set things up. Open your terminal and create a new test project:

// In your terminal (not C# code — just commands)
dotnet new xunit -n MyApp.Tests
dotnet add MyApp.Tests reference MyApp

The first command creates a test project with xUnit pre-configured. The second command tells your test project "hey, you're allowed to see and call code from MyApp." Without that reference, your tests can't access the classes they're supposed to test.

Your solution structure should look something like this:

MySolution/
├── MyApp/               ← your actual application
│   └── Calculator.cs
└── MyApp.Tests/         ← your test project
    └── CalculatorTests.cs

The test project gets its own folder, its own .csproj, and its own sense of purpose. It exists solely to make sure your app code doesn't embarrass you.

3. Your First Test — The AAA Pattern

Let's write a class to test. Here's a dead-simple Calculator:

namespace MyApp;

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public int Subtract(int a, int b) => a - b;
}

Now let's write a test for the Add method:

using MyApp;
using Xunit;

namespace MyApp.Tests;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        int result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }
}

Let's break this down:

  • [Fact] — this attribute tells xUnit "this method is a test, please run it." Without it, xUnit ignores the method completely.
  • Arrange — set up everything you need. Create objects, define inputs, prepare the scene.
  • Act — call the method you're testing. This is the one line that actually does the thing.
  • Assert — check that the result is what you expected. If result isn't 5, the test fails.

This is called the AAA patternArrange, Act, Assert. It's not enforced by the compiler, but every experienced developer follows it because it makes tests readable. When a test fails six months from now, you want to understand what it does in five seconds, not five minutes.

The [Fact] attribute means "this test is always true — it's a fact." You're asserting that 2 + 3 will always equal 5. No conditions, no variations, no "well it depends."

4. [Theory] and [InlineData] — Test Many Inputs, Write One Method

What if you want to test Add with a bunch of different inputs? You could write ten separate [Fact] methods, but that's tedious and repetitive — exactly the kind of thing we write code to avoid.

Enter [Theory] with [InlineData]:

[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(-5, -3, -8)]
[InlineData(100, 200, 300)]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
    // Arrange
    var calculator = new Calculator();

    // Act
    int result = calculator.Add(a, b);

    // Assert
    Assert.Equal(expected, result);
}

A [Theory] says "this test is true for these specific cases." Each [InlineData] line is a separate test run. xUnit will execute this method five times — once for each data set. If the third one fails, it tells you exactly which inputs caused the problem.

This is called parameterized testing, and it's brilliant for testing methods that take inputs and return outputs. You write the logic once and just feed it data. It's like a vending machine — same mechanism, different buttons, different snacks.

5. Common Assertions — Your Testing Vocabulary

Assert.Equal is great, but it's not the only tool in the box. Here are the assertions you'll use most often:

// Check that two values are equal
Assert.Equal(5, calculator.Add(2, 3));

// Check that a condition is true
Assert.True(result > 0);

// Check that a condition is false
Assert.False(string.IsNullOrEmpty(name));

// Check that a string contains a substring
Assert.Contains("hello", greeting);

// Check that a collection contains an item
Assert.Contains(42, numbers);

// Check that a value is NOT null
Assert.NotNull(customer);

// Check that a value IS null
Assert.Null(deletedCustomer);

// Check that code throws a specific exception
Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));

// Check that code throws with a specific message
var ex = Assert.Throws<ArgumentException>(() => Validate(""));
Assert.Equal("Name cannot be empty", ex.Message);

A few notes on these:

  • Assert.Throws<T> is a big deal. It tests that your code fails correctly. You're not just checking happy paths — you're verifying that bad inputs produce the right kind of explosion. The lambda () => wraps the code that should throw, so xUnit can catch the exception instead of crashing your test.
  • Assert.Contains works on both strings and collections — it's context-aware.
  • Assert.Equal uses .Equals() under the hood, so it works correctly with value types, strings, and any class that overrides Equals.

Don't try to memorize every assertion. Learn Equal, True, Throws, and Contains — those four will cover about 90% of your tests.

6. Test Naming Conventions — Make Future-You Happy

Test names should read like a sentence. The most widely-used convention is:

MethodName_Scenario_ExpectedResult

Here are some examples:

// Good names — you know exactly what they test
public void Add_TwoPositiveNumbers_ReturnsSum() { }
public void Add_NegativeAndPositive_ReturnsCorrectSum() { }
public void Divide_ByZero_ThrowsDivideByZeroException() { }
public void GetFullName_EmptyLastName_ReturnsFirstNameOnly() { }

// Bad names — tell you nothing useful
public void Test1() { }
public void AddTest() { }
public void ItWorks() { }

Why does this matter? Because when a test fails in your CI pipeline at 11 PM on a Friday, you'll see something like:

FAILED: Divide_ByZero_ThrowsDivideByZeroException

With a good name, you immediately know what broke, under what conditions, and what was supposed to happen. With Test1, you know nothing. You're opening the file, reading the code, and trying to figure out what past-you was even testing. Don't do that to future-you.

Some teams use slightly different formats — Should_ThrowException_When_DivisorIsZero or natural language like Dividing_by_zero_should_throw. Pick a convention and stick with it. Consistency beats perfection.

7. Testing Edge Cases — Where Bugs Actually Live

Here's a truth about bugs: they rarely hide in the happy path. Nobody's Add(2, 3) is broken. Bugs live at the edges — the weird inputs, the boundary values, the things you didn't think anyone would actually do.

Let's expand our Calculator with a Divide method and test the tricky stuff:

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public int Subtract(int a, int b) => a - b;
    public int Multiply(int a, int b) => a * b;

    public double Divide(int a, int b)
    {
        if (b == 0)
            throw new DivideByZeroException("Cannot divide by zero.");
        return (double)a / b;
    }
}

Now let's test the edge cases:

[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
    var calculator = new Calculator();

    Assert.Throws<DivideByZeroException>(
        () => calculator.Divide(10, 0));
}

[Fact]
public void Divide_ZeroDividedByNumber_ReturnsZero()
{
    var calculator = new Calculator();

    double result = calculator.Divide(0, 5);

    Assert.Equal(0, result);
}

[Theory]
[InlineData(7, 2, 3.5)]
[InlineData(-10, 3, -3.3333333333333335)]
[InlineData(1, 3, 0.33333333333333331)]
public void Divide_VariousInputs_ReturnsCorrectQuotient(
    int a, int b, double expected)
{
    var calculator = new Calculator();

    double result = calculator.Divide(a, b);

    Assert.Equal(expected, result, precision: 10);
}

When you're thinking about what to test, ask yourself these questions:

  • What happens with zero?
  • What happens with negative numbers?
  • What happens with null or empty strings (for string parameters)?
  • What happens at boundary valuesint.MaxValue, int.MinValue?
  • What happens when the input is exactly on the edge of a condition?

Here's a quick checklist for edge case thinking:

  • Empty input: empty string "", empty list, zero
  • Null input: null references (if the parameter type allows it)
  • Negative numbers: especially for math operations
  • Large values: int.MaxValue + 1 causes overflow — does your code handle it?
  • Duplicate values: what if a list has the same item twice?
  • Single item: a collection with just one element

The happy path is where your code works. The edges are where your code proves it works.

8. Running Your Tests — The Moment of Truth

You've written tests. Now let's run them. You have several options:

Option 1: The Command Line

// Run all tests in the solution
dotnet test

// Run tests in a specific project
dotnet test MyApp.Tests

// Run a specific test class
dotnet test --filter "FullyQualifiedName~CalculatorTests"

// Run a specific test method
dotnet test --filter "FullyQualifiedName~Add_TwoPositiveNumbers_ReturnsSum"

The output will show you something like:

Passed!  - Failed: 0, Passed: 8, Skipped: 0, Total: 8

Green text. Zero failures. That little dopamine hit? That's what test-driven developers are addicted to.

Option 2: Visual Studio Test Explorer

If you're using Visual Studio 2022 (the "Heavy Duty Truck"), open Test → Test Explorer (or press Ctrl+E, T). You'll see a tree of all your tests. Click Run All, and watch them turn green one by one. It's oddly satisfying — like popping bubble wrap, but for code.

Option 3: VS Code with C# Dev Kit

If you're on VS Code (the "Sports Bike"), install the C# Dev Kit extension. It adds a Testing panel in the sidebar where you can run and debug individual tests with a click.

Option 4: JetBrains Rider

Rider (the "Fancy Electric Car") has built-in test runner support. Tests appear in the Unit Tests window, and you can run them with a gutter icon right next to each test method.

No matter which tool you use, the workflow is the same: write code → run tests → fix what's red → repeat. When all tests are green, you can refactor with confidence, knowing that if you break something, the tests will catch it immediately.

9. Your Homework: Build a Tested Calculator

Time to put it all together. Create a Calculator class with four methods — Add, Subtract, Multiply, and Divide — and write a full suite of tests for it.

Here's the class:

namespace MyApp;

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public int Subtract(int a, int b) => a - b;
    public int Multiply(int a, int b) => a * b;

    public double Divide(int a, int b)
    {
        if (b == 0)
            throw new DivideByZeroException("Cannot divide by zero.");
        return (double)a / b;
    }
}

Now write tests that cover:

  1. Happy paths for all four operations using [Theory] and [InlineData]
  2. Division by zero — make sure it throws DivideByZeroException
  3. Negative numbers — does Subtract(3, 5) correctly return -2?
  4. Zero as inputMultiply(anything, 0) should return 0, Add(0, 0) should return 0
  5. Large numbers — what happens with int.MaxValue?

Here's a starter template to get you going:

using MyApp;
using Xunit;

namespace MyApp.Tests;

public class CalculatorTests
{
    [Theory]
    [InlineData(1, 2, 3)]
    [InlineData(-1, -1, -2)]
    [InlineData(0, 0, 0)]
    [InlineData(int.MaxValue, 0, int.MaxValue)]
    public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calc = new Calculator();
        Assert.Equal(expected, calc.Add(a, b));
    }

    [Theory]
    [InlineData(5, 3, 2)]
    [InlineData(0, 0, 0)]
    [InlineData(-5, -3, -2)]
    [InlineData(3, 5, -2)]
    public void Subtract_VariousInputs_ReturnsCorrectDifference(
        int a, int b, int expected)
    {
        var calc = new Calculator();
        Assert.Equal(expected, calc.Subtract(a, b));
    }

    [Theory]
    [InlineData(3, 4, 12)]
    [InlineData(-2, 3, -6)]
    [InlineData(0, 999, 0)]
    [InlineData(1, 1, 1)]
    public void Multiply_VariousInputs_ReturnsCorrectProduct(
        int a, int b, int expected)
    {
        var calc = new Calculator();
        Assert.Equal(expected, calc.Multiply(a, b));
    }

    [Theory]
    [InlineData(10, 2, 5.0)]
    [InlineData(7, 2, 3.5)]
    [InlineData(-9, 3, -3.0)]
    public void Divide_VariousInputs_ReturnsCorrectQuotient(
        int a, int b, double expected)
    {
        var calc = new Calculator();
        Assert.Equal(expected, calc.Divide(a, b));
    }

    [Fact]
    public void Divide_ByZero_ThrowsDivideByZeroException()
    {
        var calc = new Calculator();
        Assert.Throws<DivideByZeroException>(() => calc.Divide(1, 0));
    }
}

Run it with dotnet test and watch the green checkmarks roll in. If something fails — good! That's the test doing its job. Read the error message, fix the code (or fix the test), and run again.

Summary of Day 28

  • Unit tests are small, automated checks that verify your code behaves correctly — they're smoke detectors for bugs.
  • xUnit is the most popular .NET testing framework — create a test project with dotnet new xunit.
  • [Fact] marks a test that's always true; [Theory] with [InlineData] lets you run the same test with different inputs.
  • The AAA patternArrange, Act, Assert — gives every test a clear, readable structure.
  • Common assertions include Assert.Equal, Assert.True, Assert.Contains, Assert.Throws<T>, and Assert.Null/Assert.NotNull.
  • Use the MethodName_Scenario_ExpectedResult naming convention so test failures tell you exactly what broke.
  • Edge cases — zero, null, negative numbers, boundary values — are where bugs actually live. Test them.
  • Run tests with dotnet test on the command line or use the built-in test explorer in your IDE.

Tomorrow: we'll talk about Dependency Injection — the pattern that makes your code testable, flexible, and ready for real-world applications. If today was about verifying your code works, tomorrow is about structuring it so it's easy to verify in the first place. 🚀

See you on Day 29!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.