38. Unit Testing Fundamentals

Writing unit tests with xUnit, mocking dependencies with Moq, and applying the Arrange-Act-Assert pattern

About this chapter

Build confidence in your code by writing unit tests using xUnit and Moq, following the Arrange-Act-Assert pattern to verify isolated functionality.

  • Unit testing benefits: Regression prevention, confidence, and documentation
  • xUnit framework: Setting up tests in .NET with xUnit
  • Mocking dependencies: Using Moq to isolate code under test
  • Arrange-Act-Assert pattern: Structuring tests for clarity and consistency
  • Testing services: Unit testing business logic independent of database
  • Test naming conventions: Clear test names that document expected behavior

Learning outcomes:

  • Understand benefits of unit testing
  • Write tests using xUnit framework
  • Create mock objects with Moq
  • Apply Arrange-Act-Assert pattern
  • Unit test services and repositories
  • Write tests that serve as documentation

38.1 Why Unit Testing Matters

Without tests:

You change code
    ↓
"Looks good, ship it"
    ↓
User reports bug
    ↓
Bug takes hours to find and fix
    ↓
Fix breaks something else

With tests:

You change code
    ↓
Run tests
    ↓
Tests catch regression (breaks old functionality)
    ↓
Fix regression immediately
    ↓
Ship with confidence

Benefits of unit tests:

  • Confidence: Know your code works before shipping
  • Regression prevention: Catch when changes break existing functionality
  • Documentation: Tests show how code is supposed to be used
  • Design: Writing testable code forces better design
  • Refactoring safety: Change code knowing tests will catch issues

Test pyramid:

        /\
       /  \
      /    \     End-to-end (few, slow, expensive)
     /------\
    /        \
   /          \   Integration (moderate)
  /------------\
 /              \
/________________\  Unit (many, fast, cheap)

Most tests should be unit tests (fast, isolated, cheap to run).

38.2 Setting Up xUnit

Create test project:

# Create test project alongside API project
dotnet new xunit -n CommandAPI.Tests

# Add reference to API project
cd CommandAPI.Tests
dotnet add reference ../CommandAPI/CommandAPI.csproj

# Add testing dependencies
dotnet add package Moq
dotnet add package AutoFixture
dotnet add package FluentAssertions

Project structure:

CommandAPI/
├── Models/
├── Repositories/
├── Controllers/
├── Program.cs
└── CommandAPI.csproj

CommandAPI.Tests/
├── RepositoriesTests/
│   └── CommandRepositoryTests.cs
├── ControllersTests/
│   └── CommandsControllerTests.cs
├── ServicesTests/
│   └── PaymentServiceTests.cs
└── CommandAPI.Tests.csproj

38.3 Arrange-Act-Assert Pattern

Every test follows this structure:

[Fact]  // xUnit attribute for single test
public void AddCommand_WithValidInput_ReturnsTrue()
{
    // ARRANGE: Set up test data and dependencies
    var repository = new CommandRepository(_dbContext);
    var command = new Command 
    { 
        Id = 1, 
        HowTo = "List files", 
        CommandLine = "ls -la",
        PlatformId = 1
    };

    // ACT: Execute the method being tested
    var result = repository.CreateCommand(command);

    // ASSERT: Verify the result
    Assert.True(result);
    Assert.NotNull(command.Id);
}

Clear separation:

  • Arrange: Everything needed before calling the method
  • Act: Call the method exactly once
  • Assert: Verify the behavior

38.4 Testing Repository Methods

The repository to test:

// Repositories/ICommandRepository.cs
public interface ICommandRepository
{
    Task<Command> GetCommandByIdAsync(int id);
    Task<List<Command>> GetCommandsAsync();
    void CreateCommand(Command command);
    void UpdateCommand(Command command);
    void DeleteCommand(Command command);
    Task SaveChangesAsync();
}

// Repositories/CommandRepository.cs
public class CommandRepository : ICommandRepository
{
    private readonly AppDbContext _context;

