How to perform Unit and Integration Testing in Minimal APIs

How to perform Unit and Integration Testing in Minimal APIs

Testing is an essential part of developing ASP.NET Core Minimal APIs because it ensures that API endpoints behave correctly, reliably, and securely. Unit testing verifies the business logic of individual components, while integration testing confirms that endpoints, routing, middleware, dependency injection, and database interactions work together as expected. Regular testing helps identify bugs early, improves code quality, simplifies maintenance, and provides confidence when making changes or adding new features. As a result, testing contributes to building robust, scalable, and maintainable Minimal API applications.

In this tutorial we will be performing both Unit and Integration Testing in Minimal APIs.

ASP.NET Core Minimal API Project

On our last tutorial we created our ASP.NET Core Minimal API from Start till Finish. The Program.cs class creates a route group in an ASP.NET Core Minimal API and registers all endpoints related to the Works resource under the /works base URL.

No need to worry about the codes as you can download this project from the download button given at the bottom of this tutorial.
  • app.MapGroup(“/works”) creates a route group with the common prefix /works.
  • WorkAPIV1() & WorkAPIV2() are 2 custom extension method that maps all the endpoints (such as GET, POST, PUT, and DELETE) to that route group.
  • We have 2 sets of Minimal API for these 2 custom extension methods. The first starts with /works/v1 while second starts with /works/v2.
  • We also registered an In-Memory database and an interface called IWorkService.

See the code below.

using DailyWork;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddTransient<IWorkService, WorkService>();

builder.Services.AddDbContext<WorkDb>(opt => opt.UseInMemoryDatabase("WorkDatabase"));

var app = builder.Build();

app.MapGet("/", () => "Welcome to Minimal API Project");

app.MapGroup("/works/v1").WorkAPIV1();
app.MapGroup("/works/v2").WorkAPIV2();

app.Run();

The Work.cs class is:

public class Work
{
	public int Id { get; set; }
	public string Name { get; set; }

	public string TimeStart { get; set; }

	public string TimeEnd { get; set; }

	public bool IsComplete { get; set; }

    public string? Secret { get; set; }
}

Next, there is a static class called WorkEndpointV1 that contains the custom extension method called WorkAPIV1().

public static class WorkEndpointV1
{
    public static RouteGroupBuilder WorkAPIV1(this RouteGroupBuilder workGroup)
    {
        workGroup.MapPost("/", CreateWork)
                 .AddEndpointFilter(async (invocationContext, next) =>
                 {
                     var w = invocationContext.GetArgument<Work>(0);

                     var validationErrors = Utilities.IsValid(w);

                     if (validationErrors.Any())
                     {
                         return Results.ValidationProblem(validationErrors);
                     }

                     return await next(invocationContext);
                 });
        workGroup.MapGet("/", GetAllWork);
        workGroup.MapGet("/complete", GetCompletedWork);
        workGroup.MapGet("/{id}", GetWorkById);
        workGroup.MapPut("/{id}", UpdateWorkById);
        workGroup.MapPatch("/{id}", UpdateWorkByIdWithPatch);
        workGroup.MapDelete("/{id}", DeleteWorkById);

        return workGroup;
    }

    public static async Task<Created<Work>> CreateWork(Work work, WorkDb db)
    {
        db.Works.Add(work);
        await db.SaveChangesAsync();

        return TypedResults.Created($"/works/{work.Id}", work);
    }

    public static async Task<Ok<Work[]>> GetAllWork(WorkDb db)
    {
        return TypedResults.Ok(await db.Works.ToArrayAsync());
    }

    public static async Task<Ok<Work[]>> GetCompletedWork(WorkDb db)
    {
        return TypedResults.Ok(await db.Works.Where(t => t.IsComplete).ToArrayAsync());
    }

    public static async Task<Results<Ok<Work>, NotFound>> GetWorkById(int id, WorkDb db)
    {
        return await db.Works.FindAsync(id)
            is Work work
                ? TypedResults.Ok(work)
                : TypedResults.NotFound();
    }

    public static async Task<Results<NoContent, NotFound>> UpdateWorkById(int id, Work work, WorkDb db)
    {
        var w = await db.Works.FindAsync(id);

        if (w is null) return TypedResults.NotFound();

        w.Name = work.Name;
        w.TimeStart = work.TimeStart;
        w.TimeEnd = work.TimeEnd;
        w.IsComplete = work.IsComplete;

        await db.SaveChangesAsync();

        return TypedResults.NoContent();
    }
    public static async Task<IResult> UpdateWorkByIdWithPatch(int id, WorkDto workDto, WorkDb db)
    {
        var w = await db.Works.FindAsync(id);

        if (w is null) return TypedResults.NotFound();

        if (workDto.Name is not null) w.Name = workDto.Name;
        if (workDto.IsComplete is not null) w.IsComplete = workDto.IsComplete.Value;
        if (workDto.TimeStart is not null) w.TimeStart = workDto.TimeStart;
        if (workDto.TimeEnd is not null) w.TimeEnd = workDto.TimeEnd;

        await db.SaveChangesAsync();

        return TypedResults.NoContent();
    }

    public static async Task<Results<NoContent, NotFound>> DeleteWorkById(int id, WorkDb db)
    {
        if (await db.Works.FindAsync(id) is Work work)
        {
            db.Works.Remove(work);
            await db.SaveChangesAsync();
            return TypedResults.NoContent();
        }

        return TypedResults.NotFound();
    }
}

The above code simply performs the CRUD operations when the api GET, POST, PUT, and DELETE endpoints are called.

We also define a Utilities.cs class which is called by the filter for validating the Work class Name field. This makes sure that the Name field is not empty and should have more than 2 characters in length.

