Sitemap
iamprovidence

iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

Unit Tests

14 min readAug 17, 2025

--

Press enter or click to view image in full size

Most developers have at least heard of unit tests, so I won’t go over the basics again 😅. Still, this article should be helpful for junior developers looking to deepen their understanding, and even senior developers might pick up something new.

Today, we will cover what unit tests are, their key characteristics, what kind of code should be tested, modern naming conventions, the differences between AAA and Given-When-Then, Assert vs Expect syntax, Mocks vs Stubs, practical tips for writing good tests, various testing methodologies, and how unit tests fit into CI/CD pipelines.

There are also other articles on this topic you might find interesting:

Meanwhile, we are starting!

Understanding Unit Tests

Unit tests are small, focused tests that verify the behavior of individual components — usually functions or methods — in isolation.

[Test]
public void Add_ReturnsSumOfTwoNumbers()
{
var result = Math.Add(2, 3);

result.Should().Be(5);
}

They help ensure that each piece of code performs as expected, catching bugs early in the development process.

Unit Test Characteristics

A good unit test should satisfy the following key characteristics:

  • fast — unit tests should run quickly so they can be executed frequently (e.g., on every build or save)
  • deterministic — they should always return the same result for the same input, regardless of external state or timing
  • isolated — they test a single unit of code without relying on external systems (databases, file systems, APIs, etc.)
  • atomic — each test checks one small behavior; if it fails, the cause should be clear
  • independent — tests should not depend on each other; one test’s result should not affect another’s
Press enter or click to view image in full size

However, they also have drawbacks. Unit tests are often tied to implementation. This means that even a small change in implementation (e.g., logic reordering) can break tests, making refactoring painful.

What to Test?

Writing tests takes time and effort, so it’s important to be intentional about what you test.

Code can vary based on complexity and the number of dependencies. And, surprise, surprise, not everything has to be tested 😁.

Press enter or click to view image in full size

Let’s examine each category in more detail:

  • bottom-left corner — code with no complexity and no dependencies
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }

public string FullName => $"{FirstName} {LastName}";
}

This is usually simple getters/setters, constructors, etc.

Testing this offers little value and is typically not worth it at all.

  • bottom-right corner — code with no complexity but dependencies
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
public async Task Consume(ConsumeContext<OrderCreated> context)
{
_arderAppService(context.Message);
}
}

Those are controllers, API clients, commands, etc.

Unit testing this adds minimal confidence but requires mocking all the dependencies, making the test more brittle and artificial.

This kind of code is often better covered by integration tests.

  • top-right corner — code with complexity and dependencies
public class GodService
{
. . .

public GodService(
IOrderRepository orderRepository,
IEmailSender emailSender,
IPaymentProcessor paymentProcessor,
IInventoryService inventoryService,
IShippingService shippingService,
ICustomerRepository customerRepository,
ILogger logger,
IDiscountCalculator discountCalculator,
ITaxService taxService,
INotificationService notificationService)
{
_orderRepository = orderRepository;
_emailSender = emailSender;
_paymentProcessor = paymentProcessor;
_inventoryService = inventoryService;
_shippingService = shippingService;
_customerRepository = customerRepository;
_logger = logger;
_discountCalculator = discountCalculator;
_taxService = taxService;
_notificationService = notificationService;
}

public async Task HandleOrderAsync(Order order)
{
. . .
}
}

Also known as God-classes, chunky legacy services that require refactoring.

Writing tests here is what gave unit testing a bad reputation 😁. Although it is a good practice before refactoring, it often becomes messy and difficult when dealing with tightly coupled code.

You can still write them, but a better approach would be to rely on the integration tests. Extract smaller, focused classes and then write unit tests for those — making both refactoring and testing safer and cleaner.

  • top-left corner — code with complexity and no dependencies
public class Note
{
public int Id { get; private set; }
public string Text { get; private set; }
public DateTime UpdatedAt { get; private set; }

// business logic
public void ChangeText(string newText, DateTime updatedAtUtc)
{
if (string.IsNullOrWhiteSpace(newText))
{
throw new ArgumentNullException(nameof(newText));
}

if (updatedAtUtc.Kind != DateTimeKind.Utc)
{
throw new ArgumentException("The date must be in UTC.", nameof(updatedAtUtc));
}

this.Text = newText;
this.UpdatedAt = updatedAtUtc;
}
}

