Skip to main content

How to Write Unit Tests in Csharp

Unit tests aren't the most exciting part of writing software, but they're one of the things that pays off the most over time. They catch bugs before they become production incidents, they give you confidence to refactor without breaking things, and honestly — they save you from a lot of late-night debugging sessions. Let's walk through how to actually write good unit tests in C#.

What Counts as a Unit Test?

A unit test checks one small, isolated piece of your code — usually a single method or a tight little chunk of logic — to make sure it behaves the way you expect. The key word is isolated. You're not testing how five different components work together (that's what integration tests are for); you're testing that one specific piece does exactly what it's supposed to, on its own.

Because they're so narrow in scope, unit tests tend to be quick to write and even quicker to run. That speed is a big part of why they're worth the investment.

Getting Started: The Basic Workflow

There's a fairly standard process most C# developers follow:

Pick a testing framework. MSTest, NUnit, and xUnit are the three big names in the C# world. They're all solid choices — xUnit tends to be the most popular in newer projects, but any of them will get the job done.

Figure out what actually needs testing. You don't need to test every single line of code you write. Focus your energy on the logic-heavy stuff — the methods where bugs are most likely to hide — and don't stress over trivial one-liners that can't really go wrong.

Set up a dedicated test project. Keep your tests in their own project, separate from your production code. It keeps things tidy and makes it obvious what's shippable code versus what's testing infrastructure.

Write your test cases. Each test case is really just a specific input paired with the output you expect to see. Simple in theory — the trick is writing enough of them to actually cover your logic.

A Basic Example

Let's say you've got a dead-simple Calculator class:

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

Here's what a test for it looks like using xUnit:

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_WithTwoNumbers_ReturnsCorrectSum()
    {
        // Arrange: Initialize objects and variables.
        var calculator = new Calculator();
        
        // Act: Call the method under test.
        int result = calculator.Add(2, 3);

        // Assert: Verify the expected outcome.
        Assert.Equal(5, result);
    }
}

You'll notice the test is broken into three clear parts:

  • Arrange — set up whatever the test needs, like creating your objects or preparing input data.
  • Act — actually call the method you're testing.
  • Assert — check that what came back matches what you expected.

This Arrange-Act-Assert rhythm might feel a little formal at first, but it makes your tests way easier to read at a glance — anyone skimming your test suite can immediately tell what's being set up, what's being tested, and what the expected outcome is.

A Few Habits Worth Building

Keep each test focused on one thing. Don't try to cram multiple scenarios into a single test method. If you're testing negative numbers, boundary values, and normal inputs, those deserve separate tests — it makes failures much easier to diagnose.

Name your tests so they explain themselves. A name like Add_WithNegativeNumbers_ReturnsCorrectSum tells you exactly what's being verified without needing to open the method body. Future-you (or your teammates) will thank you.

Don't hard-code your dependencies. If a method relies on something external — a database, an API, whatever — use dependency injection so you can swap in a fake version during testing. Mocking libraries like Moq make this a lot less painful.

Don't skip the edge cases. Null inputs, empty strings, boundary values, weird unexpected data — these are exactly the scenarios where bugs like to hide. If your method should handle them gracefully, prove it with a test.

Actually run your tests, often. Tests you never run aren't doing you any good. Make it a habit to run your suite frequently so you catch regressions while they're still small and easy to fix.

Leveling Up: A Few More Advanced Techniques

Mocking Dependencies

If your method reaches out to a database, an API, or any other external system, you probably don't want your unit tests actually hitting those things — that's slow, unreliable, and honestly not what a unit test is for. Mocking lets you fake out that dependency so your test can run in complete isolation, focusing purely on the logic you actually care about.

Parameterized Tests

Writing a near-identical test for every possible input gets old fast. Instead, xUnit's [Theory] attribute lets you define one test method and feed it multiple sets of inputs and expected outputs:

[Theory]
[InlineData(1, 2, 3)]
[InlineData(4, 5, 9)]
public void Add_WithDifferentNumbers_ReturnsCorrectSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    int result = calculator.Add(a, b);
    Assert.Equal(expected, result);
}

This keeps your test suite compact and much easier to maintain — add a new case, and you're just adding a line, not a whole new method.

Testing Async Code

Since so much of modern C# leans on async/await, you'll inevitably need to test asynchronous methods too. xUnit handles this cleanly:

[Fact]
public async Task GetDataAsync_ReturnsNonNullValue()
{
    var service = new DataService();
    var result = await service.GetDataAsync();
    Assert.NotNull(result);
}

Testing the async version directly — rather than blocking on it or working around it — means you're actually exercising the real code path your application will use in production.

Why Bother With All This?

It's easy to see unit testing as extra work standing between you and shipping a feature. But the payoff shows up fast: fewer bugs slipping into production, less time spent hunting down regressions, and a codebase that's genuinely easier to refactor with confidence. There's also a quieter benefit — a good test suite doubles as documentation, showing anyone reading it exactly how a method is supposed to behave.

Popular posts from this blog

How to Check if Someone is Connected to Your Machine in Linux

Picture this: you glance at your system monitor and notice your CPU is humming along even though you're not running anything demanding. Or maybe your internet feels sluggish for no obvious reason. A small, uneasy thought creeps in — is someone else on my machine right now? For Linux users, this isn't something you have to wonder about. Linux ships with a powerful set of built-in tools that let you see exactly who's connected, who's logged in, and what your network is doing at any given moment. You don't need to be a security expert to use them — you just need to know where to look. This guide walks you through the practical, no-nonsense steps to check for unauthorized connections on your Linux system, with real commands you can run right now. Why Monitoring Network Connections Matters Every device on a network — including your own Linux machine — communicates using an IP address. When another device or user connects to your system, that connection shows up as a trac...

How to Set Up a Linux Web Server and Host an HTML Page Easily

Setting up a web server on Linux means spending a fair amount of time in the terminal — Linux leans heavily on the command line rather than clicking through menus, so you'll be typing out instructions more often than not.  If you're new to this, it can feel a little intimidating at first, but the good news is you don't need to become a Linux wizard overnight. A handful of core commands will get you surprisingly far. A few you'll lean on constantly: cd — move between directories ls — see what's in the current directory mkdir — create a new folder nano or vim — edit files right there in the terminal sudo — run something with administrator privileges Get comfortable with these and you'll be able to navigate around, tweak configuration files, and install software without much trouble. You don't need to memorize everything — you just need to be confident enough to follow along with clear instructions, which is exactly what this guide aims to give you....

Linux Network Troubleshooting

If you've spent any time as a sysadmin — or honestly, just as someone who's had to fix their own home network at 11pm — you know that connectivity issues are one of the most common headaches out there. The good news is that a handful of core tools and a methodical approach can take you from "why isn't this working" to a root cause pretty quickly.  This guide walks through the essentials: configuring interfaces, managing routes, and diagnosing problems when things go sideways. Configuring Network Interfaces Your network interfaces are the actual bridge between your machine and the outside world, so getting them configured correctly is step one for any kind of reliable connectivity. Doing It Manually ifconfig is the old-school, tried-and-true tool for this on Unix-like systems. To see everything currently configured, run: ifconfig -a If you need to manually set up a specific interface — assigning an IP, a netmask, and bringing it online — it looks like this: ifconf...