38. Unit Testing Fundamentals
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.