Those are the business logic, algorithms, data structures, etc.

This is where you should focus. Business rules, data transformations, if statements, and filters are prime candidates for unit testing. They provide the greatest value and have very few dependencies to impede refactoring.

Press enter or click to view image in full size

Naming Convention

Once you are ready to start writing unit tests, you should be aware of some conventions.

Typically, you would create a separate project for your tests that corresponds to the original module name and its structure [module].[TestType]Tests.csporj.

Press enter or click to view image in full size

A test class is usually named after the target class, like [TargetClass]Tests, or after individual methods, such as [TargetMethod]Tests, if the class is large.

Press enter or click to view image in full size

For the test’s name, there are two common approaches:

— technical naming

Press enter or click to view image in full size

It has all the information needed on the method name and in a structured manner.

[Test]
public void ChangeText_ShouldUpdateTextAndUpdatedAt_WhenValidInput() { . . . }

But if the method name is long, it can make the test name harder to scan quickly. Additionaly, it shifts the focus on implementation details instead of behavior.

However, the actual problem here is refactoring. As soon as you change the method name, you need to remember and rename all the tests 😨.

— human-readable naming

Using simple, plain English phrases is often more effective. They’re more expressive and flexible.

Press enter or click to view image in full size

This approach lets you describe system behavior, names are often meaningful even to domain experts, and it does not block you during refactoring.

[Test]
public void Updates_note_text_and_timestamp() { ... }

Developers often prefer the second naming style for business logic, as it feels more natural. But for algorithms or pure utility methods, the first approach works perfectly fine 🙃.

Unit Test Structure

A well-organized unit test typically follows a clear structure to ensure readability and maintainability.

The most common pattern is Arrange-Act-Assert (AAA):

Press enter or click to view image in full size

Where each section corresponds to:

  • Arrange — set up the necessary dependencies for the test
  • Act— execute the method or functionality you want to test
  • Assert — verify that the outcome matches the expected result

Adding comments identifying each section is a good practice to make your tests clearer, especially when they are large or complex. However, for small tests, you can skip them.

[Test]
public void Updates_note_text_and_timestamp()
{
// Arrange
var newText = "Updated note text";
var updatedAt = new DateTime(2020, 10, 30);

var note = new Note();

// Act
note.ChangeText(newText, updatedAt);

// Assert
Assert.Equal(newText, note.Text);
Assert.Equal(updatedAt, note.UpdatedAt);
}

You might have heard of the Given-When-Then pattern.

Press enter or click to view image in full size

The idea is the same, split the test into three parts: 

  • Given — set up the initial context or preconditions.
  • When — perform the action you want to test
  • Then — verify the expected outcome

Some people claim that it is more readable, especially for non-technical people:

[Test]
public void When_change_text_should_update_note_text_and_timestamp()
{
// Given
var newText = "Updated note text";
var updatedAt = new DateTime(2020, 10, 30);

var note = new Note();

// When
note.ChangeText(newText, updatedAt);

// Then
note.Text.Should().Be(newText);
note.UpdatedAt.Should().Be(updatedAt);
}

Still, unit tests are mostly for developers; therefore, the majority apply the AAA pattern😅.

Assert vs Expect

Assertions are a central part of unit testing — they confirm that the actual result matches the expected outcome.

Press enter or click to view image in full size

Different frameworks organize assertions differently, but two common styles are widely used: Assert syntax and Expect syntax.

— Assert syntax

This is the classic, procedural style used in frameworks like xUnit, NUnit, and MSTest.

Assert.AreEqual(expected, result);

Simple and direct, but it’s challenging to understand in complex assertion logic, and easy to accidentally swap the order of expected and actual values, leading to misleading error messages.

— Expect syntax

Some frameworks, like NUnit, support an Expect-like style:

Assert.That(result, Is.EqualTo(expected));

This reads more like a sentence, improving clarity and reducing the chance of argument mix-ups.

You can also use FluentAssertions, a popular library in C#, which makes tests even more expressive:

result.Should().Be(expected)

This style is highly readable and closer to natural language, making it great for business logic and domain-driven code.

Mock vs Stub

When testing a single class or method, you don’t want real dependencies (like databases, APIs, or services) interfering. Real dependencies are slow and unpredictable.

To ensure determinism, isolation, and speed in unit tests, real dependencies should be replaced with fake ones. This is typically done using mocks and stubs.

— mock

Press enter or click to view image in full size

A mock object simulates the behavior of specific methods under certain conditions.

The most popular mocking library is Moq.

Get iamprovidence’s stories in your inbox

Join Medium for free to get updates from this writer.

For example, you can create a mock object for the ITimeService interface, to set an arbitrary time in the tests:

[Test]
public void Create_note_with_text_and_timestamp()
{
// Arrange
// 👇 create the mock
Mock<ITimeService> mockTimeService = new Mock<ITimeService>();

// 👇 define how the mock should behave in each unit test
mockTimeService
.Setup(m => m.GetUtcNow())
.Returns(DateTime.Parse("2020/12/30"));

// 👇 get configured instance
ITimeService timeService = mockTimeService.Object);

var service = new NoteService(timeService); // 👈

// Act
var note = service.CreateNote("Test note");

// Assert
note.Text.Should().Be("Test note");
note.CreatedAt.Should().Be(DateTime.Parse("2020/12/30"))); // 👈 consistent behaviour
}

My favorite features of this NuGet are:

  • quickly create a mock without behaviourMock.Of<T>()
ITimeService timeService = Mock.Of<ITimeService>();
  • check that a method was called:
mock.Verify(m => m.GetUtcNow(), Times.Once);
  • set up a return for a method with specific parameters:
mock
.Setup(m => m.GetUtcNow("MyTimeZone")) // 👈
.Returns(DateTime.Parse("2020/12/30"));
  • use parameter matches:
mock
.Setup(m => m.GetUtcNow(It.IsAny<string>())) // 👈
.Returns(DateTime.Parse("2020/12/30"));

mock
.Setup(m => m.GetUtcNow(It.Is<string>(s => s.Length > 10))) // 👈
.Returns(DateTime.Parse("2020/12/30"));
  • return different values on consecutive calls:
mock
.SetupSequence(m => m.GetUtcNow())
.Returns(DateTime.Parse("2020/12/30"))
.Returns(DateTime.Parse("2021/01/01"));

Another popular mocking NuGet is NSubstitute:

// create mock
var calculator = Substitute.For<ICalculator>();

// set a return value
calculator.Add(1, 2).Returns(3);

The goal here is to provide a cleaner, more concise syntax with less boilerplate and fewer lambdas.

Ultimately, the choice of mocking library is up to you 🙃.

— stub

Press enter or click to view image in full size

A stub is a lightweight, fake implementation of a service that returns controlled responses for testing purposes.

Stubs are preferred over mocks if you need the same data across different tests, or configure complex behaviour that is hard to set up with mocks.

For example, you can implement a simple in-memory repository to use in your tests and avoid those monstrous .Setup() calls.

public class CustomerRepositoryStub : ICustomerRepository
{
public readonly Dictionary<int, Customer> _fakeData = new();

public void Add(Customer customer)
{
_fakeData[customer.Id] = customer;
}

public Customer? GetById(int id)
{
return _fakeData.GetValueOrDefault(id);
}
}

That later can be used in a test like that:

[Fact]
public async Task Register_new_customer()
{
// Arrange
var customer = new Customer
{
Id = 1,
Name = "John Doe",
};

var repo = new CustomerRepositoryStub(); // 👈

var customerService = new CustomerService(repo); // 👈

// Act
await customerService.RegisterNew(customer);

// Assert
repo._fakeData.Single().Should().Be("John Doe"); // 👈 verify a call
}

A stub provides controlled, predictable behavior — perfect for unit testing without involving a dependency.

— other test doubles *

This section is advanced, so you can skip it if you want 🙃.

The thing is, I lied to you in the previous sections about mocks and stubs 😅.

Testing terminology includes several terms to describe fake dependencies — and honestly, I think that’s just stupid 🙃.