public static class Utilities
{
    public static Dictionary<string, string[]> IsValid(Work w)
    {
        Dictionary<string, string[]> errors = new();

        if (string.IsNullOrEmpty(w.Name))
        {
            errors.TryAdd("work.name.errors", new[] { "Name is empty" });
        }

        if (w.Name.Length < 3)
        {
            errors.TryAdd("work.name.errors", new[] { "Name length < 3" });
        }

        return errors;
    }
}

Moving to static class called WorkEndpointV2 that contains the custom extension method called WorkAPIV2(). See it’s code below:

public static class WorkEndpointV2
{
    public static RouteGroupBuilder WorkAPIV2(this RouteGroupBuilder workGroup)
    {
        workGroup.MapPost("/", CreateWork)
                 .AddEndpointFilter(async (invocationContext, next) =>
                 {
                     var w = invocationContext.GetArgument<Work>(0);

                     var validationErrors = Utilities.IsValid(w);

                     if (validationErrors.Any())
                     {
                         return Results.ValidationProblem(validationErrors);
                     }

                     return await next(invocationContext);
                 });
        workGroup.MapGet("/", GetAllWork);
        workGroup.MapGet("/complete", GetCompletedWork);
        workGroup.MapGet("/{id}", GetWorkById);
        workGroup.MapPut("/{id}", UpdateWorkById);
        workGroup.MapPatch("/{id}", UpdateWorkByIdWithPatch);
        workGroup.MapDelete("/{id}", DeleteWorkById);

        return workGroup;
    }
    public static async Task<Created<Work>> CreateWork(Work work, IWorkService workService)
    {
        await workService.Add(work);

        return TypedResults.Created($"/works/{work.Id}", work);
    }

    public static async Task<Ok<List<Work>>> GetAllWork(IWorkService workService)
    {
        return TypedResults.Ok(await workService.GetAll());
    }

    public static async Task<Ok<List<Work>>> GetCompletedWork(IWorkService workService)
    {
        return TypedResults.Ok(await workService.GetCompleteWork());
    }

    public static async Task<Results<Ok<Work>, NotFound>> GetWorkById(int id, IWorkService workService)
    {
        return await workService.Find(id)
            is Work work
                ? TypedResults.Ok(work)
                : TypedResults.NotFound();
    }

    public static async Task<Results<NoContent, NotFound>> UpdateWorkById(int id, Work work, IWorkService workService)
    {
        var w = await workService.Find(id);

        if (w is null) return TypedResults.NotFound();

        w.Name = work.Name;
        w.TimeStart = work.TimeStart;
        w.TimeEnd = work.TimeEnd;
        w.IsComplete = work.IsComplete;

        await workService.Update(w);

        return TypedResults.NoContent();
    }

    public static async Task<IResult> UpdateWorkByIdWithPatch(int id, WorkDto workDto, IWorkService workService)
    {
        var w = await workService.Find(id);

        if (w is null) return TypedResults.NotFound();

        if (workDto.Name is not null) w.Name = workDto.Name;
        if (workDto.IsComplete is not null) w.IsComplete = workDto.IsComplete.Value;
        if (workDto.TimeStart is not null) w.TimeStart = workDto.TimeStart;
        if (workDto.TimeEnd is not null) w.TimeEnd = workDto.TimeEnd;

        await workService.Update(w);

        return TypedResults.NoContent();
    }

    public static async Task<Results<NoContent, NotFound>> DeleteWorkById(int id, IWorkService workService)
    {
        if (await workService.Find(id) is Work work)
        {
            await workService.Remove(work);
            return TypedResults.NoContent();
        }

        return TypedResults.NotFound();
    }
}

It uses Repository Pattern to perform CRUD operation when API endpoints are called. For this we define an interface by the name of IWorkService as given below.

public interface IWorkService
{
    Task<List<Work>> GetAll();

    Task<List<Work>> GetCompleteWork();

    ValueTask<Work?> Find(int id);

    Task Add(Work w);

    Task Update(Work w);

    Task UpdatePatch(Work w);

    Task Remove(Work w);
}

The class WorkService.cs implements this interface. See code below.

public class WorkService : IWorkService
{
    private readonly WorkDb _dbContext;

    public WorkService(WorkDb dbContext)
    {
        _dbContext = dbContext;
    }

    public async ValueTask<Work?> Find(int id)
    {
        return await _dbContext.Works.FindAsync(id);
    }

    public async Task<List<Work>> GetAll()
    {
        return await _dbContext.Works.ToListAsync();
    }

    public async Task Add(Work work)
    {
        await _dbContext.Works.AddAsync(work);

        await _dbContext.SaveChangesAsync();
    }

    public async Task Update(Work work)
    {
        _dbContext.Works.Update(work);
        await _dbContext.SaveChangesAsync();
    }

    public async Task UpdatePatch(Work work)
    {
        _dbContext.Works.Update(work);
        await _dbContext.SaveChangesAsync();
    }

    public async Task Remove(Work work)
    {
        _dbContext.Works.Remove(work);
        await _dbContext.SaveChangesAsync();
    }

    public Task<List<Work>> GetCompleteWork()
    {
        return _dbContext.Works.Where(w => w.IsComplete == true).ToListAsync();
    }
}

The CRUD operation are performed by calling WorkService.cs” class like:

workService.Add(work); // CREATE RECORD
workService.GetAll() // READ RECORD
workService.Update(w); // UPDATE RECORD
workService.Remove(work) // DELETE RECORD

Performing Unit Testing in ASP.NET Core Minimal API

Unit testing is a software testing technique used to verify the correctness of individual components of an application in isolation. A unit typically represents the smallest testable part of the software, such as a single method, function, or class. The primary objective of unit testing is to ensure that each component performs its intended functionality independently, without relying on external systems such as databases, web services, or file systems. By isolating dependencies through techniques such as mocking or stubbing, developers can focus on validating the business logic of a specific unit.

In ASP.NET Core applications, unit tests are commonly written using testing frameworks such as xUnit. A dedicated test project is created to contain the test cases, and a test runner is used to discover, execute, and report the results of the tests.

