Intermediate

Unit Testing an ASP.NET Core MVC Web Application

Unit test MVC controllers with xUnit, Moq and FluentAssertions — isolation, mocking and CI/CD.

Demo application: EmployeeManagement (ASP.NET Core MVC, xUnit, Moq, FluentAssertions)


Table of Contents

  1. Introduction to Unit Testing
  2. Basic Test Scenarios
  3. Organization and Execution Control
  4. Data-Driven Tests
  5. Test Isolation and Mocking
  6. Testing MVC Controllers
  7. Advanced Tests (Middleware, Filters, Services)
  8. CI/CD Integration
  9. 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

RolePackage
Test frameworkxUnit
Expressive assertions libraryFluentAssertions
MockingMoq
Test runnerdotnet 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

AttributeUsage
[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

ConceptExamplesRole
Test FrameworkxUnit, NUnit, MSTestAttributes, lifecycle, assertions
Test Runnerdotnet test, Visual Studio Test Explorer, RiderDiscovery, 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

MechanismLifetimeUsage
Constructor/DisposePer testLightweight setup/teardown
IClassFixture<T>Per classExpensive setup, shared among tests in a class
ICollectionFixture<T>Per collectionExpensive 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

TypeDescriptionInteracts with SUTVerifies CallsExample
FakeSimplified functional implementationYes — real callsNoSQLite :memory:, in-memory list
DummyPassed but never usedNoNonull, new object() as irrelevant dependency
StubReturns preconfigured valuesYes — returns dataNoMock<T>.Setup(...).Returns(...) without Verify
SpyRecords calls for later inspectionYesYes — after the factMock<T> + Verify after test
MockStub + interaction verificationYesYes — via Verify or MockBehavior.StrictStrict 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

MatcherMeaning
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?

ElementTest?Reason
Business logic (EmployeeService)✅ YesIt’s your code, you’re responsible
Bonus calculation (CalculateSuggestedBonus)✅ YesAlgorithm = regression risk
Input validation (GiveRaiseAsync)✅ YesCritical business rules
AutoMapper mapping (Profiles)✅ YesVia AssertConfigurationIsValid() and functional tests
Factory (EmployeeFactory)✅ YesCreates objects with precise rules
Security middleware✅ YesCritical security behavior
Action filters✅ YesRouting and validation logic
DI registration✅ Yes (smoke test)Avoids resolution errors at startup
EF Core actions (generated SQL)❌ NoEF Core’s responsibility to test its own code
Database migrations❌ NoInfrastructure test, not unit
Auto-generated code❌ NoNot your code
Simple getters/setters (Name { get; set; })❌ NoTrivial, no logic
Thin controllers (pure orchestration)⚠️ DependsIf 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

  1. Follow the AAA pattern — Arrange / Act / Assert clearly delineated in each test, separated by a blank line.

  2. One behavior per test — Not necessarily a single assertion line, but a single logical validation per test.

  3. Name tests like documentationCreateEmployee_ExternalEmployee_MustReturnExternalEmployee explains everything without reading the code.

  4. Don’t overuse It.IsAny<T>() — When the specific argument matters, use It.Is<T>(pred) or the concrete value.

  5. Verify exceptions with correct context — Use Assert.Throws<T>(() => ...) or await Assert.ThrowsAsync<T>(...) for async methods, and check the exception message if relevant.

  6. Use FluentAssertions for readabilityemployee.Should().BeOfType<InternalEmployee>() is more readable and produces better error messages.

  7. Test edge cases — Null, empty, zero, maximum value, empty list, etc. are often sources of bugs.

  8. Don’t share state between tests — Each test creates its own data. Use IClassFixture only for read-only expensive resources.

  9. Keep tests fast — A unit test should run in under 100ms. If it takes longer, it’s probably a disguised integration test.

  10. 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

TermDefinition
SUTSystem Under Test — the code being tested
AAAArrange, Act, Assert — the three phases of a test
FixtureObject shared between multiple tests (IClassFixture)
Test DoubleGeneric term for Fake, Dummy, Stub, Spy, Mock
MockSimulated object that verifies interactions
StubSimulated object that returns predefined values
FakeSimplified functional implementation (e.g.: in-memory DB)
DIDependency Injection — inject dependencies to replace them in tests
TDDTest-Driven Development — write the test before the code
Code CoveragePercentage of code lines executed by tests
RegressionBug introduced by a modification to existing code
Green-Red-RefactorTDD 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.