Core API Unit Testing
Unit testing verifies that individual pieces of code work correctly in isolation. A unit test for the BookStore API checks whether BookService.GetByIdAsync(1) returns the right book — without hitting the real database, the real file system, or any external service. This isolation is what makes unit tests fast and reliable.
What Is a Unit Test?
A unit test:
- Tests one method or class at a time
- Replaces real dependencies (like database) with fakes
- Runs in milliseconds (no network, no disk)
- Gives immediate feedback when something breaks
Unit Test:
Input: GetByIdAsync(1)
Expected Output: Book { Id = 1, Title = "Clean Code" }
Does NOT: hit SQL Server, read files, call any external service
Tools for Unit Testing in .NET
| Tool | Purpose |
|---|---|
| xUnit | Test framework (runs the tests) |
| Moq | Mocking library (creates fake dependencies) |
| FluentAssertions | Readable assertion library |
Step 1 – Create the Test Project
// From the solution root:
dotnet new xunit -n BookStoreAPI.Tests
cd BookStoreAPI.Tests
dotnet add reference ../BookStoreAPI/BookStoreAPI.csproj
dotnet add package Moq
dotnet add package FluentAssertions
Step 2 – Understanding Mocking
A mock is a fake version of a dependency. Instead of the real BookRepository that queries SQL Server, the test uses a mock that returns whatever data the test specifies.
Real test without mock: Test with mock:
BookService needs real DbContext BookService needs IBookRepository
DbContext needs real SQL Server IBookRepository is mocked
SQL Server must be running Mock returns preset data
Test takes 500ms Test takes 1ms
Test fails if DB is down Test never fails due to DB
Step 3 – Write Unit Tests for BookService
// BookStoreAPI.Tests/Services/BookServiceTests.cs
using BookStoreAPI.Models;
using BookStoreAPI.Repositories;
using BookStoreAPI.Services;
using FluentAssertions;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
namespace BookStoreAPI.Tests.Services
{
public class BookServiceTests
{
// Shared test data — the book used across tests
private readonly List<Book> _testBooks = new()
{
new Book { Id = 1, Title = "Clean Code", Author = "Robert C. Martin",
Price = 29.99m, Category = "Technology", IsAvailable = true,
CreatedDate = new DateTime(2024, 1, 1) },
new Book { Id = 2, Title = "The Pragmatic Programmer", Author = "David Thomas",
Price = 34.99m, Category = "Technology", IsAvailable = true,
CreatedDate = new DateTime(2024, 1, 2) }
};
private readonly Mock<IBookRepository> _mockRepository;
private readonly Mock<IHubContext<BookStoreHub>> _mockHubContext;
private readonly Mock<ILogger<BookService>> _mockLogger;
private readonly BookService _bookService;
public BookServiceTests()
{
_mockRepository = new Mock<IBookRepository>();
_mockHubContext = new Mock<IHubContext<BookStoreHub>>();
_mockLogger = new Mock<ILogger<BookService>>();
_bookService = new BookService(
_mockRepository.Object,
_mockHubContext.Object,
_mockLogger.Object);
}
// TEST 1: GetAllAsync returns all books
[Fact]
public async Task GetAllAsync_ShouldReturnAllBooks()
{
// Arrange: set up what the mock returns
_mockRepository.Setup(r => r.GetAllAsync())
.ReturnsAsync(_testBooks);
// Act: call the method being tested
var result = await _bookService.GetAllAsync();
// Assert: verify the result is correct
result.Should().NotBeNull();
result.Should().HaveCount(2);
result[0].Title.Should().Be("Clean Code");
}
// TEST 2: GetByIdAsync returns correct book when found
[Fact]
public async Task GetByIdAsync_WhenBookExists_ShouldReturnBook()
{
// Arrange
var expectedBook = _testBooks[0];
_mockRepository.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(expectedBook);
// Act
var result = await _bookService.GetByIdAsync(1);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(1);
result.Title.Should().Be("Clean Code");
result.Author.Should().Be("Robert C. Martin");
}
// TEST 3: GetByIdAsync returns null when not found
[Fact]
public async Task GetByIdAsync_WhenBookDoesNotExist_ShouldReturnNull()
{
// Arrange
_mockRepository.Setup(r => r.GetByIdAsync(999))
.ReturnsAsync((Book?)null);
// Act
var result = await _bookService.GetByIdAsync(999);
// Assert
result.Should().BeNull();
}
// TEST 4: CreateAsync sets CreatedDate and saves
[Fact]
public async Task CreateAsync_ShouldSetCreatedDateAndSave()
{
// Arrange
var newBook = new Book
{
Title = "Design Patterns",
Author = "Gang of Four",
Price = 45.00m,
Category = "Technology",
IsAvailable = true
};
_mockRepository.Setup(r => r.AddAsync(It.IsAny<Book>()))
.Returns(Task.FromResult(newBook));
_mockRepository.Setup(r => r.SaveAsync())
.Returns(Task.CompletedTask);
// Mock the hub to avoid NullReferenceException
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
_mockHubContext.Setup(h => h.Clients).Returns(mockClients.Object);
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
// Act
var result = await _bookService.CreateAsync(newBook);
// Assert
result.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
_mockRepository.Verify(r => r.AddAsync(It.IsAny<Book>()), Times.Once);
_mockRepository.Verify(r => r.SaveAsync(), Times.Once);
}
// TEST 5: DeleteAsync returns false when book not found
[Fact]
public async Task DeleteAsync_WhenBookNotFound_ShouldReturnFalse()
{
// Arrange
_mockRepository.Setup(r => r.GetByIdAsync(999))
.ReturnsAsync((Book?)null);
// Act
var result = await _bookService.DeleteAsync(999);
// Assert
result.Should().BeFalse();
_mockRepository.Verify(r => r.DeleteAsync(It.IsAny<Book>()), Times.Never);
_mockRepository.Verify(r => r.SaveAsync(), Times.Never);
}
// TEST 6: DeleteAsync returns true and deletes when found
[Fact]
public async Task DeleteAsync_WhenBookExists_ShouldReturnTrueAndDelete()
{
// Arrange
var bookToDelete = _testBooks[0];
_mockRepository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(bookToDelete);
_mockRepository.Setup(r => r.DeleteAsync(bookToDelete)).Returns(Task.CompletedTask);
_mockRepository.Setup(r => r.SaveAsync()).Returns(Task.CompletedTask);
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
_mockHubContext.Setup(h => h.Clients).Returns(mockClients.Object);
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
// Act
var result = await _bookService.DeleteAsync(1);
// Assert
result.Should().BeTrue();
_mockRepository.Verify(r => r.DeleteAsync(bookToDelete), Times.Once);
_mockRepository.Verify(r => r.SaveAsync(), Times.Once);
}
}
}
Running the Tests
// Run all tests from the terminal:
dotnet test
// Run with verbose output:
dotnet test --verbosity normal
Expected output:
Starting test execution...
Passed BookServiceTests.GetAllAsync_ShouldReturnAllBooks [2ms]
Passed BookServiceTests.GetByIdAsync_WhenBookExists_ShouldReturnBook [1ms]
Passed BookServiceTests.GetByIdAsync_WhenBookDoesNotExist_ShouldReturnNull [1ms]
Passed BookServiceTests.CreateAsync_ShouldSetCreatedDateAndSave [3ms]
Passed BookServiceTests.DeleteAsync_WhenBookNotFound_ShouldReturnFalse [1ms]
Passed BookServiceTests.DeleteAsync_WhenBookExists_ShouldReturnTrueAndDelete [2ms]
Passed! - 6 tests in 0.15s
The Arrange-Act-Assert Pattern
Every test follows this structure:
// ARRANGE: set up the test data and mocks
_mockRepository.Setup(...).ReturnsAsync(...);
// ACT: call the method being tested
var result = await _bookService.GetByIdAsync(1);
// ASSERT: verify the result is as expected
result.Should().NotBeNull();
result.Title.Should().Be("Clean Code");
Verifying Mock Interactions
Verify() confirms that a method on the mock was (or was not) called:
// Verify SaveAsync was called exactly once
_mockRepository.Verify(r => r.SaveAsync(), Times.Once);
// Verify DeleteAsync was never called (for the "not found" scenario)
_mockRepository.Verify(r => r.DeleteAsync(It.IsAny<Book>()), Times.Never);
Key Points
- Unit tests verify one method at a time, in isolation from databases and external services.
- Moq creates fake (mock) implementations of interfaces, letting the test control what each dependency returns.
- FluentAssertions provides readable assertions:
result.Should().Be(...)reads like plain English. - Every test follows the Arrange-Act-Assert pattern: set up → call → verify.
Verify()confirms that mock methods were called the correct number of times with the correct arguments.