Right click on the solution to add a new class project and name it DailyWorkTest. This project will contain the Unit Test cases.

Unit Test Minimal API

Now add the following packages to this newly added project. These packages are:

  • xUnit.net v3
  • Microsoft.NET.Test.Sdk
  • xunit.runner.visualstudio
  • Moq

xUnit Testing

Lets add unit test cases. So add a new class called WorkUnitTest.cs to this new project. In this class we will define our unit tests. See it’s code below:

public class WorkUnitTest
{
    [Fact]
    public async Task GetWorkByIdIfNotExists()
    {
        // Arrange
        await using var context = new MockDB().CreateDbContext();

        // Act
        var result = await WorkEndpointV1.GetWorkById(1, context);

        Assert.IsType<Results<Ok<Work>, NotFound>>(result);

        var notFoundResult = (NotFound)result.Result;

        Assert.NotNull(notFoundResult);
    }

    [Fact]
    public async Task GetAllWork()
    {
        // Arrange
        await using var context = new MockDB().CreateDbContext();

        context.Works.Add(new Work
        {
            Name = "Test Name 1",
            TimeStart = "Test TimeStart 1",
            TimeEnd = "Test TimeEnd 1",
            IsComplete = false
        });

        context.Works.Add(new Work
        {
            Name = "Test Name 2",
            TimeStart = "Test TimeStart 2",
            TimeEnd = "Test TimeEnd 2",
            IsComplete = true
        });

        await context.SaveChangesAsync();

        // Act
        var result = await WorkEndpointV1.GetAllWork(context);

        //Assert
        Assert.IsType<Ok<Work[]>>(result);

        Assert.NotNull(result.Value);
        Assert.NotEmpty(result.Value);
        Assert.Collection(result.Value, work1 =>
        {
            Assert.Equal(1, work1.Id);
            Assert.Equal("Test Name 1", work1.Name);
            Assert.False(work1.IsComplete);
        }, work2 =>
        {
            Assert.Equal(2, work2.Id);
            Assert.Equal("Test Name 2", work2.Name);
            Assert.True(work2.IsComplete);
        });
    }

    [Fact]
    public async Task GetCompletedWork()
    {
        // Arrange
        await using var context = new MockDB().CreateDbContext();

        context.Works.Add(new Work
        {
            Name = "Test Name 1",
            TimeStart = "Test TimeStart 1",
            TimeEnd = "Test TimeEnd 1",
            IsComplete = false
        });

        context.Works.Add(new Work
        {
            Name = "Test Name 2",
            TimeStart = "Test TimeStart 2",
            TimeEnd = "Test TimeEnd 2",
            IsComplete = true
        });

        await context.SaveChangesAsync();

        // Act
        var result = await WorkEndpointV1.GetCompletedWork(context);

        //Assert
        Assert.IsType<Ok<Work[]>>(result);

        Assert.NotNull(result.Value);
        Assert.NotEmpty(result.Value);
        Assert.Collection(result.Value, w =>
        {
            Assert.Equal(2, w.Id);
            Assert.Equal("Test Name 2", w.Name);
            Assert.True(w.IsComplete);
        });
    }

