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.