Lesson 38 of 45 intermediate

Unit Testing

Check one behaviour fast, with no database, so a failure points at one place

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

What makes a test a unit test rather than an integration test?

Open interactive version (quiz + challenge)

Real-world analogy

A car factory tests each spark plug on a bench before it goes into an engine. On the bench, a socket stands in for the engine, so a failure can only mean the plug is bad. Inside a running car, a failure could be the plug, the fuel or the driver. The bench is the unit test, the plug is one class, and the socket is the fake dependency.

What is it?

A unit test runs a small piece of your code in isolation and checks one result. In .NET you usually write them with xUnit: a method marked [Fact] or [Theory] that arranges inputs, acts, and asserts. Dependencies such as a repository are replaced with fakes or mocks. That works because the class receives them through its constructor. Unit tests are fast and cheap, so you can have hundreds. They cannot prove that the real database or HTTP pipeline works. That is what integration tests are for.

Real-world relevance

A mid-level developer changes the discount rule in OrderService and runs 300 unit tests in four seconds before pushing. One fails with a clear name, DiscountIsNotApplied_WhenCouponExpired, and the fix takes a minute. If those checks needed a database, the suite would take minutes, and people would stop running it. The usual trap goes the other way: tests that mock every line, and pass while the real code is broken.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

When is [Theory] better than [Fact]?

You want to test OrderService without a database. What lets you do that?

Code example

public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> repo = new();
    private readonly OrderService service;

    public OrderServiceTests() => service = new OrderService(repo.Object);

    [Fact]
    public async Task PlaceOrder_SavesOrder_WhenLinesAreValid()
    {
        var request = new PlaceOrderRequest("c-1", new List<OrderLine> { new("sku-1", 2) });

        var result = await service.PlaceAsync(request);

        Assert.True(result.Succeeded);
        repo.Verify(r => r.AddAsync(It.Is<Order>(o => o.CustomerId == "c-1"),
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-3)]
    public async Task PlaceOrder_Fails_WhenQuantityIsNotPositive(int quantity)
    {
        var request = new PlaceOrderRequest("c-1", new List<OrderLine> { new("sku-1", quantity) });

        var result = await service.PlaceAsync(request);

        Assert.False(result.Succeeded);
        repo.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Never);
    }
}

Line-by-line walkthrough

  1. 1. This is a fragment: OrderService, IOrderRepository and the request types are assumed to exist. The test class creates a Moq mock of the repository.
  2. 2. The constructor builds OrderService with the mock object, so no database is involved.
  3. 3. The first test is a [Fact]. It arranges a request with one valid line, calls PlaceAsync once, and asserts that the result succeeded.
  4. 4. Verify then checks that the service asked the repository to add an order for customer c-1, exactly once. This is the side effect we care about.
  5. 5. The second test is a [Theory] with two [InlineData] rows. The same test runs once with 0 and once with -3.
  6. 6. It asserts the result failed, and that AddAsync was never called, so a bad order is not saved.

Spot the bug

[Fact]
public async Task GetOrder_ReturnsOrder()
{
    var order = new Order { Id = 1 };
    repo.Setup(r => r.FindAsync(1)).ReturnsAsync(order);

    var result = await repo.Object.FindAsync(1);

    Assert.Equal(order, result);
}
Need a hint?
Which object does the test actually call?
Show answer
The test calls the mock itself, so it only checks that the mock returns what it was told to return. OrderService is never run, so the test can never fail. Fix: call the service, for example service.GetAsync(1), and assert on that result.

Explain like I'm 5

Before you put a toy together, you press each small piece to check it works on its own. If it breaks later, you already know the piece was fine. You test the pieces alone and fast so you find the broken one quickly.

Fun fact

Bill Wake named the Arrange, Act, Assert layout in 2001. Many teams still mark the three parts with blank lines. A test that is hard to split into those three parts is often testing too much.

Hands-on challenge

Say it out loud in 30 seconds: "How do you unit test a service that depends on a database repository?" Then check: did you mention an interface injected through the constructor, a fake or mock in the test, Arrange-Act-Assert, and that the test asserts on the result and not on the mock it just set up?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook