Demo application: EmployeeManagement (ASP.NET Core MVC, xUnit, Moq, FluentAssertions)
Table of Contents
- Introduction to Unit Testing
- Basic Test Scenarios
- Organization and Execution Control
- Data-Driven Tests
- Test Isolation and Mocking
- Testing MVC Controllers
- Advanced Tests (Middleware, Filters, Services)
- CI/CD Integration
- Summary and Best Practices
1. Introduction to Unit Testing
1.1 Why Test?
A unit test isolates a unit of code (method, class) from its dependencies to validate its behavior. Benefits:
- Rapid detection of regressions
- Living documentation of code
- Confidence for refactoring
- Long-term cost savings (bug found in development costs 10× less than in production)
1.2 Test Pyramid
graph TD
subgraph Pyramid
E2E["E2E Tests\n(UI, browser)\n[Slow, expensive, fragile]"]
Integration["Integration Tests\n[Medium]"]
Unit["Unit Tests\n[Fast, isolated, numerous]"]
end
Unit --> Integration --> E2E
style Unit fill:#4CAF50
style Integration fill:#FF9800
style E2E fill:#F44336
1.3 Frameworks Used
| Role | Package |
|---|---|
| Test framework | xUnit |
| Expressive assertions library | FluentAssertions |
| Mocking | Moq |
| Test runner | dotnet test |
1.4 Test Project Structure
EmployeeManagement.sln
├── EmployeeManagement/ ← Main ASP.NET Core MVC project
│ ├── Controllers/
│ ├── Business/
│ ├── DataAccess/
│ └── ViewModels/
└── EmployeeManagement.Test/ ← Test project (xUnit)
├── EmployeeFactoryTests.cs
├── EmployeeServiceTests.cs
├── InternalEmployeeControllerTests.cs
├── Fixtures/
└── TestData/
1.5 Arrange / Act / Assert (AAA) Pattern
[Fact]
public void CreateInternalEmployee_SuggestedBonusMustBeCalculated()
{
// Arrange — Prepare data and objects
var employeeFactory = new EmployeeFactory();
var employeeService = new EmployeeService(
new EmployeeManagementTestDataRepository(), employeeFactory);
// Act — Execute the code under test
var employee = employeeService.CreateInternalEmployee("Brooklyn", "Cannon");
// Assert — Verify the expected result
Assert.Equal(1000m, employee.SuggestedBonus);
}
2. Basic Test Scenarios
2.1 Flow of the Tested Class (EmployeeFactory)
graph TD
Factory["EmployeeFactory.CreateEmployee()"] -->|Internal| IE["InternalEmployee\n(name, first name, seniority, salary, management, level)"]
Factory -->|External| EE["ExternalEmployee\n(name, first name, company, delay, level)"]
IE --> Bonus["CalculateSuggestedBonus()\n= (seniority × courses attended) × 100"]
2.2 Boolean, String, Numeric Tests
public class EmployeeFactoryTests
{
// Boolean test
[Fact]
public void CreateEmployee_IsManager_ShouldBeSet()
{
var factory = new EmployeeFactory();
var employee = factory.CreateEmployee("Sarah", "Connor", null, true);
Assert.True(employee.IsManager);
}
// Object type test
[Fact]
public void CreateEmployee_ExternalEmployee_MustBeExternalEmployee()
{
var factory = new EmployeeFactory();
var employee = factory.CreateEmployee("John", "Smith", "Globomantics", false);
Assert.IsType<ExternalEmployee>(employee);
}
// String test
[Fact]
public void CreateEmployee_FirstName_ShouldMatchInput()
{
var factory = new EmployeeFactory();
var employee = factory.CreateEmployee("Kevin", "Dockx", null, false);
Assert.Equal("Kevin", employee.FirstName);
Assert.StartsWith("K", employee.FirstName);
Assert.Contains("evin", employee.FirstName);
}
// Exception test
[Fact]
public void CreateEmployee_NullFirstName_MustThrowArgumentNullException()
{
var factory = new EmployeeFactory();
Assert.Throws<ArgumentNullException>(
() => factory.CreateEmployee(null!, "Dockx", null, false));
}
// Async test
[Fact]
public async Task GiveRaise_RaiseGiven_ShouldBeAppliedToSalary()
{
var employee = new InternalEmployee("Kevin", "Dockx", 5, 3000, false, 1);
var service = new EmployeeService(new EmployeeManagementTestDataRepository(),
new EmployeeFactory());
await service.GiveRaiseAsync(employee, 200);
Assert.Equal(3200m, employee.Salary);
}
}
2.3 Collection Tests
[Fact]
public void CreateInternalEmployee_AttendedCourses_MustNotBeEmpty()
{
var employee = employeeService.CreateInternalEmployee("Brooklyn", "Cannon");
Assert.NotEmpty(employee.AttendedCourses);
}
[Fact]
public void CreateInternalEmployee_MustContainObligatoryFirstCourse()
{
var employee = employeeService.CreateInternalEmployee("Brooklyn", "Cannon");
Assert.Contains(employee.AttendedCourses,
course => course.Id == Guid.Parse("37e03ca7-c730-4351-834c-b66f280cdb01"));
}
2.4 Tests with FluentAssertions
// xUnit standard
Assert.Equal(1000m, employee.SuggestedBonus);
// FluentAssertions — more readable
employee.SuggestedBonus.Should().Be(1000m);
employee.FirstName.Should().StartWith("K");
employee.AttendedCourses.Should().NotBeEmpty();
employee.AttendedCourses.Should().HaveCountGreaterThan(1);
employee.Should().BeOfType<InternalEmployee>();
3. Organization and Execution Control
3.1 Test Instance Lifetime (xUnit)
graph TD
subgraph "Default (xUnit)"
T1["Test 1"] --> I1["new ClassTest()"]
T2["Test 2"] --> I2["new ClassTest()"]
T3["Test 3"] --> I3["new ClassTest()"]
end
note1["A new instance of the class\nis created for EACH test\n(maximum isolation)"]
3.2 IClassFixture — Sharing Expensive State Between Tests
// Fixture — created once for all tests in the class
public class EmployeeServiceFixture : IDisposable
{
public EmployeeService EmployeeService { get; }
public EmployeeServiceFixture()
{
// Expensive setup executed once
EmployeeService = new EmployeeService(
new EmployeeManagementTestDataRepository(),
new EmployeeFactory());
}
public void Dispose()
{
// Cleanup after all class tests
}
}
// Test class using the fixture
public class EmployeeServiceTests : IClassFixture<EmployeeServiceFixture>
{
private readonly EmployeeServiceFixture _fixture;
// Injected via constructor (xUnit pattern)
public EmployeeServiceTests(EmployeeServiceFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void FetchInternalEmployee_SalaryMustBe3000()
{
var employee = _fixture.EmployeeService
.FetchInternalEmployee(Guid.Parse("72f2f5fe-e50c-4966-8420-d50258aefdcb"));
Assert.Equal(3000m, employee.Salary);
}
}
3.3 Collection Fixture — Sharing Between Multiple Classes
// Declare the collection
[CollectionDefinition("EmployeeServiceCollection")]
public class EmployeeServiceCollectionFixture : ICollectionFixture<EmployeeServiceFixture>
{
// Empty class — serves only as a marker
}
// Use in both test classes
[Collection("EmployeeServiceCollection")]
public class EmployeeServiceTests
{
private readonly EmployeeServiceFixture _fixture;
public EmployeeServiceTests(EmployeeServiceFixture fixture) => _fixture = fixture;
}
[Collection("EmployeeServiceCollection")]
public class DataDrivenEmployeeServiceTests
{
private readonly EmployeeServiceFixture _fixture;
public DataDrivenEmployeeServiceTests(EmployeeServiceFixture fixture) => _fixture = fixture;
}
3.4 Execution Controls
// Skip a test
[Fact(Skip = "Known bug — to fix in sprint 12")]
public void SkippedTest() { ... }
// Categorize tests with Trait
[Fact]
[Trait("Category", "Integration")]
[Trait("Priority", "High")]
public void IntegrationTest() { ... }
// Run only tests of a category
// dotnet test --filter "Category=Integration"
4. Data-Driven Tests
4.1 Theory vs Fact
| Attribute | Usage |
|---|---|
[Fact] | Test without parameters — single case |
[Theory] | Parameterized test — multiple data cases |
graph TD
Theory["[Theory]"] -->|provides data| IID["[InlineData]\nInline data"]
Theory --> MD["[MemberData]\nData from static property/method"]
Theory --> CD["[ClassData]\nData from a class"]
Theory --> EXT["External data\n(CSV, JSON, DB)"]
4.2 InlineData
[Theory]
[InlineData("1fd115cf-f44c-4982-86bc-a8fe2e4ff83e")]
[InlineData("37e03ca7-c730-4351-834c-b66f280cdb01")]
public void CreateInternalEmployee_MustHaveAttendedObligatoryCoursesById(Guid courseId)
{
var employee = _fixture.EmployeeService.CreateInternalEmployee("Brooklyn", "Cannon");
Assert.Contains(employee.AttendedCourses, c => c.Id == courseId);
}
4.3 MemberData — Data from a Static Property
public class EmployeeServiceTests
{
// Static property returning test data
public static IEnumerable<object[]> CourseTestData =>
new List<object[]>
{
new object[] { Guid.Parse("1fd115cf-f44c-4982-86bc-a8fe2e4ff83e") },
new object[] { Guid.Parse("37e03ca7-c730-4351-834c-b66f280cdb01") }
};
[Theory]
[MemberData(nameof(CourseTestData))]
public void CreateInternalEmployee_MustHaveAttendedObligatoryCoursesById(Guid courseId)
{
var employee = _fixture.EmployeeService.CreateInternalEmployee("Brooklyn", "Cannon");
Assert.Contains(employee.AttendedCourses, c => c.Id == courseId);
}
}
4.4 ClassData — Data Encapsulated in a Class
// Separate data class (reusable)
public class CourseTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { Guid.Parse("1fd115cf-f44c-4982-86bc-a8fe2e4ff83e") };
yield return new object[] { Guid.Parse("37e03ca7-c730-4351-834c-b66f280cdb01") };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// Usage in tests
[Theory]
[ClassData(typeof(CourseTestData))]
public void CreateInternalEmployee_MustHaveAttendedObligatoryCoursesById(Guid courseId)
{
var employee = _fixture.EmployeeService.CreateInternalEmployee("Brooklyn", "Cannon");
Assert.Contains(employee.AttendedCourses, c => c.Id == courseId);
}
4.5 TheoryData — Strongly Typed Data (.NET 5+)
// More type-safe than IEnumerable<object[]>
public class StronglyTypedCourseData : TheoryData<Guid>
{
public StronglyTypedCourseData()
{
Add(Guid.Parse("1fd115cf-f44c-4982-86bc-a8fe2e4ff83e"));
Add(Guid.Parse("37e03ca7-c730-4351-834c-b66f280cdb01"));
}
}
[Theory]
[ClassData(typeof(StronglyTypedCourseData))]
public void StronglyTypedTest(Guid courseId) { ... }
5. Test Isolation and Mocking
5.1 Types of Test Doubles
graph TD
TestDouble["Test Doubles"] --> Fake["Fake\nSimplified functional implementation\n(e.g.: in-memory DB)"]
TestDouble --> Dummy["Dummy\nNever called\n(just to satisfy the compiler)"]
TestDouble --> Stub["Stub\nReturns predefined values"]
TestDouble --> Spy["Spy\nRecords calls for verification"]
TestDouble --> Mock["Mock\nVerifies interactions (expected behavior)"]
5.2 EF Core In-Memory for Tests
// Option 1: EF Core InMemory Provider
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Option 2: SQLite in-memory (more realistic, supports constraints)
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=:memory:"));
5.3 HttpClient with Custom Message Handler
// Custom handler to simulate HTTP responses
public class TestableHttpMessageHandler : HttpMessageHandler
{
private HttpResponseMessage? _response;
public void SetResponse(HttpResponseMessage response)
=> _response = response;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_response
?? throw new InvalidOperationException("Response not configured"));
}
}
// Usage in tests
[Fact]
public async Task GetPromotion_WhenApiReturnsSuccess_ShouldReturnPromotion()
{
var handler = new TestableHttpMessageHandler();
handler.SetResponse(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new { Title = "Senior Developer" }),
Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://localhost:5000/")
};
// Inject the client into the service under test
}
5.4 Moq — Basic Configuration
// Create a mock of an interface
var employeeServiceMock = new Mock<IEmployeeService>();
// Configure a return value for a method
employeeServiceMock
.Setup(m => m.FetchInternalEmployeeAsync(It.IsAny<Guid>()))
.ReturnsAsync(new InternalEmployee("Kevin", "Dockx", 5, 3000, false, 1)
{
Id = Guid.Parse("7183748a-ebeb-4355-8084-f190f8a5a68f"),
SuggestedBonus = 500
});
// Access the mock object
var serviceUnderTest = new SomeService(employeeServiceMock.Object);
5.5 Moq — Argument Matchers
// It.IsAny<T>() — any value of type T
employeeFactoryMock
.Setup(m => m.CreateEmployee("Kevin", It.IsAny<string>(), null, false))
.Returns(new InternalEmployee("Kevin", "Dockx", 5, 2500, false, 1));
// It.Is<T>(predicate) — value matching the predicate
employeeFactoryMock
.Setup(m => m.CreateEmployee(
It.Is<string>(value => value.Contains("a")),
It.IsAny<string>(), null, false))
.Returns(new InternalEmployee("WithA", "Test", 0, 3000, false, 1));
5.6 Moq — Verification of Calls (Verify)
// Verify a method was called once
employeeServiceMock.Verify(
m => m.GiveRaiseAsync(It.IsAny<InternalEmployee>(), 200),
Times.Once);
// Verify an exact number of calls
employeeServiceMock.Verify(
m => m.FetchInternalEmployeeAsync(It.IsAny<Guid>()),
Times.Exactly(2));
// Verify a method was NOT called
employeeServiceMock.Verify(
m => m.DeleteEmployee(It.IsAny<Guid>()),
Times.Never);
5.7 Moq — Mocking Async Methods
// ReturnsAsync for methods returning Task<T>
employeeServiceMock
.Setup(m => m.FetchInternalEmployeeAsync(It.IsAny<Guid>()))
.ReturnsAsync(new InternalEmployee("Kevin", "Dockx", 5, 3000, false, 1));
// For Task (no return value)
employeeServiceMock
.Setup(m => m.DeleteEmployeeAsync(It.IsAny<Guid>()))
.Returns(Task.CompletedTask);
6. Testing MVC Controllers
6.1 Controller Test Architecture
graph TD
subgraph "Testing a controller"
Test["InternalEmployeeControllerTests"]
Test --> Mock1["Mock IEmployeeService\n(simulated behavior)"]
Test --> Mock2["Mock IMapper\nor real MapperConfiguration"]
Test --> Controller["InternalEmployeeController\n(under test)"]
Mock1 --> Controller
Mock2 --> Controller
Controller --> Result["IActionResult\n(ViewResult, BadRequestResult, etc.)"]
Result --> Assertions["Assertions on:\n- result type\n- ViewModel type\n- ViewModel values\n- ModelState"]
end
6.2 Testing ViewResult and ViewModel
[Fact]
public async Task InternalEmployeeDetails_InputFromTempData_MustReturnCorrectEmployee()
{
// Arrange
var expectedEmployeeId = Guid.Parse("7183748a-ebeb-4355-8084-f190f8a5a68f");
var employeeServiceMock = new Mock<IEmployeeService>();
employeeServiceMock
.Setup(m => m.FetchInternalEmployeeAsync(It.IsAny<Guid>()))
.ReturnsAsync(new InternalEmployee("Jaimy", "Johnson", 3, 3400, true, 1)
{
Id = expectedEmployeeId,
SuggestedBonus = 500
});
var mapperConfiguration = new MapperConfiguration(
cfg => cfg.AddProfile<MapperProfiles.EmployeeProfile>());
var mapper = new Mapper(mapperConfiguration);
var controller = new InternalEmployeeController(
employeeServiceMock.Object, mapper, null);
// Configure TempData
var tempData = new TempDataDictionary(
new DefaultHttpContext(),
new Mock<ITempDataProvider>().Object);
tempData["EmployeeId"] = expectedEmployeeId;
controller.TempData = tempData;
// Act
var result = await controller.InternalEmployeeDetails(null);
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<InternalEmployeeDetailViewModel>(viewResult.Model);
Assert.Equal(expectedEmployeeId, viewModel.Id);
}
6.3 Testing with Invalid ModelState
[Fact]
public async Task AddInternalEmployee_InvalidInput_MustReturnBadRequest()
{
// Arrange
var employeeServiceMock = new Mock<IEmployeeService>();
var mapperMock = new Mock<IMapper>();
var controller = new InternalEmployeeController(
employeeServiceMock.Object, mapperMock.Object, null);
var viewModel = new CreateInternalEmployeeViewModel();
// Simulate invalid ModelState
controller.ModelState.AddModelError("FirstName", "Required");
// Act
var result = await controller.AddInternalEmployee(viewModel);
// Assert
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<SerializableError>(badRequest.Value);
}
6.4 Testing with Session
[Fact]
public async Task InternalEmployeeDetails_InputFromSession_MustReturnCorrectEmployee()
{
// Arrange
var expectedId = Guid.Parse("7183748a-ebeb-4355-8084-f190f8a5a68f");
// Configure a mock Session
var mockSession = new Mock<ISession>();
var sessionData = JsonSerializer.SerializeToUtf8Bytes(expectedId);
mockSession.Setup(s => s.TryGetValue("EmployeeId", out sessionData)).Returns(true);
var mockHttpContext = new Mock<HttpContext>();
mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object);
var controller = new InternalEmployeeController(/* ... */);
controller.ControllerContext = new ControllerContext
{
HttpContext = mockHttpContext.Object
};
// Act + Assert
var result = await controller.InternalEmployeeDetails(null);
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<InternalEmployeeDetailViewModel>(viewResult.Model);
Assert.Equal(expectedId, viewModel.Id);
}
6.5 Testing with HttpContext.User (Authentication)
[Fact]
public async Task GetProtectedEmployee_AuthenticatedUser_MustReturnEmployee()
{
// Simulate an authenticated user
var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, "Kevin"),
new Claim(ClaimTypes.NameIdentifier, "user-id-123"),
new Claim(ClaimTypes.Role, "Admin")
}, "TestAuthentication"));
var controller = new ProtectedController(/* ... */);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
var result = await controller.GetProtectedEmployee();
Assert.IsType<OkObjectResult>(result);
}
7. Advanced Tests (Middleware, Filters, Services)
7.1 Middleware Tests
public class MyCustomMiddleware
{
private readonly RequestDelegate _next;
public MyCustomMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Headers.ContainsKey("X-Custom-Header"))
{
context.Response.StatusCode = 400;
return;
}
await _next(context);
}
}
// Middleware test
public class MyCustomMiddlewareTests
{
[Fact]
public async Task InvokeAsync_WithCustomHeader_ShouldReturn400()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Headers["X-Custom-Header"] = "invalid";
RequestDelegate next = (ctx) => Task.CompletedTask;
var middleware = new MyCustomMiddleware(next);
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(400, context.Response.StatusCode);
}
}
7.2 Action Filter Tests
public class EmployeeValidationFilterTests
{
[Fact]
public void OnActionExecuting_InvalidModel_ShouldSetBadRequest()
{
// Arrange — Create a simulated ActionExecutingContext
var httpContext = new DefaultHttpContext();
var actionContext = new ActionContext(
httpContext,
new RouteData(),
new ActionDescriptor());
var context = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object?>(),
new object());
context.ModelState.AddModelError("FirstName", "Required");
var filter = new EmployeeValidationFilter();
// Act
filter.OnActionExecuting(context);
// Assert
Assert.IsType<BadRequestObjectResult>(context.Result);
}
}
7.3 Testing Service Registration in DI
// Verify that services are registered correctly in Program.cs
public class ServiceRegistrationTests
{
[Fact]
public void AddEmployeeManagementServices_ShouldRegisterAllServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddEmployeeManagementServices(); // Extension method to test
// Act
var serviceProvider = services.BuildServiceProvider();
// Assert — Verify interfaces resolve
var employeeService = serviceProvider.GetService<IEmployeeService>();
Assert.NotNull(employeeService);
var employeeFactory = serviceProvider.GetService<EmployeeFactory>();
Assert.NotNull(employeeFactory);
}
}
8. CI/CD Integration
8.1 Running Tests from CLI
# Run all tests
dotnet test
# With code coverage report
dotnet test --collect:"XPlat Code Coverage"
# Filter by category (Trait)
dotnet test --filter "Category=Integration"
# Filter by test name
dotnet test --filter "FullyQualifiedName~EmployeeFactory"
# Tests in parallel between multiple projects
dotnet test --parallel
# For multiple target frameworks
dotnet test -f net8.0
dotnet test -f net9.0
# Report in specific format
dotnet test --logger "trx;LogFileName=results.trx"
dotnet test --logger "junit"
8.2 Azure DevOps Pipeline
# azure-pipelines.yml
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
stages:
- stage: Build
jobs:
- job: BuildAndTest
steps:
# Restore and build
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: '**/*.sln'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.sln'
arguments: '--configuration Release'
# Run tests with coverage
- task: DotNetCoreCLI@2
displayName: 'Test'
inputs:
command: 'test'
projects: '**/*.Test.csproj'
arguments: >
--configuration Release
--collect:"XPlat Code Coverage"
--logger "trx;LogFileName=TestResults.trx"
# Publish test results
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
# Publish coverage report
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage'
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
8.3 Framework: Test Runner vs Test Framework
| Concept | Examples | Role |
|---|---|---|
| Test Framework | xUnit, NUnit, MSTest | Attributes, lifecycle, assertions |
| Test Runner | dotnet test, Visual Studio Test Explorer, Rider | Discovery, execution, test reporting |
9. Summary and Best Practices
9.1 Test Naming Convention
[MethodUnderTest]_[Scenario]_[ExpectedBehavior]
Examples:
✅ CreateInternalEmployee_ValidInput_ShouldHaveAttendedCourses
✅ AddInternalEmployee_InvalidModelState_MustReturnBadRequest
✅ GiveRaise_MinimumRaiseGiven_MinimumRaiseGivenMustBeTrue
9.2 When to Use What?
graph TD
Need{Need?} -->|No external dependency| Unit["Pure unit test\n(no mock)"]
Need -->|Dependency to control| Mock["Mock with Moq"]
Need -->|Realistic DB| EFMem["EF Core InMemory\nor SQLite :memory:"]
Need -->|External HTTP| Handler["TestableHttpMessageHandler"]
Need -->|Shared expensive setup| Fixture["IClassFixture / ICollectionFixture"]
Need -->|Multiple data cases| Theory["[Theory] + InlineData/MemberData/ClassData"]
9.3 Test Data Isolation
sequenceDiagram
participant Test as Test
participant Mock as Mock IEmployeeService
participant SUT as Controller (SUT)
Test->>Mock: Setup(FetchAsync) ReturnsAsync(employee)
Test->>SUT: Inject mock into controller
Test->>SUT: Call the method
SUT->>Mock: FetchInternalEmployeeAsync(id)
Mock-->>SUT: InternalEmployee (predefined value)
SUT-->>Test: IActionResult
Test->>Test: Assert on ViewResult, ViewModel
Test->>Mock: Verify (optional)
9.4 Good Unit Test Checklist
- Isolated: does not depend on other tests or external resources
- Reproducible: same result on every execution
- Fast: under 100ms ideally
- Self-validating: no manual inspection required
- Well-named: name explains what is being tested and the expected result
- Single behavior per test: single logical assertion (not necessarily a single Assert line)
9.5 IClassFixture vs Constructor/Dispose Comparison
| Mechanism | Lifetime | Usage |
|---|---|---|
Constructor/Dispose | Per test | Lightweight setup/teardown |
IClassFixture<T> | Per class | Expensive setup, shared among tests in a class |
ICollectionFixture<T> | Per collection | Expensive setup, shared among multiple classes |
Deep Dive — Detailed Content by Section
5-A. Test Isolation and Mocking — Deep Dive
5-A.1 Complete Comparative Table of Test Doubles
| Type | Description | Interacts with SUT | Verifies Calls | Example |
|---|---|---|---|---|
| Fake | Simplified functional implementation | Yes — real calls | No | SQLite :memory:, in-memory list |
| Dummy | Passed but never used | No | No | null, new object() as irrelevant dependency |
| Stub | Returns preconfigured values | Yes — returns data | No | Mock<T>.Setup(...).Returns(...) without Verify |
| Spy | Records calls for later inspection | Yes | Yes — after the fact | Mock<T> + Verify after test |
| Mock | Stub + interaction verification | Yes | Yes — via Verify or MockBehavior.Strict | Strict Mock<T> |
Practical rule: prefer stubs for dependencies you only want return values from. Use mocks (with Verify) only to test that an interaction took place — it’s the behavior that matters, not the internal implementation.
5-A.2 Fake — SQLite In-Memory for EF Core
The EF Core InMemory provider does not support database constraints (foreign keys, uniqueness constraints). Prefer SQLite :memory: for more realistic tests.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
public class EmployeeManagementDbContextFactory : IDisposable
{
private SqliteConnection? _connection;
// Creates a DbContext with SQLite in-memory
public EmployeeManagementDbContext CreateDbContext()
{
if (_connection == null)
{
// IMPORTANT: connection must stay open for the DB to persist
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
}
var options = new DbContextOptionsBuilder<EmployeeManagementDbContext>()
.UseSqlite(_connection)
.Options;
var context = new EmployeeManagementDbContext(options);
// Create the schema
context.Database.EnsureCreated();
return context;
}
public void Dispose()
{
_connection?.Dispose();
}
}
// Usage in tests with IClassFixture
public class EmployeeRepositoryTests : IClassFixture<EmployeeManagementDbContextFactory>
{
private readonly EmployeeManagementDbContextFactory _dbContextFactory;
public EmployeeRepositoryTests(EmployeeManagementDbContextFactory dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
[Fact]
public async Task GetInternalEmployees_ReturnsAllInternalEmployees()
{
// Arrange
var dbContext = _dbContextFactory.CreateDbContext();
var repository = new EmployeeManagementRepository(dbContext);
// Act
var employees = await repository.GetInternalEmployeesAsync();
// Assert
Assert.NotNull(employees);
Assert.True(employees.Any());
}
[Fact]
public async Task AddInternalEmployee_ThenFetch_ShouldMatchSavedEmployee()
{
// Arrange
var dbContext = _dbContextFactory.CreateDbContext();
var repository = new EmployeeManagementRepository(dbContext);
var newEmployee = new InternalEmployee("Alice", "Martin", 2, 3500, false, 1);
// Act
repository.AddInternalEmployee(newEmployee);
await dbContext.SaveChangesAsync();
var fetched = await repository.GetInternalEmployeeAsync(newEmployee.Id);
// Assert
Assert.NotNull(fetched);
fetched.Should().BeEquivalentTo(newEmployee,
options => options.Excluding(e => e.AttendedCourses));
}
}
Important note: with SQLite
:memory:, as long as the connection remains open, the database persists. Once it’s closed, everything is lost. This is the desired behavior for tests: isolated and reproducible.
5-A.3 HttpClient — Complete PromotionEligibilityHandler
// The custom handler simulates the promotion API
public class PromotionEligibilityHandler : HttpMessageHandler
{
private readonly bool _isEligibleForPromotion;
public PromotionEligibilityHandler(bool isEligibleForPromotion)
{
_isEligibleForPromotion = isEligibleForPromotion;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var promotionEligibility = new PromotionEligibility
{
IsEligibleForPromotion = _isEligibleForPromotion
};
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(promotionEligibility),
Encoding.UTF8,
"application/json")
};
return Task.FromResult(response);
}
}
// Model returned by the API
public class PromotionEligibility
{
public bool IsEligibleForPromotion { get; set; }
}
// ============================================================
// TESTS
// ============================================================
public class PromotionServiceTests
{
[Fact]
public async Task IsEligibleForPromotion_EligibleEmployee_ShouldReturnTrue()
{
// Arrange — inject the handler that returns "eligible"
var handler = new PromotionEligibilityHandler(isEligibleForPromotion: true);
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://localhost:5000/")
};
var service = new PromotionService(httpClient);
// Act
var result = await service.IsEligibleForPromotionAsync(
Guid.Parse("7183748a-ebeb-4355-8084-f190f8a5a68f"));
// Assert
Assert.True(result);
}
[Fact]
public async Task IsEligibleForPromotion_NotEligibleEmployee_ShouldReturnFalse()
{
// Arrange — inject the handler that returns "not eligible"
var handler = new PromotionEligibilityHandler(isEligibleForPromotion: false);
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://localhost:5000/")
};
var service = new PromotionService(httpClient);
// Act
var result = await service.IsEligibleForPromotionAsync(
Guid.Parse("72f2f5fe-e50c-4966-8420-d50258aefdcb"));
// Assert
Assert.False(result);
}
}
sequenceDiagram
participant Test as Test
participant SUT as PromotionService (SUT)
participant Handler as PromotionEligibilityHandler
participant API as Real API (never called)
Test->>+SUT: IsEligibleForPromotionAsync(id)
SUT->>+Handler: SendAsync(request)
Note over Handler: Returns predefined JSON
Handler-->>-SUT: HttpResponseMessage (200 OK)
SUT-->>-Test: true / false
Note over API: Never contacted
5-A.4 Moq — Advanced Guide
MockBehavior: Strict vs Loose
// MockBehavior.Loose (default): unconfigured calls return default values
var looseMock = new Mock<IEmployeeService>();
// FetchInternalEmployeeAsync not configured → returns null without exception
// MockBehavior.Strict: any unconfigured call throws an exception
var strictMock = new Mock<IEmployeeService>(MockBehavior.Strict);
// FetchInternalEmployeeAsync not configured → MockException on call
// Useful to ensure no unexpected calls are made
Setup with Callback
var capturedEmployee = (InternalEmployee?)null;
employeeServiceMock
.Setup(m => m.AddInternalEmployee(It.IsAny<InternalEmployee>()))
.Callback<InternalEmployee>(emp =>
{
// Capture the passed argument for inspection after
capturedEmployee = emp;
});
// After calling the SUT:
Assert.Equal("Kevin", capturedEmployee?.FirstName);
Setup with Exception
employeeServiceMock
.Setup(m => m.GiveRaiseAsync(It.IsAny<InternalEmployee>(), It.Is<int>(r => r < 100)))
.ThrowsAsync(new EmployeeInvalidRaiseException(
"Raise is below the minimum allowed."));
Moq Matcher Table
| Matcher | Meaning |
|---|---|
It.IsAny<T>() | Any value of type T |
It.Is<T>(pred) | Value satisfying the predicate |
It.IsIn(list) | Value contained in the list |
It.IsNotIn(list) | Value not in the list |
It.IsInRange<T>(min, max, range) | Value in a range |
It.IsNull<T>() | Null value |
It.IsNotNull<T>() | Non-null value |
It.IsRegex(pattern) | String matching the regex |
6-A. MVC Controller Tests — Deep Dive
6-A.1 EmployeeManagement Application Architecture
graph TD
subgraph "Presentation Layer"
IC["InternalEmployeeController"]
EC["EmployeeOverviewController"]
end
subgraph "Business Layer"
ES["EmployeeService\n(IEmployeeService)"]
EF["EmployeeFactory"]
end
subgraph "Data Access Layer"
Repo["IEmployeeManagementRepository"]
DB["EF Core DbContext\n(SQLite / SQL Server)"]
end
IC --> ES
EC --> ES
ES --> EF
ES --> Repo
Repo --> DB
6-A.2 AutoMapper in Tests — Using the Real Mapper
Do not mock AutoMapper. If you mock IMapper, you don’t test the real mapping, and your tests won’t catch profile configuration errors.
// ✅ GOOD — Use the real mapper with real profiles
public class InternalEmployeeControllerTests
{
private readonly IMapper _mapper;
private readonly Mock<IEmployeeService> _employeeServiceMock;
private readonly InternalEmployeeController _controller;
public InternalEmployeeControllerTests()
{
_employeeServiceMock = new Mock<IEmployeeService>();
// Create the real AutoMapper configuration
var mapperConfiguration = new MapperConfiguration(cfg =>
{
cfg.AddProfile<EmployeeProfile>();
});
// Validate the configuration (throws if a mapping is misconfigured)
mapperConfiguration.AssertConfigurationIsValid();
_mapper = mapperConfiguration.CreateMapper();
_controller = new InternalEmployeeController(
_employeeServiceMock.Object,
_mapper,
null);
}
}
// ❌ BAD — Mocking IMapper hides mapping errors
var mapperMock = new Mock<IMapper>();
mapperMock.Setup(m => m.Map<InternalEmployeeDetailViewModel>(It.IsAny<InternalEmployee>()))
.Returns(new InternalEmployeeDetailViewModel()); // Returns empty object — no real mapping
7-A. Advanced Tests — Deep Dive
7-A.1 Security Middleware — EmployeeManagementSecurityHeadersMiddleware
// Middleware that adds security headers to each response
public class EmployeeManagementSecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public EmployeeManagementSecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Add security headers
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'");
context.Response.Headers.Append("Referrer-Policy", "no-referrer");
await _next(context);
}
}
// ============================================================
// MIDDLEWARE TESTS
// ============================================================
public class EmployeeManagementSecurityHeadersMiddlewareTests
{
[Fact]
public async Task InvokeAsync_ShouldAddXContentTypeOptionsHeader()
{
// Arrange
var context = new DefaultHttpContext();
RequestDelegate next = (ctx) => Task.CompletedTask;
var middleware = new EmployeeManagementSecurityHeadersMiddleware(next);
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(context.Response.Headers.ContainsKey("X-Content-Type-Options"));
Assert.Equal("nosniff",
context.Response.Headers["X-Content-Type-Options"].ToString());
}
[Fact]
public async Task InvokeAsync_ShouldCallNextMiddleware()
{
// Arrange
var context = new DefaultHttpContext();
var nextWasCalled = false;
RequestDelegate next = (ctx) =>
{
nextWasCalled = true;
return Task.CompletedTask;
};
var middleware = new EmployeeManagementSecurityHeadersMiddleware(next);
// Act
await middleware.InvokeAsync(context);
// Assert — the next middleware must have been called
Assert.True(nextWasCalled, "The next middleware was not called in the chain.");
}
}
7-A.2 Action Filter — CheckShowStatisticsHeader
// Filter that checks for the presence of a custom header
public class CheckShowStatisticsHeader : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.HttpContext.Request.Headers
.TryGetValue("ShowStatistics", out var showStatistics)
|| showStatistics != "true")
{
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
return;
}
base.OnActionExecuting(context);
}
}
// ============================================================
// FILTER TESTS
// ============================================================
public class CheckShowStatisticsHeaderTests
{
[Fact]
public void OnActionExecuting_WithHeader_ShouldNotSetResult()
{
// Arrange
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["ShowStatistics"] = "true";
var context = new ActionExecutingContext(
new ActionContext(httpContext, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()),
new List<IFilterMetadata>(),
new Dictionary<string, object?>(),
controller: new object());
var filter = new CheckShowStatisticsHeader();
// Act
filter.OnActionExecuting(context);
// Assert — no short-circuit, action continues
Assert.Null(context.Result);
}
[Fact]
public void OnActionExecuting_WithoutHeader_ShouldReturn403()
{
// Arrange
var context = new ActionExecutingContext(
new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor(), new ModelStateDictionary()),
new List<IFilterMetadata>(),
new Dictionary<string, object?>(),
controller: new object());
var filter = new CheckShowStatisticsHeader();
// Act
filter.OnActionExecuting(context);
// Assert — filter must short-circuit with 403
var statusResult = Assert.IsType<StatusCodeResult>(context.Result);
Assert.Equal(403, statusResult.StatusCode);
}
[Theory]
[InlineData("false")]
[InlineData("0")]
[InlineData("")]
[InlineData("yes")]
public void OnActionExecuting_WithInvalidHeaderValue_ShouldReturn403(string headerValue)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["ShowStatistics"] = headerValue;
var context = new ActionExecutingContext(
new ActionContext(httpContext, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()),
new List<IFilterMetadata>(),
new Dictionary<string, object?>(),
controller: new object());
var filter = new CheckShowStatisticsHeader();
filter.OnActionExecuting(context);
var statusResult = Assert.IsType<StatusCodeResult>(context.Result);
Assert.Equal(403, statusResult.StatusCode);
}
}
9-A. Summary and Best Practices — Deep Dive
9-A.1 Decision Matrix: What to Test?
| Element | Test? | Reason |
|---|---|---|
Business logic (EmployeeService) | ✅ Yes | It’s your code, you’re responsible |
Bonus calculation (CalculateSuggestedBonus) | ✅ Yes | Algorithm = regression risk |
Input validation (GiveRaiseAsync) | ✅ Yes | Critical business rules |
| AutoMapper mapping (Profiles) | ✅ Yes | Via AssertConfigurationIsValid() and functional tests |
Factory (EmployeeFactory) | ✅ Yes | Creates objects with precise rules |
| Security middleware | ✅ Yes | Critical security behavior |
| Action filters | ✅ Yes | Routing and validation logic |
| DI registration | ✅ Yes (smoke test) | Avoids resolution errors at startup |
| EF Core actions (generated SQL) | ❌ No | EF Core’s responsibility to test its own code |
| Database migrations | ❌ No | Infrastructure test, not unit |
| Auto-generated code | ❌ No | Not your code |
Simple getters/setters (Name { get; set; }) | ❌ No | Trivial, no logic |
| Thin controllers (pure orchestration) | ⚠️ Depends | If logic present → yes; if simple delegation → maybe not |
9-A.2 Test Naming Conventions
Method: [MethodUnderTest]_[Scenario]_[ExpectedBehavior]
Well-named examples:
✅ CreateEmployee_ExternalEmployee_MustReturnExternalEmployee
✅ GiveRaise_RaiseBelowMinimum_MustThrowEmployeeInvalidRaiseException
✅ FetchInternalEmployee_ValidId_ShouldReturnEmployeeWithCorrectSalary
✅ AddInternalEmployee_InvalidModelState_MustReturnBadRequest
✅ InvokeAsync_MissingSecurityHeader_ShouldReturn403
✅ RegisterDataServices_IEmployeeService_ShouldBeScoped
Poorly-named examples:
❌ Test1
❌ EmployeeTest
❌ ShouldWork
❌ CreateEmployee_Test
9-A.3 10 Tips for Better Unit Tests
-
Follow the AAA pattern — Arrange / Act / Assert clearly delineated in each test, separated by a blank line.
-
One behavior per test — Not necessarily a single assertion line, but a single logical validation per test.
-
Name tests like documentation —
CreateEmployee_ExternalEmployee_MustReturnExternalEmployeeexplains everything without reading the code. -
Don’t overuse
It.IsAny<T>()— When the specific argument matters, useIt.Is<T>(pred)or the concrete value. -
Verify exceptions with correct context — Use
Assert.Throws<T>(() => ...)orawait Assert.ThrowsAsync<T>(...)for async methods, and check the exception message if relevant. -
Use FluentAssertions for readability —
employee.Should().BeOfType<InternalEmployee>()is more readable and produces better error messages. -
Test edge cases — Null, empty, zero, maximum value, empty list, etc. are often sources of bugs.
-
Don’t share state between tests — Each test creates its own data. Use
IClassFixtureonly for read-only expensive resources. -
Keep tests fast — A unit test should run in under 100ms. If it takes longer, it’s probably a disguised integration test.
-
Run tests in CI before merging — A test that only runs locally is not a real test. The pipeline is the source of truth.
9-A.4 Quick Glossary
| Term | Definition |
|---|---|
| SUT | System Under Test — the code being tested |
| AAA | Arrange, Act, Assert — the three phases of a test |
| Fixture | Object shared between multiple tests (IClassFixture) |
| Test Double | Generic term for Fake, Dummy, Stub, Spy, Mock |
| Mock | Simulated object that verifies interactions |
| Stub | Simulated object that returns predefined values |
| Fake | Simplified functional implementation (e.g.: in-memory DB) |
| DI | Dependency Injection — inject dependencies to replace them in tests |
| TDD | Test-Driven Development — write the test before the code |
| Code Coverage | Percentage of code lines executed by tests |
| Regression | Bug introduced by a modification to existing code |
| Green-Red-Refactor | TDD cycle: red test → implementation → refactoring |
Search Terms
asp.net · unit · testing · core · mvc · web · application · patterns · architecture · c# · .net · development · test · tests · moq · deep · dive · a.1 · a.2 · data · isolation · middleware · mocking · a.3