    [Fact]
    public async Task GetWorkById()
    {
        // Arrange
        await using var context = new MockDB().CreateDbContext();

        context.Works.Add(new Work
        {
            Name = "Test Name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        });

        await context.SaveChangesAsync();

        // Act
        var result = await WorkEndpointV1.GetWorkById(1, context);

        //Assert
        Assert.IsType<Results<Ok<Work>, NotFound>>(result);

        var okResult = (Ok<Work>)result.Result;

        Assert.NotNull(okResult.Value);
        Assert.Equal(1, okResult.Value.Id);
    }

    [Fact]
    public async Task CreateWork()
    {
        //Arrange
        await using var context = new MockDB().CreateDbContext();

        var newWork = new Work
        {
            Name = "Test Name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        //Act
        var result = await WorkEndpointV1.CreateWork(newWork, context);

        //Assert
        Assert.IsType<Created<Work>>(result);

        Assert.NotNull(result);
        Assert.NotNull(result.Location);

        Assert.NotEmpty(context.Works);
        Assert.Collection(context.Works, work =>
        {
            Assert.Equal(1, work.Id);
            Assert.Equal("Test Name", work.Name);
            Assert.Equal("Test TimeStart", work.TimeStart);
            Assert.Equal("Test TimeEnd", work.TimeEnd);
            Assert.False(work.IsComplete);
        });
    }

    [Fact]
    public async Task UpdateWork()
    {
        //Arrange
        await using var context = new MockDB().CreateDbContext();

        context.Works.Add(new Work
        {
            Name = "Exiting test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        });

        await context.SaveChangesAsync();

        var updatedWork = new Work
        {
            Name = "Updated test name",
            TimeStart = "Updated Test TimeStart",
            TimeEnd = "Updated Test TimeEnd",
            IsComplete = true
        };

        //Act
        var result = await WorkEndpointV1.UpdateWorkById(1, updatedWork, context);

        //Assert
        Assert.IsType<Results<NoContent, NotFound>>(result);

        var noContentResult = (NoContent)result.Result;

        Assert.NotNull(noContentResult);

        var workInDb = await context.Works.FindAsync(1);

        Assert.NotNull(workInDb);
        Assert.Equal("Updated test name", workInDb!.Name);
        Assert.True(workInDb.IsComplete);
    }

    [Fact]
    public async Task UpdateWorkByPatch()
    {
        //Arrange
        await using var context = new MockDB().CreateDbContext();

        context.Works.Add(new Work
        {
            Name = "Exiting test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        });

        await context.SaveChangesAsync();

        var updatedWork = new WorkDto
        {
            Name = "Updated test name",
            TimeStart = "Updated Test TimeStart",
            TimeEnd = "Updated Test TimeEnd",
            IsComplete = true
        };

        //Act
        var result = await WorkEndpointV1.UpdateWorkByIdWithPatch(1, updatedWork, context);

        //Assert
        Assert.IsType<NoContent>(result);

        var noContentResult = (NoContent)result;

        Assert.NotNull(noContentResult);

        var workInDb = await context.Works.FindAsync(1);

        Assert.NotNull(workInDb);
        Assert.Equal("Updated test name", workInDb!.Name);
        Assert.True(workInDb.IsComplete);
    }

    [Fact]
    public async Task DeleteWork()
    {
        //Arrange
        await using var context = new MockDB().CreateDbContext();

        var existingWork = new Work     
        {
            Id = 1,
            Name = "Existing test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        context.Works.Add(existingWork);

        await context.SaveChangesAsync();

        //Act
        var result = await WorkEndpointV1.DeleteWorkById(existingWork.Id, context);

        //Assert
        Assert.IsType<Results<NoContent, NotFound>>(result);

        var noContentResult = (NoContent)result.Result;

        Assert.NotNull(noContentResult);
        Assert.Empty(context.Works);
    }
}
Explanation

The above code contains unit test cases written in xUnit that test all the Minimal API endpoints. This includes creation of a new record, reading a record by its id, update a record and delete a record. Test cases when record is not found are also included.

In ASP.NET Core Minimal APIs, TypedResults are preferred over Results because they provide stronger typing, better testability, and automatic API documentation support. While both classes are used to return HTTP responses from API endpoints, TypedResults return specific response types (such as Ok, NotFound, or Created), making the endpoint’s behavior explicit and easier to validate.

One of the main advantages of using TypedResults is improved testability. Since each response has a concrete type, unit tests can directly verify the returned result without relying on generic interfaces or casting. For example, a test can assert that an endpoint returns TypedResults.Ok&RltWork> or TypedResults.NotFound, making the tests more readable, type-safe, and less prone to runtime errors.

See the method GetWorkById that uses TypedResults to return Ok(work) when record is found and NotFound() when record is not available in the database.

public static async Task<Results<Ok<Work>, NotFound>> GetWorkById(int id, WorkDb db)
{
    return await db.Works.FindAsync(id)
        is Work work
            ? TypedResults.Ok(work)
            : TypedResults.NotFound();
}

In it’s Unit Test case given below we assert both OK and NotFound very easily since we used the return type of Results<Ok, NotFound>.

[Fact]
public async Task GetWorkByIdIfNotExists()
{
    // Arrange
    await using var context = new MockDB().CreateDbContext();

    // Act
    var result = await WorkEndpointV1.GetWorkById(1, context);

    Assert.IsType<Results<Ok<Work>, NotFound>>(result);

    var notFoundResult = (NotFound)result.Result;

    Assert.NotNull(notFoundResult);
}

The following code uses the Ok class, and the value’s type is a collection of work.

[Fact]
public async Task GetWorkById()
{
    // Arrange
    await using var context = new MockDB().CreateDbContext();

    context.Works.Add(new Work
    {
        Name = "Test Name",
        TimeStart = "Test TimeStart",
        TimeEnd = "Test TimeEnd",
        IsComplete = false
    });

    await context.SaveChangesAsync();

    // Act
    var result = await WorkEndpointV1.GetWorkById(1, context);

    //Assert
    Assert.IsType<Results<Ok<Work>, NotFound>>(result);

    var okResult = (Ok<Work>)result.Result;

    Assert.NotNull(okResult.Value);
    Assert.Equal(1, okResult.Value.Id);
}

[Fact]
public async Task CreateWork()
{
    //Arrange
    await using var context = new MockDB().CreateDbContext();

    var newWork = new Work
    {
        Name = "Test Name",
        TimeStart = "Test TimeStart",
        TimeEnd = "Test TimeEnd",
        IsComplete = false
    };

    //Act
    var result = await WorkEndpointV1.CreateWork(newWork, context);

    //Assert
    Assert.IsType<Created<Work>>(result);

    Assert.NotNull(result);
    Assert.NotNull(result.Location);

    Assert.NotEmpty(context.Works);
    Assert.Collection(context.Works, work =>
    {
        Assert.Equal(1, work.Id);
        Assert.Equal("Test Name", work.Name);
        Assert.Equal("Test TimeStart", work.TimeStart);
        Assert.Equal("Test TimeEnd", work.TimeEnd);
        Assert.False(work.IsComplete);
    });
}

[Fact]
public async Task UpdateWork()
{
    //Arrange
    await using var context = new MockDB().CreateDbContext();

    context.Works.Add(new Work
    {
        Name = "Exiting test name",
        TimeStart = "Test TimeStart",
        TimeEnd = "Test TimeEnd",
        IsComplete = false
    });

    await context.SaveChangesAsync();

    var updatedWork = new Work
    {
        Name = "Updated test name",
        TimeStart = "Updated Test TimeStart",
        TimeEnd = "Updated Test TimeEnd",
        IsComplete = true
    };

    //Act
    var result = await WorkEndpointV1.UpdateWorkById(1, updatedWork, context);

    //Assert
    Assert.IsType<Results<NoContent, NotFound>>(result);

    var noContentResult = (NoContent)result.Result;

    Assert.NotNull(noContentResult);

    var workInDb = await context.Works.FindAsync(1);

    Assert.NotNull(workInDb);
    Assert.Equal("Updated test name", workInDb!.Name);
    Assert.True(workInDb.IsComplete);
}

Unit Testing using fakes or mock objects

Unit tests should verify the behavior of a single component without depending on external resources such as databases, web services, or file systems. To achieve this isolation, developers use fake or mock objects that replace the real dependencies of the component under test. This approach ensures that the test focuses only on the business logic and produces fast, reliable, and repeatable results.

A fake object is a simplified implementation of a dependency that provides predefined behavior. It is often created manually and is suitable for simple testing scenarios. For example, a fake repository can return hard-coded data instead of retrieving records from a database. While fake objects are easy to understand, they can become difficult to maintain as the number of test cases increases.

A mock object, on the other hand, is created using a mocking framework such as Moq, which is one of the most widely used mocking libraries for .NET applications. Moq allows developers to create mock implementations of interfaces or virtual classes dynamically, eliminating the need to write custom fake classes. Developers can configure mock objects to return specific values, throw exceptions, or verify that certain methods are called with the expected parameters.

In ASP.NET Core applications, Moq is commonly used to mock services, repositories, and other dependencies that are injected through the built-in Dependency Injection (DI) container. During unit testing, the real dependency is replaced with a mock object, allowing the component to be tested independently of external systems. This isolation makes tests deterministic and prevents failures caused by unavailable databases or network services.

One of the key advantages of Moq is its ability to verify interactions between objects. Besides returning predefined results, Moq can confirm that a method was invoked the expected number of times and with the correct arguments. This feature helps ensure that the component under test not only produces the correct output but also interacts correctly with its dependencies.

Using fake or mock objects with the Moq package provides several benefits, including faster test execution, better isolation of business logic, improved reliability, easier maintenance, and greater confidence during code refactoring. As a result, Moq has become a standard tool for writing effective unit tests in ASP.NET Core applications, enabling developers to build high-quality and maintainable software.

Next, add a new class called WorkUnitTestMoq.cs. This class contains test cases for testing with Moq objects. Recall the static class called WorkEndpointV2 contains the custom extension method called WorkAPIV2(). These contains API method that uses WorkService. We will fake this WorkService using Moq objects.

See it’s code below

public class WorkUnitTestMoq
{
    [Fact]
    public async Task GetWorkByIdIfNotExists()
    {
        // Arrange
        var mock = new Mock<IWorkService>();

        mock.Setup(m => m.Find(It.Is<int>(id => id == 1))).ReturnsAsync((Work?)null);

        // Act
        var result = await WorkEndpointV2.GetWorkById(1, mock.Object);

        //Assert
        Assert.IsType<Results<Ok<Work>, NotFound>>(result);

        var notFoundResult = (NotFound)result.Result;

        Assert.NotNull(notFoundResult);
    }

    [Fact]
    public async Task GetAllWork()
    {
        // Arrange
        var mock = new Mock<IWorkService>();

        mock.Setup(m => m.GetAll())
        .ReturnsAsync(new List<Work> {
            new Work
            {
                Id = 1,
                Name = "Test Name 1",
                TimeStart = "Test TimeStart 1",
                TimeEnd = "Test TimeEnd 1",
                IsComplete = false
            },
            new Work
            {
                Id = 2,
                Name = "Test Name 2",
                TimeStart = "Test TimeStart 2",
                TimeEnd = "Test TimeEnd 2",
                IsComplete = true
            }
        });

        // Act
        var result = await WorkEndpointV2.GetAllWork(mock.Object);

        //Assert
        Assert.IsType<Ok<List<Work>>>(result);

        Assert.NotNull(result.Value);
        Assert.NotEmpty(result.Value);
        Assert.Collection(result.Value, work1 =>
        {
            Assert.Equal(1, work1.Id);
            Assert.Equal("Test Name 1", work1.Name);
            Assert.False(work1.IsComplete);
        }, work2 =>
        {
            Assert.Equal(2, work2.Id);
            Assert.Equal("Test Name 2", work2.Name);
            Assert.True(work2.IsComplete);
        });
    }

    [Fact]
    public async Task GetCompletedWork()
    {
        // Arrange
        var mock = new Mock<IWorkService>();

        mock.Setup(m => m.GetCompleteWork())
        .ReturnsAsync(new List<Work> {
            new Work
            {
                Id = 2,
                Name = "Test Name 2",
                TimeStart = "Test TimeStart 2",
                TimeEnd = "Test TimeEnd 2",
                IsComplete = true
            }
        });

        // Act
        var result = await WorkEndpointV2.GetCompletedWork(mock.Object);

        //Assert
        Assert.IsType<Ok<List<Work>>>(result);

        Assert.NotNull(result.Value);
        Assert.NotEmpty(result.Value);
        Assert.Collection(result.Value, w =>
        {
            Assert.Equal(2, w.Id);
            Assert.Equal("Test Name 2", w.Name);
            Assert.True(w.IsComplete);
        });
    }

    [Fact]
    public async Task GetWorkById()
    {
        // Arrange
        var mock = new Mock<IWorkService>();

        mock.Setup(m => m.Find(It.Is<int>(id => id == 1)))
        .ReturnsAsync(new Work
        {
            Id = 1,
            Name = "Test Name 1",
            TimeStart = "Test TimeStart 1",
            TimeEnd = "Test TimeEnd 1",
            IsComplete = false
        });

        // Act
        var result = await WorkEndpointV2.GetWorkById(1, mock.Object);

        //Assert
        Assert.IsType<Results<Ok<Work>, NotFound>>(result);

        var okResult = (Ok<Work>)result.Result;

        Assert.NotNull(okResult.Value);
        Assert.Equal(1, okResult.Value.Id);
    }

    [Fact]
    public async Task CreateWork()
    {
        //Arrange
        var works = new List<Work>();

        var mock = new Mock<IWorkService>();

        var newWork = new Work
        {
            Id = 1,
            Name = "Test Name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        mock.Setup(m => m.Add(It.Is<Work>(t => t.Name == newWork.Name && t.TimeStart == newWork.TimeStart && t.TimeEnd == newWork.TimeEnd && t.IsComplete == newWork.IsComplete)))
        .Callback<Work>(a => works.Add(a))
        .Returns(Task.CompletedTask);

        //Act
        var result = await WorkEndpointV2.CreateWork(newWork, mock.Object);

        //Assert
        Assert.IsType<Created<Work>>(result);

        Assert.NotNull(result);
        Assert.NotNull(result.Location);

        Assert.NotEmpty(works);
        Assert.Collection(works, work =>
        {
            Assert.Equal(1, work.Id);
            Assert.Equal("Test Name", work.Name);
            Assert.Equal("Test TimeStart", work.TimeStart);
            Assert.Equal("Test TimeEnd", work.TimeEnd);
            Assert.False(work.IsComplete);
        });
    }

    [Fact]
    public async Task UpdateWork()
    {
        //Arrange
        var mock = new Mock<IWorkService>();

        var existingWork = new Work
        {
            Name = "Exiting test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        var updatedWork = new Work
        {
            Name = "Updated test name",
            TimeStart = "Updated Test TimeStart",
            TimeEnd = "Updated Test TimeEnd",
            IsComplete = true
        };

        mock.Setup(m => m.Find(It.Is<int>(id => id == 1)))
        .ReturnsAsync(existingWork);

        mock.Setup(m => m.Update(It.Is<Work>(t => t.Name == updatedWork.Name && t.TimeStart == updatedWork.TimeStart && t.TimeEnd == updatedWork.TimeEnd && t.IsComplete == updatedWork.IsComplete)))
            .Callback<Work>(w => updatedWork = w)
            .Returns(Task.CompletedTask);

        //Act
        var result = await WorkEndpointV2.UpdateWorkById(1, updatedWork, mock.Object);

        //Assert
        Assert.IsType<Results<NoContent, NotFound>>(result);

        var noContentResult = (NoContent)result.Result;

        Assert.NotNull(noContentResult);

        var work = await WorkEndpointV2.GetWorkById(1, mock.Object);
        var okResult = (Ok<Work>)work.Result;

        Assert.NotNull(work);
        Assert.Equal("Updated test name", okResult.Value.Name);
        Assert.True(okResult.Value.IsComplete);
    }

    [Fact]
    public async Task UpdateWorkByPatch()
    {
        //Arrange
        var mock = new Mock<IWorkService>();

        var existingWork = new Work
        {
            Id = 1,
            Name = "Exiting test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        var updatedWork = new WorkDto
        {
            Name = "Updated test name",
            TimeStart = "Updated Test TimeStart",
            TimeEnd = "Updated Test TimeEnd",
            IsComplete = true
        };

        mock.Setup(m => m.Find(It.Is<int>(id => id == 1)))
        .ReturnsAsync(existingWork);

        mock.Setup(m => m.UpdatePatch(It.Is<Work>(t =>
                t.Name == updatedWork.Name &&
                t.TimeStart == updatedWork.TimeStart &&
                t.TimeEnd == updatedWork.TimeEnd &&
                t.IsComplete == updatedWork.IsComplete)))
                .Callback<Work>(w =>
                {
                    // copy fields from Work to WorkDto for later assertions
                    updatedWork.Name = w.Name;
                    updatedWork.TimeStart = w.TimeStart;
                    updatedWork.TimeEnd = w.TimeEnd;
                    updatedWork.IsComplete = w.IsComplete;
                })
                .Returns(Task.CompletedTask);

        //Act
        var result = await WorkEndpointV2.UpdateWorkByIdWithPatch(1, updatedWork, mock.Object);

        //Assert
        Assert.IsType<NoContent>(result);

        var noContentResult = (NoContent)result;

        Assert.NotNull(noContentResult);

        var work = await WorkEndpointV2.GetWorkById(1, mock.Object);
        var okResult = (Ok<Work>)work.Result;

        Assert.NotNull(work);
        Assert.Equal("Updated test name", okResult.Value.Name);
        Assert.True(okResult.Value.IsComplete);
    }

    [Fact]
    public async Task DeleteWork()
    {
        //Arrange
        var mock = new Mock<IWorkService>();

        var existingWork = new Work
        {
            Id = 1,
            Name = "Existing test name",
            TimeStart = "Test TimeStart",
            TimeEnd = "Test TimeEnd",
            IsComplete = false
        };

        var works = new List<Work> { existingWork };

        mock.Setup(m => m.Find(It.Is<int>(id => id == existingWork.Id)))
        .ReturnsAsync(existingWork);

        mock.Setup(m => m.Remove(It.Is<Work>(t => t.Id == 1)))
            .Callback<Work>(t => works.Remove(t))
            .Returns(Task.CompletedTask);

        //Act
        var result = await WorkEndpointV2.DeleteWorkById(existingWork.Id, mock.Object);

        //Assert
        Assert.IsType<Results<NoContent, NotFound>>(result);

        var noContentResult = (NoContent)result.Result;

        Assert.NotNull(noContentResult);
        Assert.Empty(works);
    }
}
Explanation:

In place of WorkService object we fake it with a Moq object:

var mock = new Mock<IWorkService>();

We then use this fake object in test cases like:

mock.Setup(m => m.Find(It.Is<int>(id => id == 1))).ReturnsAsync((Work?)null);
var result = await WorkEndpointV2.GetWorkById(1, mock.Object);

It.Is(id => id == 1) is an argument matcher that tells Moq to apply this configuration only when the Find() method is called with the value 1.

See the code of GetAllWork() test case where Moq object returns 2 work records whenever the WorkService needs to be called.

var mock = new Mock<IWorkService>();

mock.Setup(m => m.GetAll())
.ReturnsAsync(new List<Work> {
    new Work
    {
        Id = 1,
        Name = "Test Name 1",
        TimeStart = "Test TimeStart 1",
        TimeEnd = "Test TimeEnd 1",
        IsComplete = false
    },
    new Work
    {
        Id = 2,
        Name = "Test Name 2",
        TimeStart = "Test TimeStart 2",
        TimeEnd = "Test TimeEnd 2",
        IsComplete = true
    }
});

var result = await WorkEndpointV2.GetAllWork(mock.Object);

In the Test Explorer run all the test and they all passed successfully. See it’s image below.

Minimal API Unit Test passed

Integration Tests in ASP.NET Core Minimal API

Integration tests evaluate how different components of an application work together, providing broader coverage than unit tests. While unit tests focus on isolated pieces of code—such as individual methods or classes—integration tests verify that multiple components interact correctly to produce the expected outcome. In some cases, they test the complete workflow required to process a request from start to finish.

Because of their broader scope, integration tests validate the application’s infrastructure and overall framework. They often involve real components such as:

  • Databases
  • File systems
  • Network services
  • The request-response pipeline

Integration tests:

  1. Use the same components that the application relies on in production.
  2. Require more code execution and data processing.
  3. Take longer to run due to their broader scope.

For this reason, integration tests should be reserved for the most critical infrastructure scenarios. Whenever a behavior can be validated with either a unit test or an integration test, prefer a unit test because it is faster, simpler, and more focused.

Requirements for Integration Tests

Integration testing in ASP.NET Core requires the following components:

  • A test project that contains and executes the integration tests. This project references the System Under Test (SUT).
  • A test web host and test server client that host the SUT and send HTTP requests while receiving responses.
  • A test runner to execute the tests and report the results.
Integration Test Workflow

Integration tests typically follow the standard Arrange, Act, Assert (AAA) pattern:

  1. Configure the SUT’s test web host.
  2. Create a test server client to communicate with the application.
  3. Arrange: Prepare the test data and HTTP request.
  4. Act: Send the request and capture the response.
  5. Assert: Verify that the actual response matches the expected result.
  6. Repeat the process until all tests have been executed.
  7. Generate and report the test results.
Test Host Configuration

The test web host is usually configured differently from the application’s production host. For example, integration tests often use a separate database, custom configuration settings, or mocked external services to provide a controlled testing environment.

Microsoft.AspNetCore.Mvc.Testing

The Microsoft.AspNetCore.Mvc.Testing package simplifies integration testing by providing the infrastructure needed to host and test an ASP.NET Core application. It manages the test web host and the in-memory TestServer, reducing the amount of setup code required.

Key features of the package include:

  • Copies the SUT’s dependency (.deps) file to the test project’s output directory.
  • Sets the content root to the SUT’s project directory so that static files, Razor pages, and views are available during testing.
  • Provides the WebApplicationFactory class, which simplifies bootstrapping the application with TestServer.

Creating a new Project for Integration Test

We create a new Class Project called IntegrationTest. Here we will write our Integration Tests for the Minimal API. First of all add the following 4 packages to this project:

  • Microsoft.AspNetCore.Mvc.Testing
  • xunit.v3
  • xunit.runner.visualstudio
  • Microsoft.NET.Test.Sdk
  • Microsoft.EntityFrameworkCore.Sqlite

Since the integration tests will use SQLite database so we have added Microsoft.EntityFrameworkCore.Sqlite package which is SQLite database provider for Entity Framework Core.

Integration Test Packages

Create a new folder called “Helpers”, to this folder a new class called TestWebApplicationFactory.cs. This class is a custom test host factory for ASP.NET Core integration tests. Its primary job is to replace the application’s normal database configuration with a test database and ensure that database starts in a clean state before tests run.

public class TestWebApplicationFactory<TProgram>
    : WebApplicationFactory<TProgram> where TProgram : class
{
    protected override IHost CreateHost(IHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<IDbContextOptionsConfiguration<WorkDb>>();

            // Register WorkDb for tests using Sqlite. Do not call UseInternalServiceProvider; let EF manage its service provider.
            services.AddDbContext<WorkDb>((serviceProvider, options) =>
            {
                var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
                options.UseSqlite($"Data Source={Path.Join(path, "Integration_tests.db")}");
            });

            // Build the provider
            var serviceProvider = services.BuildServiceProvider();

            // Create a scope
            using var scope = serviceProvider.CreateScope();

            var context = scope.ServiceProvider.GetRequiredService<WorkDb>();
            context.Database.EnsureDeleted(); // Ensure a clean database for each test run
            context.Database.EnsureCreated(); // Ensure the database is created
        });

        return base.CreateHost(builder);
    }
}

TestWebApplicationFactory is a custom integration test factory that extends WebApplicationFactory to configure the application specifically for testing. When the test host is created, it replaces the application’s existing WorkDb database configuration with a SQLite database stored in a local file, preventing tests from using the production database. It then builds a temporary service provider to obtain a WorkDb instance, deletes any existing test database, and recreates it to ensure a clean, consistent database state before the application starts. This allows integration tests to run against an isolated database with a predictable schema and no leftover data from previous test runs.

Notice it remove the existing database configuration.

services.RemoveAll<IDbContextOptionsConfiguration<WorkDb>>();

Then registers a SQLite test database.

services.AddDbContext<WorkDb>((serviceProvider, options) =>
{
    var path = Environment.GetFolderPath(
        Environment.SpecialFolder.LocalApplicationData);

    options.UseSqlite(
        $"Data Source={Path.Join(path, "Integration_tests.db")}");
});

Deletes and then recreates a fresh database copy.

context.Database.EnsureDeleted();
context.Database.EnsureCreated();
Overall flow
Test starts
      │
      ▼
CreateHost()
      │
      ▼
Remove production WorkDb configuration
      │
      ▼
Register SQLite WorkDb
      │
      ▼
Delete Integration_tests.db
      │
      ▼
Create new Integration_tests.db
      │
      ▼
Build ASP.NET Core application
      │
      ▼
Tests run against clean SQLite database
WorkEndpointsV1Tests.cs containing the 2 integration tests.

[Collection("Sequential")]
public class WorkEndpointsV1Tests : IClassFixture<TestWebApplicationFactory<Program>>
{
    private readonly TestWebApplicationFactory<Program> _factory;
    private readonly HttpClient _httpClient;

    public WorkEndpointsV1Tests(TestWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _httpClient = factory.CreateClient();
    }

    public static IEnumerable<object[]> InvalidWorks => new List<object[]>
    {
        new object[] { new WorkDto { Name = "", TimeStart = "Test Time Start", TimeEnd = "Test Time End", IsComplete = false }, "Name is empty" },
        new object[] { new WorkDto { Name = "no", TimeStart = "Test Time Start", TimeEnd = "Test Time End", IsComplete = false }, "Name length < 3" }
    };

    [Theory]
    [MemberData(nameof(InvalidWorks))]
    public async Task PostWorkWithValidationProblems(WorkDto work, string errorMessage)
    {
        var response = await _httpClient.PostAsJsonAsync("/works/v1", work);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

        var problemResult = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();

        Assert.NotNull(problemResult?.Errors);
        Assert.Collection(problemResult.Errors, (error) => Assert.Equal(errorMessage, error.Value.First()));
    }

    [Fact]
    public async Task PostWorkWithValidParameters()
    {
        using (var scope = _factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetService<WorkDb>();
        }

        var response = await _httpClient.PostAsJsonAsync("/works/v1", new WorkDto
        {
            Name = "Test Name",
            TimeStart = "Test Time Start",
            TimeEnd = "Test Time End",
            IsComplete = false
        });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var works = await _httpClient.GetFromJsonAsync<List<Work>>("/works/v1");

        Assert.NotNull(works);
        Assert.Single(works);

        Assert.Collection(works, (work) =>
        {
            Assert.Equal("Test Name", work.Name);
            Assert.Equal("Test Time Start", work.TimeStart);
            Assert.Equal("Test Time End", work.TimeEnd);
            Assert.False(work.IsComplete);
        });
    }
}

WorkEndpointsV1Tests is an integration test class that verifies the behavior of the version 1 Work API endpoints by interacting with a running instance of the application created by TestWebApplicationFactory. The class uses xUnit’s IClassFixture to share a single test application instance and HttpClient to send HTTP requests to the API, closely simulating real client interactions. The [Collection(“Sequential”)] attribute ensures that the tests execute sequentially, preventing conflicts caused by sharing the same SQLite test database. The test suite validates both successful and unsuccessful API behavior: parameterized tests ([Theory]) verify that invalid input data returns a 400 Bad Request response with the expected validation errors, while functional tests ([Fact]) confirm that valid requests create new work items, return a 201 Created response, and persist the data correctly by retrieving and verifying it through the API. This approach ensures that endpoint routing, request validation, database interaction, and response generation all work together as expected in an end-to-end testing environment.

We also add another class called WorkEndpointsV2Tests.cs whose work is to test the version 2 Work API endpoints. This class is very similar to the previous class. It’s code is given below.

[Collection("Sequential")]
public class WorkEndpointsV2Tests : IClassFixture<TestWebApplicationFactory<Program>>
{
    private readonly TestWebApplicationFactory<Program> _factory;
    private readonly HttpClient _httpClient;

    public WorkEndpointsV2Tests(TestWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _httpClient = factory.CreateClient();
    }

    public static IEnumerable<object[]> InvalidWorks => new List<object[]>
    {
        new object[] { new WorkDto { Name = "", TimeStart = "Test Time Start", TimeEnd = "Test Time End", IsComplete = false }, "Name is empty" },
        new object[] { new WorkDto { Name = "no", TimeStart = "Test Time Start", TimeEnd = "Test Time End", IsComplete = false }, "Name length < 3" }
    };

    [Theory]
    [MemberData(nameof(InvalidWorks))]
    public async Task PostWorkWithValidationProblems(WorkDto work, string errorMessage)
    {
        var response = await _httpClient.PostAsJsonAsync("/works/v2", work);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

        var problemResult = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();

        Assert.NotNull(problemResult?.Errors);
        Assert.Collection(problemResult.Errors, (error) => Assert.Equal(errorMessage, error.Value.First()));
    }

    [Fact]
    public async Task PostWorkWithValidParameters()
    {
        using (var scope = _factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetService<WorkDb>();
        }

        var response = await _httpClient.PostAsJsonAsync("/works/v2", new WorkDto
        {
            Name = "Test Name",
            TimeStart = "Test Time Start",
            TimeEnd = "Test Time End",
            IsComplete = false
        });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var works = await _httpClient.GetFromJsonAsync<List<Work>>("/works/v2");

        Assert.NotNull(works);
        Assert.Single(works);

        Assert.Collection(works, (work) =>
        {
            Assert.Equal("Test Name", work.Name);
            Assert.Equal("Test Time Start", work.TimeStart);
            Assert.Equal("Test Time End", work.TimeEnd);
            Assert.False(work.IsComplete);
        });
    }
}

It’s time we run the Integration tests in the Test Explorer and congrats they all pass. Check the below image.

Integration Tests Successful

You can download the source codes by clicking the button:

Download

Organizing Test Projects

Keep unit tests and integration tests in separate projects. This separation offers several benefits:

  • Prevents integration testing infrastructure from being included in unit test projects.
  • Makes it easier to run unit tests or integration tests independently.
  • Improves project organization and maintainability.
Conclusion

In this tutorial we learned how to perform Unit and Integration test for ASP.NET Core Minimal APIs. We covered almost all aspects of testing and hope our efforts are satisfactory. If you have any questions then feel free to use the comment’s section below.

SHARE THIS ARTICLE

  • linkedin
  • reddit
yogihosting

ABOUT THE AUTHOR

I hope you enjoyed reading this tutorial. If it helped you then consider buying a cup of coffee for me. This will help me in writing more such good tutorials for the readers. Thank you. Buy Me A Coffee donate

Leave a Reply

Your email address will not be published. Required fields are marked *