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

ToolPurpose
xUnitTest framework (runs the tests)
MoqMocking library (creates fake dependencies)
FluentAssertionsReadable 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.

Leave a Comment