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
- What a unit test is and why it matters
xUnit: installation and alternatives (MSTest,NUnit)- The Arrange-Act-Assert structure
[Fact]and[Theory]with[InlineData]- Test doubles: mocking
ILibraryRepositorywith Moq - Code coverage: what it measures and its limits
- Example: tests for
Loan.RegisterReturn() - Example: tests for
Library.LendBookAsyncwith a mocked repository
- 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.
xUnit: installation and alternatives (MSTest, NUnit)
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.csprojdotnet 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.
- 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
Bookand a freshly createdLoan, 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
Assertclass 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.
[Fact] and [Theory] with [InlineData]
[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.
- Test doubles: mocking
ILibraryRepository with Moq
ILibraryRepository with MoqA 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.
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 touchedMock<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.
- 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.
- Example: tests for
Loan.RegisterReturn()
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.
- Example: tests for
Library.LendBookAsync with a mocked repository
Library.LendBookAsync with a mocked repositoryPicking 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
Actblocks followed by several unrelatedAsserts, 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 realTask.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
virtualmembers, 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
-
Complete the parameterized test
CalculatePenalty_WithFixedPenalty_ReturnsExpectedValuefrom section 4 so it checks, withAssert.Equal, the exact expected value in each case (you'll need to simulate the passage of days somehow — for example, by buildingLoanwith a loan date already in the past if your implementation allows it, or documenting the limitation if it doesn't). -
Write a
[Fact]test that checksLibrary.RegisterLoanraises theLoanRegisteredevent (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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
