The Repository Pattern post said, more than once, that BookReservationService was "genuinely unit-testable with a mock," because it depends on IBookRepository, an abstraction, rather than a concrete class talking directly to a real database. That claim was never actually backed up with a real test. This post keeps that promise properly, using the exact same Library Book Reservation example, explaining every concept along the way rather than assuming familiarity with testing at all.
The Problem Unit Testing Actually Solves
BookReservationService depends on IBookRepository and INotificationSender. In production, those are backed by a real EF Core repository talking to a real SQL Server database, and a real email-sending service. If you wanted to test BookReservationService's actual logic, does it correctly reserve a book, does it correctly send a notification only when reservation succeeds, testing it against the real versions would mean a real database has to exist and be reachable, test data has to be manually set up and cleaned up afterward, and a real email might actually get sent every time the test runs. This is slow, fragile, and genuinely unpleasant to run repeatedly.
Think of testing a car's dashboard warning lights by actually driving the car empty of oil until the engine seizes, versus using a diagnostic bench that can simulate "oil level: critically low" as an input and simply checking whether the warning light turns on. The bench doesn't need a real engine at all, it just needs to convincingly send the right signal.
What a Mock Actually Is
A mock is a fake object that implements the same interface as a real dependency, but where you, the person writing the test, control exactly what it returns for any given input, without any real database, network call, or external system involved at all.
// The REAL implementation - used in production
public class BookRepository : IBookRepository
{
// ... talks to a real LibraryDbContext, a real database
}
// A MOCK - used only in tests, created automatically
// by the Moq library, never hand-written by you
var mockRepository = new Mock<IBookRepository>();
// YOU tell the mock exactly what to return when a
// specific method is called with specific arguments
mockRepository
.Setup(repo => repo.GetByIdAsync(1))
.ReturnsAsync(new Book { Id = 1, Title = "Test Book", IsAvailable = true });
// From BookReservationService's perspective, this mock
// IS a perfectly valid IBookRepository - it has no way
// to tell the difference between this and a real one
This is the exact payoff of Dependency Inversion from the earlier post: because BookReservationService's constructor only asks for the interface, a test can hand it a completely fake implementation, and the service has no way to know or care.
Setting Up an xUnit Test Project
xUnit is the testing framework, the tool that actually discovers your test methods, runs them, and reports pass or fail. Tests live in a separate project from your main application.
# Creating the test project (terminal / VS Code)
dotnet new xunit -n LibrarySystem.Tests
cd LibrarySystem.Tests
dotnet add reference ../LibrarySystem/LibrarySystem.csproj
dotnet add package Moq
// A minimal first test - [Fact] marks a method as
// a single, standalone test case
public class BookReservationServiceTests
{
[Fact]
public void ExampleTest()
{
Assert.True(1 + 1 == 2);
}
}
# Running tests
dotnet test
[Fact] tells xUnit "this method is a test, run it." Assert.True(...), and its many siblings, Assert.Equal, Assert.False, Assert.Null, is how a test states what it actually expects to be true, if the assertion fails, the test fails, and xUnit reports exactly which one and why.
The Arrange-Act-Assert Pattern
Nearly every well-written unit test follows the same three-part shape, and naming it explicitly makes tests dramatically easier to read and write consistently.
[Fact]
public async Task ReserveBookAsync_ReturnsTrue_WhenBookIsAvailable()
{
// ARRANGE - set up the mocks and the object being tested
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(true);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(
mockRepository.Object,
mockNotifier.Object
);
// ACT - call the ONE method actually being tested
var result = await service.ReserveBookAsync(1, "member@example.com");
// ASSERT - check the outcome matches what was expected
Assert.True(result);
}
Think of a science experiment's structure: set up the conditions (Arrange), run the actual experiment (Act), record and check the result against the hypothesis (Assert). Separating these three phases clearly is what makes a test readable at a glance, rather than a tangled block where setup, execution, and checking are all mixed together.
Note the naming convention: MethodName_ExpectedBehavior_Condition. ReserveBookAsync_ReturnsTrue_WhenBookIsAvailable reads almost like a sentence, and immediately tells you what broke just from the test name in a failure report, without needing to open the test method itself.
Verify: Checking That Something Happened, Not Just What Was Returned
Assert checks a return value. Verify, a Moq-specific feature, checks that a specific method on a mock was actually called, which matters when the thing you care about isn't a return value, but a side effect.
[Fact]
public async Task ReserveBookAsync_SendsNotification_WhenReservationSucceeds()
{
// Arrange
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(true);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(
mockRepository.Object,
mockNotifier.Object
);
// Act
await service.ReserveBookAsync(1, "member@example.com");
// Assert - VERIFY that SendAsync was actually called,
// with the correct recipient, EXACTLY once
mockNotifier.Verify(
n => n.SendAsync("member@example.com", It.IsAny<string>()),
Times.Once
);
}
// It.IsAny<string>() means "don't care about the exact
// message text, just confirm SendAsync was called with
// this specific recipient" - useful when the precise
// wording isn't what the test is actually checking
This test would fail if BookReservationService's ReserveBookAsync method forgot to call the notifier at all, even though the reservation itself might still correctly return true. Assert alone wouldn't have caught that; Verify specifically checks that the interaction actually happened.
Testing the Failure Path: What Should NOT Happen
A genuinely thorough test suite checks the failure case just as carefully as the success case, specifically confirming that something that shouldn't happen, doesn't.
[Fact]
public async Task ReserveBookAsync_ReturnsFalse_WhenBookIsNotAvailable()
{
// Arrange - the mock is configured to simulate
// a book that's already reserved
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(false);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(
mockRepository.Object,
mockNotifier.Object
);
// Act
var result = await service.ReserveBookAsync(1, "member@example.com");
// Assert - the reservation correctly failed
Assert.False(result);
// AND, just as importantly - confirm the notification
// was NEVER sent, since there's nothing to notify about
mockNotifier.Verify(
n => n.SendAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
This second assertion, Times.Never, is exactly the kind of check that catches a real bug: if someone later modifies BookReservationService and accidentally moves the notification call outside the if (success) block, this test fails immediately, loudly, and specifically, rather than the bug quietly reaching production where a member gets a confusing "reservation confirmed" email for a book they didn't actually get.
[Theory] and [InlineData]: One Test, Many Inputs
Writing a nearly-identical test method for every input value you want to check is repetitive. [Theory] combined with [InlineData] runs the same test logic once per data row, keeping the test method itself written only once.
[Theory]
[InlineData(1, "member@example.com", true)]
[InlineData(2, "another@example.com", true)]
[InlineData(999, "member@example.com", false)]
public async Task ReserveBookAsync_ReturnsExpectedResult(
int bookId, string email, bool repositoryReturns)
{
// Arrange
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(bookId, email))
.ReturnsAsync(repositoryReturns);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(mockRepository.Object, mockNotifier.Object);
// Act
var result = await service.ReserveBookAsync(bookId, email);
// Assert
Assert.Equal(repositoryReturns, result);
}
// xUnit runs this ONE method three times, once per
// [InlineData] row, reporting each as its own separate
// pass/fail result - not one combined test
Think of a single, reusable form with blank fields, filled out three different ways, rather than writing three entirely separate forms from scratch that happen to ask the same questions.
What NOT to Unit Test: The Distinction That Matters in an Interview
BookReservationService is unit-tested, with mocks, exactly as shown above. The real BookRepository, the class that actually talks to EF Core and a real database, is not tested this same way. Testing that class against a real (or realistic, disposable) database is called an integration test, a genuinely different kind of test with a different purpose.
A unit test tests one class's logic in isolation, every dependency is mocked, it's fast, milliseconds, no real database, no real network. Example: does BookReservationService call the notifier correctly when a reservation succeeds.
An integration test confirms that multiple real pieces work together correctly, often using a real (or realistic, temporary) database, it's slower, but catches problems mocks physically cannot, a wrong SQL query, a broken EF Core mapping, a real connection string issue. Example: does BookRepository.ReserveAsync actually update the correct row in a real database.
Why this distinction gets asked about directly: a common interview question is some version of "would you unit test your repository class?" The genuinely correct answer is no, not with mocks, because there's nothing left to fake once you're already testing the thing that talks to the real database. That class gets validated through an integration test instead, often using a real test database or an in-memory database provider specifically built for this purpose.
The Complete Test Class
public class BookReservationServiceTests
{
[Fact]
public async Task ReserveBookAsync_ReturnsTrue_WhenBookIsAvailable()
{
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(true);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(mockRepository.Object, mockNotifier.Object);
var result = await service.ReserveBookAsync(1, "member@example.com");
Assert.True(result);
}
[Fact]
public async Task ReserveBookAsync_SendsNotification_WhenReservationSucceeds()
{
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(true);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(mockRepository.Object, mockNotifier.Object);
await service.ReserveBookAsync(1, "member@example.com");
mockNotifier.Verify(
n => n.SendAsync("member@example.com", It.IsAny<string>()),
Times.Once
);
}
[Fact]
public async Task ReserveBookAsync_ReturnsFalse_WhenBookIsNotAvailable()
{
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(1, "member@example.com"))
.ReturnsAsync(false);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(mockRepository.Object, mockNotifier.Object);
var result = await service.ReserveBookAsync(1, "member@example.com");
Assert.False(result);
mockNotifier.Verify(
n => n.SendAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
[Theory]
[InlineData(1, "member@example.com", true)]
[InlineData(2, "another@example.com", true)]
[InlineData(999, "member@example.com", false)]
public async Task ReserveBookAsync_ReturnsExpectedResult(
int bookId, string email, bool repositoryReturns)
{
var mockRepository = new Mock<IBookRepository>();
mockRepository
.Setup(repo => repo.ReserveAsync(bookId, email))
.ReturnsAsync(repositoryReturns);
var mockNotifier = new Mock<INotificationSender>();
var service = new BookReservationService(mockRepository.Object, mockNotifier.Object);
var result = await service.ReserveBookAsync(bookId, email);
Assert.Equal(repositoryReturns, result);
}
}
Key Lessons
A mock is a fake implementation of an interface, entirely controlled by the test, it exists specifically because BookReservationService depends on IBookRepository, the abstraction, rather than a concrete class, which is the direct, practical payoff of Dependency Inversion.
Arrange-Act-Assert is the standard shape of a readable unit test, set up, execute one action, check the result, and naming the sections explicitly, even just as comments, makes tests dramatically easier to follow.
Assert checks a return value; Verify checks that a specific interaction actually happened, both matter, and testing only one of them can miss real bugs.
Testing the failure path deliberately, Times.Never, Assert.False, is just as important as testing the happy path, it's often where the actual bugs hide.
[Theory] and [InlineData] avoid duplicating nearly-identical test methods for different input values.
Unit tests mock every dependency and test one class in isolation; integration tests use real, or realistic, dependencies to confirm multiple real pieces work together, knowing which kind of test fits which class is a genuinely common interview question.
Summary
The Repository Pattern post claimed BookReservationService was "genuinely unit-testable" because it depends on an interface rather than a concrete class, this post is where that claim actually gets demonstrated, with real, runnable tests. A mock stands in for a real dependency, letting a test control exactly what happens without a real database anywhere in sight. Arrange-Act-Assert keeps each test readable. Verify catches missing side effects that a simple return-value check would miss entirely. And knowing the line between a unit test and an integration test, what gets mocked, what doesn't, and why, is exactly the kind of distinction that separates someone who has used a testing framework from someone who actually understands what each type of test is for.
Originally published on my blog: TechStack Blog
More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals

Top comments (0)