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

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

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);
}
}
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
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.
[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 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);
}
}
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
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.

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:
Integration tests:
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.
Integration testing in ASP.NET Core requires the following components:
Integration tests typically follow the standard Arrange, Act, Assert (AAA) pattern:
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.
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:
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:
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.

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
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();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
[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
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.

You can download the source codes by clicking the button:
Keep unit tests and integration tests in separate projects. This separation offers several benefits:
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.