    public CommandRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Command> GetCommandByIdAsync(int id)
    {
        return await _context.Commands.FirstOrDefaultAsync(c => c.Id == id);
    }

    public async Task<List<Command>> GetCommandsAsync()
    {
        return await _context.Commands.ToListAsync();
    }

    public void CreateCommand(Command command)
    {
        _context.Commands.Add(command);
    }

    public void UpdateCommand(Command command)
    {
        _context.Commands.Update(command);
    }

    public void DeleteCommand(Command command)
    {
        _context.Commands.Remove(command);
    }

    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }
}

Tests for the repository:

// Tests/RepositoriesTests/CommandRepositoryTests.cs
using Xunit;
using Moq;
using CommandAPI.Models;
using CommandAPI.Data;
using CommandAPI.Repositories;
using Microsoft.EntityFrameworkCore;

public class CommandRepositoryTests
{
    // Fixture for in-memory database
    private readonly AppDbContext _context;
    private readonly CommandRepository _repository;

    public CommandRepositoryTests()
    {
        // Create in-memory database for testing
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;

        _context = new AppDbContext(options);
        _repository = new CommandRepository(_context);
    }

    [Fact]
    public async Task GetCommandByIdAsync_WithValidId_ReturnsCommand()
    {
        // Arrange
        var command = new Command 
        { 
            HowTo = "List files", 
            CommandLine = "ls -la",
            PlatformId = 1
        };
        _context.Commands.Add(command);
        await _context.SaveChangesAsync();

        // Act
        var result = await _repository.GetCommandByIdAsync(command.Id);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("List files", result.HowTo);
        Assert.Equal("ls -la", result.CommandLine);
    }

    [Fact]
    public async Task GetCommandByIdAsync_WithInvalidId_ReturnsNull()
    {
        // Arrange
        var invalidId = 9999;

        // Act
        var result = await _repository.GetCommandByIdAsync(invalidId);

        // Assert
        Assert.Null(result);
    }

    [Fact]
    public async Task CreateCommand_WithValidCommand_AddsToDatabase()
    {
        // Arrange
        var command = new Command 
        { 
            HowTo = "Deploy app", 
            CommandLine = "docker push",
            PlatformId = 1
        };

        // Act
        _repository.CreateCommand(command);
        await _repository.SaveChangesAsync();

        // Assert
        var saved = await _context.Commands
            .FirstOrDefaultAsync(c => c.HowTo == "Deploy app");
        Assert.NotNull(saved);
        Assert.Equal("docker push", saved.CommandLine);
    }

    [Fact]
    public async Task DeleteCommand_WithValidCommand_RemovesFromDatabase()
    {
        // Arrange
        var command = new Command 
        { 
            HowTo = "Old command", 
            CommandLine = "rm -rf /",
            PlatformId = 1
        };
        _context.Commands.Add(command);
        await _context.SaveChangesAsync();
        var commandId = command.Id;

        // Act
        var commandToDelete = await _repository.GetCommandByIdAsync(commandId);
        _repository.DeleteCommand(commandToDelete);
        await _repository.SaveChangesAsync();

        // Assert
        var deleted = await _context.Commands
            .FirstOrDefaultAsync(c => c.Id == commandId);
        Assert.Null(deleted);
    }

    [Fact]
    public async Task GetCommandsAsync_WithMultipleCommands_ReturnsAll()
    {
        // Arrange
        _context.Commands.AddRange(
            new Command { HowTo = "Command 1", CommandLine = "cmd1", PlatformId = 1 },
            new Command { HowTo = "Command 2", CommandLine = "cmd2", PlatformId = 1 },
            new Command { HowTo = "Command 3", CommandLine = "cmd3", PlatformId = 1 }
        );
        await _context.SaveChangesAsync();

        // Act
        var result = await _repository.GetCommandsAsync();

        // Assert
        Assert.NotEmpty(result);
        Assert.True(result.Count >= 3);
    }
}

38.5 Mocking with Moq

When testing services, mock external dependencies:

// Services/IEmailService.cs
public interface IEmailService
{
    Task SendEmailAsync(string to, string subject, string body);
}