I don’t see any point in inventing more entities and increasing terminological complexity for no real gain. Especially when the boundaries between these terms are blurry. What’s the purpose of all this overcomplication? None, really 😒.

Therefore, I have given you a simple distinction between mocks (imitate a behavior) and stubs (faked implementations). Moreover, this is how most developers understand them.

However, for those who are interested in academic terminologies, here is a proper overview of those terms.

A test double is a generic term for any object that stands in for a real dependency in a test.

Test doubles are grouped into two categories:

  • mocks verify outcoming calls
  • stubs provide data for incoming interactions

Here is a quick overview of each type:

  • mock — used to verify interactions: was a method called, with what arguments
messageBusMock
.Verify(mock => mock.Send("Hello"), Times.Once());
  • spy — intercept and record outgoing calls
// does not send anything
// just stores all sent messages in memory
public class MessageBusSpy : IMessageBus
{
public List<string> _sentMessages = new ();

public void Send(string message)
{
_sentMessages.Add(message);
}
}
  • stub — provides specific return values depending on the test case (different tests, different data)
var customerRepositoryStub = Substitute.For<ICustomerRepository>();

customerRepositoryStub
.GetAll(1)
.Returns(new User("John"));
  • dummy — does not have any behaviour. Usually, they are just used to fill parameter lists
public class CustomerRepositoryDummy : ICustomerRepository
{
public List<Customers> GetAll()
{
return [];
}
}

// or

ICustomerRepository customerRepositoryDummy = Mock.Of<ICustomerRepository>();
  • fake — a working implementation with simplified logic (e.g., stores data in memory instead of a database)
public class CustomerRepositoryFake : ICustomerRepository
{
public List<Customers> _data = [
new Customer("John"),
new Customer("Sam"),
];

public List<Customers> GetAll()
{
return _data;
}
}

This means that the good old mocks that developers write are in fact mock-stubs, while stubs are spy-fakes 😅.

Reduce Repetition in the Arrange Section

While the Act section is usually a single line, and the Assert is unique to each test, the Arrange often contains repeated setup code across multiple tests.

To avoid code duplication in the arrange section of your tests, consider using data-driven tests, shared setup logic, and the builder pattern.

Data-driven tests let you run the same test logic with different inputs and expected results, reducing repetitive code.

[TestCase(2, 3, 5)]
[TestCase(3, 2, 5)]
[TestCase(5, 3, 8)]
public void Add_ReturnsSumOfTwoNumbers(int n1, int n2, int expected)
{
var result = Math.Add(n1, n2);

result.Should().Be(expected);
}

. . .


[TestCaseSource(nameof(SubtractTestCases))]
public int Subtract_ReturnsCorrectDifference(int a, int b)
{
return Math.Subtract(a, b);
}

public static IEnumerable<TestCaseData> SubtractTestCases()
{
yield return new TestCaseData(5, 3).Returns(2).SetName("5 minus 3 is 2");
yield return new TestCaseData(10, 4).Returns(6).SetName("10 minus 4 is 6");
yield return new TestCaseData(0, 0).Returns(0).SetName("0 minus 0 is 0");
}

Complex dependencies can be set up, shared, and reused across multiple tests.

[TestFixture]
public class NoteServiceTests
{
private readonly Mock<ITimeService> _timeService;

private readonly NoteService _service;

// 👇 run before every test
[SetUp]
public void Setup()
{
_timeService= new Mock<ITimeService>();

_service = new NoteService(_timeService.Object);

_timeService
.Setup(m => m.GetUtcNow())
.Returns(DateTime.Parse("2020/12/30"));
}

[Test]
public void Create_note_with_text_and_timestamp()
{
// Arrange
// Act
var note = _service.CreateNote("Test note");

// Assert
note.Text.Should().Be("Test note");
note.CreatedAt.Should().Be(DateTime.Parse("2020/12/30"));
}

// other tests that reuse the same setup
}

There are other ways to handle setup (per class, per assembly). In general, different testing frameworks offer similar functionality but use different attributes or conventions to achieve it.

Press enter or click to view image in full size

When you Arrange section is bloated by complex object creation, you can make use of the builder pattern:

[Test]
public void Note_should_be_marked_as_important()
{
// Given
var note = new NoteBuilder() // 👈
.WithTitle("Urgent task")
.WithContent("Finish the report by EOD")
.Build();

// When
_noteService.MarkAsImportant(note);

// Then
note.IsImportant.Should().BeTrue();
}

It not only avoids clutter in tests but also improves readability and provides an easy way to construct entities.

Keep Tests Focused

To keep your tests readable and maintainable, follow a few simple guidelines. These practices help reduce complexity and make failures easier to diagnose.

— avoid multiple Arrange, Act, and Assert sections in one test

Tests should test a single behaviour. Having multiple Arrange-Act-Assert likely means the test is doing too much and should be split into separate tests.

— avoid if statements in tests

Conditional logic inside tests adds complexity and ambiguity. If you need if statements, it's often a sign that the test is trying to cover multiple scenarios at once. Use separate test cases instead.

— avoid too many assertions

It is acceptable to have multiple Assert statements in the test. Just make sure they all relate to the same behavior. If they check unrelated things, the test becomes unclear and harder to debug, especially when it fails.

Testing Methodologies

Not all testing methodologies are equally effective in every context. Some approaches excel at verifying low-level logic, while others are better suited for system-wide validation or collaboration with stakeholders.

Let’s have a look at how different testing styles — Test Last, TDD, Snapshot Testing, and BDD — fit (or don’t fit) within the scope of unit testing.

— Test Last Development 🟢

Writing tests after you core logic tends to be a good approach.

Press enter or click to view image in full size

By writing the code first, you get a clearer picture of the necessary services, their responsibilities, and how to structure dependencies. This makes it easier to design meaningful tests without premature abstractions or guesswork.

— TDD (Test Driven Development) 🟠

The effectiveness of unit tests heavily depends on the context in which you want to apply TDD.

For utility functions, algorithms, and data structures, TDD shines — these components have clear input-output behavior.

Press enter or click to view image in full size

However, applying TDD to business logic and application logic is more problematic. At the early design stage, it’s difficult to predict which services you’ll need, what their interfaces will look like, or how to effectively mock their behavior. As a result, the TDD is not applicable.

— Snapshot Tests 🔴

Snapshot tests are usually a poor fit for unit tests.

Press enter or click to view image in full size

They often produce brittle tests that fail due to minor, irrelevant changes. Over time, they can lead to blindly approving changes just to keep the tests green, reducing their value and trustworthiness.

— BDD (Behaviour Driven Development) 🔴

For unit testing, BDD can be overkill.

Press enter or click to view image in full size

Writing detailed behavior specs for small methods often adds unnecessary complexity without much benefit. In unit tests, clarity and speed matter more than narrative structure.

CI/CD

Unit tests are the foundation of reliable code. They execute quickly, and even a large number of unit tests can run in a short time.

Most developers run unit tests locally before pushing changes. Some even configure git hooks or triggers in their IDE.

In CI/CD, unit tests should be executed on every pull request.

Press enter or click to view image in full size

They’re fast, provide instant feedback, and help catch regressions early.

Summary

I could write another paragraph, but you’re probably tired already 😅. So here are the key points instead:

  • don’t write unit tests for overly complex or messy code — clean it first. Focus tests on business logic, algorithms, and utility methods
  • use clear, plain-English names for test methods
  • apply Arrange-Act-Assert pattern, or Given-When-Then if you prefer
  • use fluent assertions with Expect (or your framework’s equivalent) for readability
  • avoid having multiple Arrange, Act, or Assert sections in a single test
  • reduce repetition in the Arrange step with shared setup, data-driven tests, or the builder pattern
  • don’t use if statements in your tests
  • keep assertions focused — too many in one test can muddy the intent

And remember, unit tests should actually test things, and not just exist to pass coverage scan 😁.

Press enter or click to view image in full size

I hope you learned something new here 🤓. If so, let me know 💬

If you enjoyed the article, please give it a clap👏. It helps to boost my self-esteem 😅

Support me with a link below☕️

Don’t forget to follow for more content on tests 😉

In a while, crocodile 🐊

--

--

iamprovidence
iamprovidence

Written by iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence