The previous lesson decoupled Library from any concrete persistence mechanism, injecting ILibraryRepository instead of coupling it to SQLite, JSON, or Entity Framework Core. It mentioned, in passing, that this would open the door to testing Library without touching any real file or database. This lesson delivers on that promise: it introduces unit testing, code that automatically verifies other code behaves as expected, and applies it to Loan.RegisterReturn() and to Library.LendBookAsync, replacing ILibraryRepository with a test double that needs no real database. With this in place, any future change to BiblioTech — including the refactoring in this module's last lesson — can be verified in seconds, repeatably, instead of being checked by hand every time.

Contents

  1. What a unit test is and why it matters
  2. xUnit: installation and alternatives (MSTest, NUnit)
  3. The Arrange-Act-Assert structure
  4. [Fact] and [Theory] with [InlineData]
  5. Test doubles: mocking ILibraryRepository with Moq
  6. Code coverage: what it measures and its limits
  7. Example: tests for Loan.RegisterReturn()
  8. Example: tests for Library.LendBookAsync with a mocked repository

  1. What a unit test is and why it matters

A unit test is a piece of code that runs a small, isolated unit of your program — usually a single method — and automatically checks that its result is the expected one, with no manual intervention. The key word is "automatically": instead of running BiblioTech by hand, making a loan from the console, and looking at whether the message seems right, a unit test does exactly that same thing in code, and fails loudly if the result doesn't match what's expected.

Without unit tests With unit tests
Verifying a change means running the whole application by hand Verifying a change means running a set of tests in seconds
A bug introduced by a change is discovered, if at all, much later A bug is discovered on the spot, before it reaches production
Refactoring (Lesson 5) is scary: will everything still work the same? Refactoring is safe: existing tests confirm behavior hasn't changed
Repeating the same manual check over and over is tedious The same checks run, identically, as many times as needed

Unit tests don't replace other forms of testing (integration, manual, end-user acceptance), but they're the fastest and cheapest foundation to build: they run in milliseconds, with no dependency on a database, a network, or a graphical interface.

  1. xUnit: installation and alternatives (MSTest, NUnit)

xUnit is one of the most widely used testing frameworks in the modern .NET ecosystem (in fact, it's the one the ASP.NET Core team itself uses to test its own code). An xUnit test project is created as an independent .csproj project, separate from the main project:

dotnet new xunit -n BiblioTech.Tests
cd BiblioTech.Tests
dotnet add reference ../BiblioTech/BiblioTech.csproj

dotnet add reference adds a reference to BiblioTech's main project, so the test project can use its classes (Library, Loan, ILibraryRepository...). There are two alternatives to xUnit, with a very similar philosophy and only syntax differences:

Framework Test attribute Origin
xUnit (used in this lesson) [Fact] / [Theory] The most popular choice in modern, open-source .NET projects
MSTest [TestMethod] Microsoft's own testing framework, integrated into Visual Studio from the start
NUnit [Test] One of the oldest testing frameworks in .NET, inspired by Java's JUnit

This lesson focuses on xUnit as the most widespread choice for new projects, but the concepts (Arrange-Act-Assert, test doubles) apply exactly the same way with any of the three.

  1. The Arrange-Act-Assert structure

Almost any well-written unit test follows the same three-step pattern, known as Arrange-Act-Assert:

[Fact]
public void RegisterReturn_SetsReturnDate()
{
    // Arrange: prepare the data and objects the test needs
    Book book = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
    Member member = new Member(1, "Ana Martinez");
    book.Lend();
    Loan loan = new Loan(book, member);

    // Act: execute the specific action being tested
    loan.RegisterReturn();

    // Assert: check that the result is the expected one
    Assert.NotNull(loan.ReturnDate);
}
  • Arrange: builds the starting state — here, a lent Book and a freshly created Loan, not yet returned.
  • Act: calls the single method being tested — here, RegisterReturn(); if this section has more than one line, it's usually a sign the test is trying to verify too many things at once.
  • Assert: checks, using xUnit's Assert class methods (Assert.Equal, Assert.True, Assert.NotNull, Assert.Throws...), that the result obtained matches the expected one.

This split into three blocks, even though it might look like just a formatting convention, makes any test read the same way across the whole project — the same consistency benefit seen in Lesson 1 of this module, now applied to tests.

  1. [Fact] and [Theory] with [InlineData]

xUnit distinguishes two kinds of test methods:

  • [Fact]: a test with a single input, with no parameters — like the one in the previous section.
  • [Theory]: a parameterized test, run several times with different sets of values, each declared with [InlineData(...)].
[Theory]
[InlineData(0, 0)]    // no delay, no penalty
[InlineData(5, 0)]    // 5 days on loan, within the allowed period (14 days), no penalty
[InlineData(20, 2.00)] // 20 days, 6 days over the 14-day limit: fixed penalty of 2.00
public void CalculatePenalty_WithFixedPenalty_ReturnsExpectedValue(int daysUntilReturn, decimal expectedPenalty)
{
    // Arrange
    Book book = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
    Member member = new Member(1, "Ana Martinez");
    book.Lend();
    Loan loan = new Loan(book, member);

    // Act: the passage of time is simulated directly on the calculation (see Common Mistakes)
    loan.RegisterReturn();
    decimal penalty = loan.CalculatePenalty(new FixedPenalty());

    // Assert
    Assert.True(penalty >= 0); // exercise 1 completes a more precise version of this test
}

Each [InlineData(...)] line generates one independent run of the same test method, with those specific values as arguments (daysUntilReturn, expectedPenalty). Instead of writing three near-identical [Fact] methods — one per input/output combination — [Theory] expresses them as data, much more compact and easy to extend with one more case.

  1. Test doubles: mocking ILibraryRepository with Moq

A test double is an object that stands in, during a test, for a real dependency — a database, an external service, the file system — so that the test runs fast, in isolation, and with no dependency on anything external. Moq is the most widely used library in .NET for creating mocks, the most flexible type of test double: an object that implements an interface (like ILibraryRepository, extracted in the previous lesson) with no real logic behind it, whose behavior is configured line by line right inside the test.

dotnet add package Moq
using Moq;

Mock<ILibraryRepository> mockRepository = new Mock<ILibraryRepository>();

// Configures the mock's behavior when SaveCatalog is called with any list
mockRepository
    .Setup(repo => repo.SaveCatalog(It.IsAny<List<LibraryItem>>()))
    .Verifiable(); // marks this call so it can be verified afterward

ILibraryRepository fakeRepository = mockRepository.Object; // the object injected into Library

// ... use fakeRepository as if it were a real ILibraryRepository ...

mockRepository.Verify(repo => repo.SaveCatalog(It.IsAny<List<LibraryItem>>()), Times.Once);
// Checks that SaveCatalog was called exactly once, with no real file or database touched

Mock<ILibraryRepository> creates an object that fulfills the interface's contract with no real implementation behind it; .Setup(...) defines what should happen when a specific method is called (here, simply accepting it, doing nothing else); It.IsAny<...>() accepts any value of that type as an argument, without requiring a specific one; .Verify(...) checks, at the end of the test, that an expected call actually happened. This is the practical reason Lesson 3 extracted ILibraryRepository: without that interface, there would be nothing to replace with a mock, and testing Library would always require a real database.

  1. Code coverage: what it measures and its limits

Code coverage measures what percentage of the production code's lines (or branches) run when the test suite executes. Tools like Coverlet (usable via dotnet test --collect:"XPlat Code Coverage") generate a report with that percentage. It's a useful metric for spotting areas of code that are completely untested, but it has an important limit worth being clear about from the start: high coverage doesn't guarantee quality tests. A method can run inside a test (counting as "covered") without that test actually checking anything relevant with Assert. Coverage is an indicator of what's missing testing, not a goal to maximize for its own sake.

  1. Example: tests for Loan.RegisterReturn()

A more complete set of tests for RegisterReturn(), covering the normal case and an edge case:

public class LoanTests
{
    [Fact]
    public void RegisterReturn_SetsReturnDate()
    {
        // Arrange
        Book book = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
        Member member = new Member(1, "Ana Martinez");
        book.Lend();
        Loan loan = new Loan(book, member);

        // Act
        loan.RegisterReturn();

        // Assert
        Assert.NotNull(loan.ReturnDate);
    }

    [Fact]
    public void ReturnDate_BeforeReturning_IsNull()
    {
        // Arrange
        Book book = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
        Member member = new Member(1, "Ana Martinez");
        book.Lend();
        Loan loan = new Loan(book, member);

        // Act: RegisterReturn() is never called

        // Assert
        Assert.Null(loan.ReturnDate);
    }
}

The second test explicitly checks the initial state (ReturnDate is null while it hasn't been returned yet), a case just as important as the first test's "happy path": it verifies that Loan doesn't mistakenly assign a return date prematurely.

  1. Example: tests for Library.LendBookAsync with a mocked repository

Picking Library back up from the previous lesson, LendBookAsync is extended so that, after registering the loan, it persists the catalog's new state through ILibraryRepository:

class Library
{
    private readonly ILibraryRepository _repository;

    public List<LibraryItem> Catalog { get; } = new List<LibraryItem>();
    public List<Loan> Loans { get; } = new List<Loan>();

    public event Action<Loan>? LoanRegistered;

    public Library(ILibraryRepository repository)
    {
        _repository = repository;
    }

    public void RegisterLoan(Loan loan)
    {
        Loans.Add(loan);
        LoanRegistered?.Invoke(loan);
    }

    public async Task LendBookAsync(Book book, Member member)
    {
        await Task.Delay(1000); // simulating a slow check, Module 4

        if (!book.Available)
        {
            throw new InvalidOperationException($"'{book.Title}' is not available for loan.");
        }

        book.Lend();
        RegisterLoan(new Loan(book, member));

        _repository.SaveCatalog(Catalog); // persists the new state after the loan
    }
}

Tests for this method, replacing ILibraryRepository with a Moq mock:

public class LibraryTests
{
    [Fact]
    public async Task LendBookAsync_WithAvailableBook_SavesCatalog()
    {
        // Arrange
        Mock<ILibraryRepository> mockRepository = new Mock<ILibraryRepository>();
        Library library = new Library(mockRepository.Object);

        Book book = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
        Member member = new Member(1, "Ana Martinez");

        // Act
        await library.LendBookAsync(book, member);

        // Assert
        Assert.False(book.Available);
        Assert.Single(library.Loans);
        mockRepository.Verify(
            repo => repo.SaveCatalog(It.IsAny<List<LibraryItem>>()),
            Times.Once); // the catalog was saved exactly once, with no real database touched
    }

    [Fact]
    public async Task LendBookAsync_WithUnavailableBook_ThrowsAndDoesNotSave()
    {
        // Arrange
        Mock<ILibraryRepository> mockRepository = new Mock<ILibraryRepository>();
        Library library = new Library(mockRepository.Object);

        Book book = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
        book.Lend(); // already lent beforehand
        Member member = new Member(1, "Ana Martinez");

        // Act + Assert: Assert.ThrowsAsync runs the action and checks the expected exception
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => library.LendBookAsync(book, member));

        mockRepository.Verify(
            repo => repo.SaveCatalog(It.IsAny<List<LibraryItem>>()),
            Times.Never); // if the loan failed, a save should never have been attempted
    }
}

Neither of these two tests touches a file, a real SQLite database, or a real LibraryDbContext: mockRepository.Object is a fake ILibraryRepository that only records which calls it received, letting you check with Times.Once/Times.Never exactly how many times SaveCatalog was invoked. The second test, moreover, verifies something just as important as the success case: that a failed loan does not trigger an improper catalog save.

Common Mistakes and Tips

  • Testing several unrelated things in a single test: if a test method has several Act blocks followed by several unrelated Asserts, split it into independent tests; when a test fails, its name should be enough to know exactly what broke.
  • Depending on real wait times in tests (like LendBookAsync's real Task.Delay(1000)): in a real project, that wait would be replaced with an injectable time abstraction (out of scope for this lesson); here, simply assume the tests in this section do, in fact, take around a second each.
  • Mocking a concrete class instead of an interface: Moq can create mocks of classes with virtual members, but it's much simpler and more common to mock interfaces — another practical reason to prefer interfaces (ILibraryRepository) at a design's extension points, as you already saw in Lesson 3.
  • Chasing 100% code coverage as a goal in itself: it's more valuable to have 80% coverage with tests that verify real behaviors than 100% with empty tests that just run code without checking anything relevant.

Exercises

  1. Complete the parameterized test CalculatePenalty_WithFixedPenalty_ReturnsExpectedValue from section 4 so it checks, with Assert.Equal, the exact expected value in each case (you'll need to simulate the passage of days somehow — for example, by building Loan with a loan date already in the past if your implementation allows it, or documenting the limitation if it doesn't).

  2. Write a [Fact] test that checks Library.RegisterLoan raises the LoanRegistered event (use a local variable captured by the subscribed lambda to check that it was invoked).

Solutions

[Theory]
[InlineData(0, 0)]
[InlineData(2, 4.00)] // 2 days late over the 14 allowed, FixedPenalty charges a flat 2.00
public void CalculatePenalty_WithFixedPenalty_ReturnsExpectedValue(int daysLate, decimal expectedPenalty)
{
    // Note: this version assumes a test helper method that lets LoanDate be set manually;
    // documenting this limitation is also part of writing good tests.
    decimal calculatedPenalty = new FixedPenalty().CalculatePenalty(daysLate);
    Assert.Equal(expectedPenalty, calculatedPenalty);
}
[Fact]
public void RegisterLoan_RaisesLoanRegisteredEvent()
{
    // Arrange
    Mock<ILibraryRepository> mockRepository = new Mock<ILibraryRepository>();
    Library library = new Library(mockRepository.Object);

    bool eventRaised = false;
    library.LoanRegistered += _ => eventRaised = true;

    Book book = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
    Member member = new Member(1, "Ana Martinez");
    book.Lend();

    // Act
    library.RegisterLoan(new Loan(book, member));

    // Assert
    Assert.True(eventRaised);
}

Conclusion

In this lesson you've learned what a unit test is and why it gives you the confidence to change code without fear, used xUnit with the Arrange-Act-Assert structure, [Fact] and [Theory] with [InlineData], and replaced ILibraryRepository with a Moq mock to test Library.LendBookAsync quickly and in isolation, with no real database — the concrete benefit of having extracted that interface in the previous lesson. You've also seen what code coverage measures and why it shouldn't be chased as an end in itself.

This module's last lesson, Code Review and Refactoring, builds directly on what you've learned here: the unit tests you just wrote will be the safety net that lets you refactor a long Library method with confidence, checking at every step that its behavior hasn't changed.

© Copyright 2026. All rights reserved