// Services/EmailService.cs
public class EmailService : IEmailService
{
    private readonly SmtpClient _smtpClient;

    public EmailService(SmtpClient smtpClient)
    {
        _smtpClient = smtpClient;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        var message = new MailMessage("from@example.com", to)
        {
            Subject = subject,
            Body = body
        };
        
        await _smtpClient.SendMailAsync(message);
    }
}

// Tests/ServicesTests/EmailServiceTests.cs
public class EmailServiceTests
{
    [Fact]
    public async Task SendEmailAsync_WithValidEmail_SendsSuccessfully()
    {
        // Arrange
        var mockSmtpClient = new Mock<SmtpClient>();
        mockSmtpClient
            .Setup(x => x.SendMailAsync(It.IsAny<MailMessage>()))
            .Returns(Task.CompletedTask);

        var emailService = new EmailService(mockSmtpClient.Object);

        // Act
        await emailService.SendEmailAsync(
            "user@example.com", 
            "Test Subject", 
            "Test Body");

        // Assert
        mockSmtpClient.Verify(
            x => x.SendMailAsync(It.IsAny<MailMessage>()),
            Times.Once);  // Verify SendMailAsync was called exactly once
    }

    [Fact]
    public async Task SendEmailAsync_WhenSmtpThrows_PropagatesException()
    {
        // Arrange
        var mockSmtpClient = new Mock<SmtpClient>();
        mockSmtpClient
            .Setup(x => x.SendMailAsync(It.IsAny<MailMessage>()))
            .ThrowsAsync(new InvalidOperationException("SMTP connection failed"));

        var emailService = new EmailService(mockSmtpClient.Object);

        // Act & Assert
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => emailService.SendEmailAsync("user@example.com", "Subject", "Body"));
    }
}

Common Moq patterns:

// Setup return value
mock.Setup(x => x.GetUserAsync(It.IsAny<int>()))
    .ReturnsAsync(new User { Id = 1, Name = "John" });

// Setup to throw exception
mock.Setup(x => x.GetUserAsync(It.IsAny<int>()))
    .ThrowsAsync(new HttpRequestException("API down"));

// Setup with specific arguments
mock.Setup(x => x.GetUserAsync(123))
    .ReturnsAsync(new User { Id = 123, Name = "John" });

// Verify method was called
mock.Verify(x => x.GetUserAsync(123), Times.Once);

// Verify with any arguments
mock.Verify(x => x.GetUserAsync(It.IsAny<int>()), Times.AtLeastOnce);

// Setup sequence (different return values on each call)
mock.SetupSequence(x => x.GetUserAsync(It.IsAny<int>()))
    .ReturnsAsync(new User { Id = 1 })
    .ThrowsAsync(new HttpRequestException())
    .ReturnsAsync(new User { Id = 2 });

38.6 Testing Service Logic

Service to test:

public class CommandService
{
    private readonly ICommandRepository _repository;
    private readonly IEmailService _emailService;
    private readonly ILogger<CommandService> _logger;

    public CommandService(
        ICommandRepository repository,
        IEmailService emailService,
        ILogger<CommandService> logger)
    {
        _repository = repository;
        _emailService = emailService;
        _logger = logger;
    }

    public async Task<bool> CreateCommandAndNotifyAsync(Command command, string notifyEmail)
    {
        try
        {
            _repository.CreateCommand(command);
            await _repository.SaveChangesAsync();

            await _emailService.SendEmailAsync(
                notifyEmail,
                "New Command Created",
                $"Command: {command.HowTo}");

            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create command");
            return false;
        }
    }
}

// Tests/ServicesTests/CommandServiceTests.cs
public class CommandServiceTests
{
    private readonly Mock<ICommandRepository> _mockRepository;
    private readonly Mock<IEmailService> _mockEmailService;
    private readonly Mock<ILogger<CommandService>> _mockLogger;
    private readonly CommandService _service;

