Search
K
  1. Integration/Unit Testing

Integration/Unit Testing

Integration Testing

NOTE

This document is still a work-in-progress. Please check back soon...

You can have a look at the test project here in the meantime to get a quick idea.

Route-less Testing

The recommended approach to test your endpoints is to perform integration testing using the WebApplicationFactory. You will first need to install the following package.

terminal
dotnet add package Microsoft.AspNetCore.Mvc.Testing

FastEndpoints comes with a set of extension methods for the HttpClient to make testing more convenient in a strongly-typed and route-less manner. I.e. you don't need to specify the route URLs when testing endpoints.

The structure of the project we will be testing looks like this:

project structure

TIP

We won't be going over the details on how to build the actual solution. Instead will focus mainly on the tests.

Follow the steps below to start WAF testing your endpoints:

Example Project Setup

The examples below will use the following NuGet packages, they are recommendations but not strictly required. Install them in your test project.

terminal
dotnet add package Bogus # used to generate realistic looking fake data
dotnet add package FluentAssertions # used to write prettier assert statements
dotnet add package Testcontainers # used to auto-start docker containers

With the Nuget packages in place we can start creating our test suite. We will keep this simple for now, but will expand on it further down the line.

One of the first things we'll be doing is implement a custom WebApplicationFactory. Our basic implementation looks like this:

ApiWebFactory.cs
namespace Api.Test.Integration;

// We use the "IApiMarker" interface to reference our API project.
// We also implement the "IAsyncLifetime" interface to properly initialize and dispose of our used services.
public class ApiWebFactory : WebApplicationFactory<IApiMarker>, IAsyncLifetime
{
    // This defines our database container that will get spun up automatically for us for each test
    private readonly TestcontainerDatabase _database = new TestcontainersBuilder<PostgreSqlTestcontainer>()
        .WithDatabase(new PostgreSqlTestcontainerConfiguration
        {
            Database = "testDb",
            Username = "testUser",
            Password = "doesnt_matter"
        }).Build();

    // We set up our test API server with this override
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // We disable any logging-providers for our test.
        builder.ConfigureLogging(logging => logging.ClearProviders());
        
        // We configure our services for testing
        builder.ConfigureTestServices(services =>
        {
            // remove any DbContextOptions registrations
            var descriptor = services.SingleOrDefault(d =>
                d.ServiceType == typeof(DbContextOptions<ApiDbContext>));
            
            // Remove any DbContext registrations
            services.RemoveAll(typeof(ApiDbContext));
            
            // Register our DbContext with the test DB connection string provided from our container
            services.AddPersistence(_database.ConnectionString);
        });
    }
    
    public async Task InitializeAsync()
    {
        // Start up our Docker container with the Postgres DB
        await _database.StartAsync();
    }

    public async Task DisposeAsync()
    {
        // Stop our Docker container with the Postgres DB
        await _database.DisposeAsync();
    }
}

With this in place we can start with writing our first test. We will start with the CreateUserEndpoint:

CreateUserEndpoint.cs
namespace Api.Features.Users.CreateUser;

public class Endpoint : Endpoint<CreateUserRequest, CreateUserResponse, Mapper>
{
    private readonly ApiDbContext _dbContext;

    public Endpoint(ApiDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public override void Configure()
    {
        Post("users");
        AllowAnonymous();
    }

    public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
    {
        var isAlreadyPresent = await _dbContext.Users.AnyAsync(x => x.Email == req.Email, ct);

        if (isAlreadyPresent)
        {
            Logger.LogInformation("User with this mail is already present");
            AddError(e => e.Email, $"A user with this mail is already present");
        }
        
        ThrowIfAnyErrors();

        var user = Map.ToEntity(req);
        user = _dbContext.Users.Add(user).Entity;
        await _dbContext.SaveChangesAsync(ct);
        
        Logger.LogInformation("User created, Id = '{userId}'", user.Id);

        var response = Map.FromEntity(user);

        await SendCreatedAtAsync<GetUser.Endpoint>(new { id = response.Id}, response);
    }
}

The tests look something like the following. Do note that many more tests should be written to properly cover the endpoint, but this should get you started:

namespace Api.Test.Integration.Features.Users;

// We use the "ApiWebFactory" as a "IClassFixture<TFixture>" to get it into the tests.
public class CreateUserEndpointTests : IClassFixture<ApiWebFactory>
{
    private readonly ApiWebFactory _apiWebFactory;
    private readonly HttpClient _client;

    // With this `Faker` from `Bogus` we will create realistic looking test-data
    private readonly Faker<CreateUserRequest> _userRequestGenerator = new()
        .RuleFor(x => x.FirstName, faker => faker.Name.FirstName())
        .RuleFor(x => x.LastName, faker => faker.Name.LastName())
        .RuleFor(x => x.Email, faker => faker.Internet.Email());

    public CreateUserEndpointTests(ApiWebFactory apiWebFactory)
    {
        _apiWebFactory = apiWebFactory;
        _client = apiWebFactory.CreateClient();
    }

