Unit Tests in C#

Unit Tests in C#
09/2026
Noel H. | Solution Engineer

TL;DR

Unit tests verify individual units of an application in isolation and in milliseconds – without a database or infrastructure. This article shows how the AAA pattern (Arrange, Act, Assert) keeps tests readable and easy to diagnose, and breaks down the different test doubles: dummy, stub, spy, mock, and fake. One focus is on common anti-patterns such as over-mocking, brittle tests, assertion roulette, mystery guest, and test logic inside the test code. It also explains why code coverage is a useful indicator but not proof of quality. Bottom line: well-written unit tests improve design, make refactoring safer, and catch flawed implementations early.

What is a unit test?

A unit test is an automated test that verifies a single, self-contained unit of the system – such as a method – in isolation from its dependencies. It runs in milliseconds and needs no database, no network access, and no running infrastructure.

Well-written unit tests deliver the following benefits:

  • Regression protection: Every code change is immediately checked against the existing tests. This catches regressions faster.
  • Design feedback: Code that's hard to test is usually poorly designed too. Tests force clear dependencies.
  • Documentation: Tests like BorrowBookAsync_BookNotAvailable_ThrowsBookNotAvailableException precisely describe what's supposed to happen in that scenario.
  • Refactoring safety: With a green test suite, you can restructure code safely without constantly worrying about undetected bugs.

‍

The AAA pattern (Arrange, Act, Assert)

Every unit test can be broken down into three phases. This separation makes tests more readable and makes it easier to diagnose a failure.

  1. Arrange: set up the test data
  2. Act: call the method under test
  3. Assert: validate the result

A good example of AAA:

[TestMethod]
public void Create_ValidInputs_SetsDueDate14DaysLater()
{
    // Arrange
    var now = new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero);
    var expectedDueDate = now.AddDays(14);

    // Act
    var loan = Loan.Create(Guid.NewGuid(), Guid.NewGuid(), now);

    // Assert
    loan.DueDate.ShouldBe(expectedDueDate);
}

A bad example of AAA:

[TestMethod]
public void TestLoan()
{
    var loan = Loan.Create(Guid.NewGuid(), Guid.NewGuid(),
        DateTimeOffset.UtcNow);  // not deterministic!
    Assert.IsNotNull(loan);
    Assert.AreEqual(loan.BorrowedAt.AddDays(14), loan.DueDate);
    Assert.IsNull(loan.ReturnedAt);
    // what is actually being tested here?
}

Test Doubles

TypeDescriptionUse Case
DummyAn object that's passed in but never used.Filling a parameter when the value doesn't matter.
StubReturns predefined answers to requests.Indirect inputs: a repository returns an object.
SpyA stub that also records how it was called.Verifying that a method was called.
MockExpects specific calls and fails if they don't happen.Verifying interactions.
FakeA real (simplified) implementation that can't be used in production.In-memory repository.

‍

Anti-Patterns

‍

Over-Mocking

When every method of a stub is verified, you're no longer testing individual behavior but the system's implementation.

Example:

// Too much: implementation detail
await _books.Received(1).GetByIdAsync(book.Id);  // internal query
await _books.Received(1).UpdateAsync(book);        // this is relevant!
// Only verify business-relevant interactions.

Brittle Tests

Tests that rely on internal structure (private fields) break during refactoring. You should test behavior, not implementation.

‍

Assertion Roulette

A test with twelve asserts only shows, on failure, which assert failed first. In that case, the test should be split up.

‍

Mystery Guest

The data comes from an external file, a global variable, or a database. This makes it harder for the reader of the test to understand why a particular value is used.

‍

Test Logic (if/for)

Control flow inside a test can introduce bugs into the test itself. Tests should be kept simple. Different variants can be modeled using DataRow.

Example:

// NOT LIKE THIS
[TestMethod]
public void TestTitles()
{
    foreach (var title in new[] { null, "", "   " })
    {
        // Test logic = potential bugs in the test itself!
    }
}
// LIKE THIS
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("   ")]
public void Constructor_EmptyTitle_ThrowsArgumentException(string? title) { ... }

Code Coverage – Useful, But Not a Goal

Code coverage indicates what proportion of the program code was executed during the tests, but it says nothing about the quality of the tests.

What code coverage tells you: which code hasn't been tested at all.

What code coverage doesn't tell you: whether the tests are meaningful, whether the right scenarios are covered, or whether the assertions are correct. That said, a coverage report can help identify classes or code paths that aren't tested at all.

‍

Conclusion

Unit tests are an important part of modern software development. They help uncover flawed implementations – ones that could, for example, damage existing code – much faster.

Noel H. | Solution Engineer
Noel H. | Solution Engineer

Software Modernization

Legacy systems can be renewed step by step instead of through a risky full replacement. Devware has been doing this for mid-sized companies since 2004, ISO 27001 and ISO 9001 certified.
Pfeil-Icon – Mehr erfahren

Mehr zum Thema

Arrow pointing right (link)
Building an MCP Server in C#
09/2026

Building an MCP Server in C#

Blue arrow pointing right (link)
No items found.
No items found.
Compliance Evidence from Your Software Service Provider: What to Ask For
09/2026

Compliance Evidence from Your Software Service Provider: What to Ask For

Blue arrow pointing right (link)