Search
K
  1. Integration & Unit Testing

Integration & Unit Testing

Integration Testing

Route-less Testing

The recommended integration/end-to-end testing strategy is to use the following stack:

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.

Walkthrough

The quickest way to get going is to scaffold a starter project using our Template Pack. If you aren't ready to scaffold a project yet, follow along with the source code.

install & scaffold
dotnet new install FastEndpoints.TemplatePack
dotnet new feproj -n E2EWalkthrough

Solution Setup

There are two projects in the solution. The main application is in the Source folder and the actual test project is in the Tests folder. The main project doesn't reference the test project. The test project references the main project so it can access the Endpoint/Request/Response classes. The Endpoints and DTOs are internal so we expose internal classes of the main project to the test project via an assembly attribute.

FastEndpoints.Testing Package

Even though you have the choice of using the WebApplicationFactory<T> directly together with the convenient HttpClient extensions for sending requests to the WAF, the FastEndpoints.Testing package comes with an abstract Class-Fixture and Test-Class Base to make testing even more convenient by offering:

  • One-time test-class setup & teardown.
  • Ability to do ordered test-method execution within a class.
  • Sets up xUnit test-output-helper and message-sink.
  • Automatically picks up configuration overrides from appsettings.Testing.json file.
  • Convenient test-service configuration & HttpClient creation.
  • Easy access to fake data generation using Bogus.

Class Fixture

A class fixture in xUnit is an object that is instantiated once per each test-class and is destroyed after all the test-methods of the class has been executed. It is the recommended strategy to share data/state among multiple tests of the same test-class. To create a fixture for a test-class, inherit from the provided TestFixture<TProgram> base:

public class MyFixture : TestFixture<Program>
{
    public MyFixture(IMessageSink s) : base(s) { }

    protected override Task SetupAsync()
    {
        // place one-time setup code here
    }

    protected override void ConfigureServices(IServiceCollection s)
    {
        // do test service registration here
    }

    protected override Task TearDownAsync()
    {
        // do cleanups here
    }
}

This fixture when instantiated by xUnit for a test-class, bootstraps an instance of our main application (SUT) as an in-memory test-server/web-host. That instance will be re-used by all the test-methods of the class speeding up test execution as constantly booting up and tearing down a WAF/web-host per each test-method would lead to slower test execution. If a certain set of tests call for a differently configured state (web-host, fake/seed data, httpclients), you should create a separate fixture for those tests.

Test Class

xUnit considers a single class file containing multiple test-methods as a single test-collection. A test-collection is a set of test-methods that would be executed serially together, but never in parallel. I.e. two test-methods of the same class will never execute simultaneously. Infact, it's impossible to make it do so. The order in which the methods are executed is not guranteed (unless we do explicit ordering). Inherit TestClass<TFixture> to create a test-class:

public class MyTests : TestClass<MyFixture>
{
    public MyTests(MyFixture f, ITestOutputHelper o) : base(f, o) { }

    [Fact, Priority(1)]
    public async Task Invalid_User_Input()
    {
        var (rsp, res) = await Fixture.Client.POSTAsync<Endpoint, Request, ErrorResponse>(new()
        {
            FirstName = "x",
            LastName = "y"
        });
        rsp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
        res.Errors.Count.Should().Be(2);
        res.Errors.Keys.Should().Equal("firstName", "lastName");
    }

    [Fact, Priority(2)]
    public async Task Valid_User_Input()
    {
        var (rsp, res) = await Fixture.Client.POSTAsync<Endpoint, Request, Response>(new()
        {
            FirstName = "Mike",
            LastName = "Kelso"
        });
        rsp.IsSuccessStatusCode.Should().BeTrue();
        res.Message.Should().Be("Hello Mike Kelso...");
    }
}

Test Ordering

In the above example, the tests are ordered by annotating the test-method with the [Priority(n)] attribute. Test-methods that don't have an attribute decoration would most likely be executed in alphabetical order. XUnit.Priority package is used to provide the ordering functionality.

HttpClient Configuration

In the above example, a MyFixture instance is made available to the test-class via the property Fixture which has a default HttpClient accessible via the Client property. You can configure custom clients within test-methods like so:

[Fact]
public async Task Access_Protected_Endpoint()
{
    var adminClient = Fixture.CreateClient(c =>
    {
        c.DefaultRequestHeaders.Authorization = new("Bearer", jwtToken);
    });
}

Or setup pre-configured clients on the MyFixture class itself if they won't be modified by individual test-methods:

public class MyFixture : TestFixture<Program>
{
    public MyFixture(IMessageSink s) : base(s) { }

    public HttpClient Admin { get; private set; }
    public HttpClient Customer { get; private set; }

    protected override async Task SetupAsync()
    {
        var apiKey = await GetApiKey(...);
        Admin = CreateClient(c => c.DefaultRequestHeaders.Add("x-api-key", apiKey));
        Customer = CreateClient();
    }

    protected override Task TearDownAsync()
    {
        Admin.Dispose();
        Customer.Dispose();
        return Task.CompletedTask;
    }
}

Request Sending Methods

Extension methods such as POSTAsync can be called on the HttpClients to send requests to the web-host. The POSTAsync method in the above example has 3 generic parameters.

  • The type of the Endpoint class (endpoint's route URL is not needed)
  • The type of the Request DTO class
  • The type of the Response DTO class

The method argument takes an instance of a populated Request DTO and Returns a record class containing the HttpResponseMessage and the Response DTO of the endpoint, with which you can do assertions. There are other such methods for sending requests to your endpoints.

Integration Test Examples

Please study this project to get a deeper understanding of recommended patterns for common tasks such as, working with a real database, fake data generation, test organization with feature folders, etc.


Unit Testing

Endpoint Testing With FakeItEasy

In situations where doing a unit test would be less tedious compared to setting up an integration test (or even impossible to do so), you may use the Factory.Create<TEndpoint>() method to get an instance of your endpoint which is suitable for unit testing.

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

    var ep = Factory.Create<AdminLoginEndpoint>(
        A.Fake<ILogger<AdminLoginEndpoint>>(), //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.

Service Registration

If your endpoint (or it's dependencies) uses either property injection or manual resolving, those services would need to be registered per test like so:

var fakeMailer = A.Fake<IEmailService>();
A.CallTo(() => fakeMailer.SendEmail())
 .Returns("test email sent");

var ep = Factory.Create<UserCreateEndpoint>(ctx =>
{
    ctx.AddTestServices(s => s.AddSingleton(fakeMailer));
});

Endpoints With Mappers

When unit testing an endpoint that has a mapper, you have to set it yourself via the Map property like so:

var ep = Factory.Create<MyEndpoint>();
ep.Map = new MyMapper();

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 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);
INFO

Starting with .NET 7.0, you have the ability to write unit tests like this by implementing ExecuteAsync() and returning the built-in union type as mentioned here.

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

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

Unit Test Examples

You can find more unit testing examples here.


© FastEndpoints 2023