    [Fact]
    public async Task User_with_valid_data_is_created()
    {
        // Arrange
        // Generate a realistic looking user request with `Bogus`
        var user = _userRequestGenerator.Generate();
        
        // Act
        // Executing a `POST` call to the `CreateUserEndpoint`, note that we use the extension method `POSTAsync` for this.
        // `POSTAsync` comes from FastEndpoints and allows to easily call the endpoint by targeting the `Endpoint` class 
        // in one of the generic parameters. It also returns the `HttpResponseMessage` and the actual JSON return type `CreateUserResponse`
         var (response, result) = await _client
             .POSTAsync<Endpoint, CreateUserRequest, CreateUserResponse>(user);

        // Assert
        response.Should().NotBeNull();
        response!.StatusCode.Should().Be(HttpStatusCode.Created);
        result.Should().NotBeNull();
        result!.Id.Should().Be(1);
    }

    [Fact]
    public async Task User_with_invalid_mail_is_rejected()
    {
        // Arrange
        const string invalidEmail = "invalidEmail";
        var user = _userRequestGenerator.Clone()
            .RuleFor(x => x.Email, invalidEmail)
            .Generate();

        // Act
        var (response, result) = await _client
            .POSTAsync<Endpoint, CreateUserRequest, ErrorResponse>(user);

        // Assert
        response.Should().NotBeNull();
        response!.StatusCode.Should().Be(HttpStatusCode.BadRequest);
        result.Should().NotBeNull();
        result!.Errors.Keys.Should().Contain(nameof(User.Email));
    }
}

Unit Testing

Endpoint Testing With FakeItEasy

If you don't mind the extra effort needed for more granular testing with unit tests, you may use the Factory.Create<TEndpoint>() method to get an instance of your endpoint which is suitable for unit testing.

[TestMethod]
public async Task AdminLoginSuccess()
{
    // Arrange
    var fakeConfig = A.Fake<IConfiguration>();
    A.CallTo(() => fakeConfig["TokenKey"]).Returns("0000000000000000");

    var ep = Factory.Create<AdminLogin>(
        A.Fake<ILogger<AdminLogin>>(), //mock dependencies for injecting to the constructor
        A.Fake<IEmailService>(),
        fakeConfig);

    var req = new AdminLoginRequest
    {
        UserName = "admin",
        Password = "pass"
    };

    // Act
    await ep.HandleAsync(req, default);
    var rsp = ep.Response;

    // Assert
    Assert.IsNotNull(rsp);
    Assert.IsFalse(ep.ValidationFailed);
    Assert.IsTrue(rsp.Permissions.Contains("Inventory_Delete_Item"));
}

Use the Factory.Create() method by passing it the mocked dependencies which are needed by the endpoint constructor, if there's any. It has multiple overloads that enables you to instantiate endpoints with or without constructor arguments.

Then simply execute the handler by passing in a request dto and a default cancellation token.

Finally do your assertions on the Response property of the endpoint instance.

Response DTO Returning Handler

If you prefer to return the dto object from your handler, you can implement the ExecuteAsync() method instead of HandleAsync() like so:

public class AdminLogin : Endpoint<LoginRequest, LoginResponse>
{
    public override void Configure()
    {
        Post("/admin/login");
        AllowAnonymous();
    }

    public override Task<LoginResponse> ExecuteAsync(LoginRequest req, CancellationToken ct)
    {
        return Task.FromResult(
            new LoginResponse
            {
                JWTToken = "xxx",
                ExpiresOn = "yyy"
            });
    }
}

By doing the above, you can simply access the response DTO like below instead of through the Response property of the endpoint when unit testing.

var res = await ep.ExecuteAsync(req, default);

Adding Route Parameters

For passing down route parameters you will have to alter the HttpContext by setting them in the Factory.Create. See the example below:

Endpoint

public class Endpoint : Endpoint<Request, Response>
{
    public override void Configure()
    {
        Get("users/{id}");
        AllowAnonymous();
    }

    public override async Task HandleAsync(Request req, CancellationToken ct)
    {
        var user = new Response
        {
            Id = req.Id,
            FullName = req.FirstName + " " + req.LastName
        };

        await SendAsync(user);
    }
}

public class Request
{
    public int Id { get; set; }
    public string FirstName { get; set;}
    public string LastName { get; set;}
}

public class Response
{
    public int Id { get; set; }
    public string FullName { get; set; }
}

Test

[TestMethod]
public async Task GetSingleUserById()
{
    // Arrange
    var ep = Factory.Create<Endpoint>(ctx =>
      ctx.Request.RouteValues.Add("id", "1"));

    var req = new Request 
    {
      FirstName = "Jeff",
      LastName = "Bridges"
    };

    // Act
    await ep.HandleAsync(req, default);
    var rsp = ep.Response;

    // Assert
    Assert.IsNotNull(rsp);
    Assert.AreEqual(1, rsp.Id);
    Assert.AreEqual("Jeff Bridges", rsp.FullName);
}

© FastEndpoints 2023