    public CommandServiceTests()
    {
        _mockRepository = new Mock<ICommandRepository>();
        _mockEmailService = new Mock<IEmailService>();
        _mockLogger = new Mock<ILogger<CommandService>>();

        _service = new CommandService(
            _mockRepository.Object,
            _mockEmailService.Object,
            _mockLogger.Object);
    }

    [Fact]
    public async Task CreateCommandAndNotifyAsync_WithValidInput_CreatesAndNotifies()
    {
        // Arrange
        var command = new Command 
        { 
            HowTo = "Test", 
            CommandLine = "test",
            PlatformId = 1
        };
        var email = "user@example.com";

        _mockRepository.Setup(x => x.SaveChangesAsync()).Returns(Task.CompletedTask);
        _mockEmailService.Setup(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
            .Returns(Task.CompletedTask);

        // Act
        var result = await _service.CreateCommandAndNotifyAsync(command, email);

        // Assert
        Assert.True(result);
        _mockRepository.Verify(x => x.CreateCommand(command), Times.Once);
        _mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
        _mockEmailService.Verify(
            x => x.SendEmailAsync(email, It.IsAny<string>(), It.IsAny<string>()),
            Times.Once);
    }

    [Fact]
    public async Task CreateCommandAndNotifyAsync_WhenEmailFails_StillSavesCommand()
    {
        // Arrange
        var command = new Command { HowTo = "Test", CommandLine = "test", PlatformId = 1 };
        var email = "user@example.com";

        _mockRepository.Setup(x => x.SaveChangesAsync()).Returns(Task.CompletedTask);
        _mockEmailService.Setup(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
            .ThrowsAsync(new Exception("Email failed"));

        // Act
        var result = await _service.CreateCommandAndNotifyAsync(command, email);

        // Assert
        Assert.False(result);  // Service returns false on error
        _mockRepository.Verify(x => x.CreateCommand(command), Times.Once);
        _mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
    }

    [Fact]
    public async Task CreateCommandAndNotifyAsync_WhenRepositoryFails_ReturnsFalse()
    {
        // Arrange
        var command = new Command { HowTo = "Test", CommandLine = "test", PlatformId = 1 };
        var email = "user@example.com";

        _mockRepository.Setup(x => x.SaveChangesAsync())
            .ThrowsAsync(new DbUpdateException("Database error", new Exception()));

        // Act
        var result = await _service.CreateCommandAndNotifyAsync(command, email);

        // Assert
        Assert.False(result);
        _mockRepository.Verify(x => x.CreateCommand(command), Times.Once);
        _mockEmailService.Verify(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }
}

38.7 Test Naming Conventions

Good test names:

[Fact]
public void CreateCommand_WithValidInput_ReturnsTrue()
// Pattern: MethodName_Condition_ExpectedResult

[Fact]
public void UpdateCommand_WhenCommandNotFound_ThrowsNotFoundException()

[Fact]
public void DeleteCommand_WithValidId_RemovesFromDatabase()

[Fact]
public async Task GetUserAsync_WhenHttpClientThrows_LogsError()

Avoid:

[Fact]
public void Test1()  // ❌ Not descriptive

[Fact]
public void TestCreateCommand()  // ❌ Doesn't say what we're testing

[Fact]
public void ShouldWorkCorrectly()  // ❌ Vague

38.8 Test Coverage Goals

Measure coverage:

# Install coverage tool
dotnet add package coverlet.collector --version 6.0

# Run tests with coverage
dotnet test /p:CollectCoverage=true /p:CoverageFormat=opencover

# View coverage report
# Look for coverage.opencover.xml

Coverage targets:

  • Critical paths (business logic): 80-90% coverage
  • Repositories: 70-80% coverage
  • Controllers: 60-70% coverage (integration tests better)
  • Utils/Helpers: 70-80% coverage
  • Don’t cover: Auto-generated code, UI code, external dependencies

Don’t chase 100% coverage—focus on meaningful tests:

// ❌ Pointless test
[Fact]
public void CreateCommand_WithValidInput_SetsId()
{
    var command = new Command { HowTo = "test", CommandLine = "cmd" };
    Assert.True(command.Id >= 0);  // Always true, doesn't test anything
}

// ✓ Meaningful test
[Fact]
public async Task CreateCommand_WithValidInput_PersistsToDatabase()
{
    var command = new Command { HowTo = "test", CommandLine = "cmd", PlatformId = 1 };
    _repository.CreateCommand(command);
    await _repository.SaveChangesAsync();
    
    var saved = await _repository.GetCommandByIdAsync(command.Id);
    Assert.NotNull(saved);  // Tests actual behavior
}

38.9 Common Testing Pitfalls

Pitfall 1: Shared mutable state

// ❌ WRONG - Tests interfere with each other
private List<Command> _commands;

public CommandRepositoryTests()
{
    _commands = new List<Command> { new Command { ... } };
}

[Fact]
public void Test1_ModifiesSharedList()
{
    _commands.Add(new Command { ... });
    Assert.Equal(2, _commands.Count);
}

[Fact]
public void Test2_AssumesOriginalCount()
{
    Assert.Equal(1, _commands.Count);  // Fails if Test1 ran first!
}

// ✓ CORRECT - Fresh state per test
public CommandRepositoryTests()
{
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())  // Unique per test
        .Options;
    _context = new AppDbContext(options);
}

Pitfall 2: Testing multiple things

// ❌ WRONG - Too many assertions
[Fact]
public void CreateCommand_DoesEverything()
{
    // ... setup ...
    
    _repository.CreateCommand(command);
    await _repository.SaveChangesAsync();
    var saved = await _repository.GetCommandByIdAsync(command.Id);
    
    Assert.NotNull(saved);
    Assert.Equal(command.HowTo, saved.HowTo);
    Assert.Equal(command.CommandLine, saved.CommandLine);
    Assert.Equal(command.PlatformId, saved.PlatformId);
    // When this fails, hard to know which assertion failed
}

// ✓ CORRECT - One concept per test
[Fact]
public async Task CreateCommand_SavesHowTo()
{
    // ... setup ...
    _repository.CreateCommand(command);
    await _repository.SaveChangesAsync();
    var saved = await _repository.GetCommandByIdAsync(command.Id);
    Assert.Equal(command.HowTo, saved.HowTo);
}

[Fact]
public async Task CreateCommand_SavesCommandLine()
{
    // ... setup ...
    _repository.CreateCommand(command);
    await _repository.SaveChangesAsync();
    var saved = await _repository.GetCommandByIdAsync(command.Id);
    Assert.Equal(command.CommandLine, saved.CommandLine);
}

Pitfall 3: Sleeping in tests

// ❌ WRONG - Tests slow down
[Fact]
public async Task DoSomethingAsync_EventuallyCompletes()
{
    Task.Run(() => DoSomething());
    await Task.Delay(1000);  // Just hoping it's done
    Assert.True(completed);
}

// ✓ CORRECT - Use TaskCompletionSource or ManualResetEvent
[Fact]
public async Task DoSomethingAsync_EventuallyCompletes()
{
    var tcs = new TaskCompletionSource<bool>();
    Task.Run(() => { DoSomething(); tcs.SetResult(true); });
    
    await tcs.Task;  // Waits until actually done
    Assert.True(completed);
}

38.10 Running Tests

Run all tests:

dotnet test

Run specific test:

dotnet test --filter "CommandRepositoryTests"

Run with verbose output:

dotnet test -v d

Run and generate coverage:

dotnet test /p:CollectCoverage=true

Watch mode (re-run on changes):

dotnet watch test

38.11 What’s Next

You now have:

  • ✓ Understanding why unit tests matter
  • ✓ xUnit setup and structure
  • ✓ Arrange-Act-Assert pattern
  • ✓ Testing repositories with in-memory database
  • ✓ Mocking dependencies with Moq
  • ✓ Testing services with mocked collaborators
  • ✓ Good test naming conventions
  • ✓ Coverage measurement and targets
  • ✓ Common testing pitfalls and how to avoid them

Next: Testing Controllers—Testing action results, validation, authorization, and integration with services.