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 can use the vanilla WebApplicationFactory<T> together with the convenient HttpClient extensions for sending requests to the WAF, the FastEndpoints.Testing package comes with an abstract App Fixture and Test Base to make testing even more convenient by offering:

  • One-time fixture 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.
  • Speeds up test execution by preventing unnecessary WAF/SUT instantiations.
  • Easy access to fake data generation using Bogus.

App Fixture

An AppFixture is a special type of abstract class fixture, which in xUnit is simply a shared object that is instantiated once per each test-class before test execution begins and destroyed after all the test-methods of the class completes execution. It is the recommended strategy to share a common resource among multiple tests of the same test-class.

An AppFixture when instantiated by xUnit for a test-class, bootstraps an instance of your target 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 (or even per test-class) would result in slower test execution.

To create an AppFixture, inherit from AppFixture<TProgram> base:

public class MyApp : AppFixture<Program>
{
    protected override Task SetupAsync()
    {
        // place one-time setup code here
    }
    
    protected override void ConfigureApp(IWebHostBuilder a)
    {
        // do host builder config here
    }

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

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

When you create a derived AppFixture, you're uniquely configuring a SUT with its own set of test services & settings. Need another SUT that's configured differently? Simply create another derived AppFixture. The internal caching mechanism of AppFixtures ensures that only one instance of a SUT is ever booted up per derived AppFixture no matter how many test-classes are using the same derived AppFixture. For example, if you create 2 derived AppFixtures called MyTestAppA and MyTestAppB, there will only ever be 2 instances of your SUT running at any given time during a test run.

Test Base

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. In fact, it's impossible to make it do so. The order in which the methods are executed is not guaranteed (unless we do explicit ordering). Inherit TestBase<TAppFixture> to create a test-class:

public class MyTests(MyApp App) : TestBase<MyApp>
{
    [Fact, Priority(1)]
    public async Task Invalid_User_Input()
    {
        var (rsp, res) = await App.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 App.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, 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 MyApp fixture instance is injected by xUnit via the constructor of the test-class, 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 = App.CreateClient(c =>
    {
        c.DefaultRequestHeaders.Authorization = new("Bearer", jwtToken);
    });
}

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

public class MyApp : AppFixture<Program>
{
    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.

State Fixture

There's a generic variant TestBase<TAppFixture,TStateFixture> you can use to share a common state/resource amongst the test-methods of a test-class. Simply implement a StateFixture and use it with the test-class like so:

public sealed class MyState : StateFixture
{
    public int Id { get; set; } //some state

    protected override async Task SetupAsync()
    {
        Id = 123; //some setup logic
        await Task.CompletedTask;
    }

    protected override async Task TearDownAsync()
    {
        Id = 0; //some teardown logic
        await Task.CompletedTask;
    }
}

public class MyTests(MyApp App, MyState State) : TestBase<MyApp, MyState>
{
    [Fact]
    public async Task State_Check()
        => State.Id.Should().Be(123);
}

This approach allows your test suit to have just a couple of derived AppFixtures, each representing a uniquely configured SUT(live app/WAF instance), while each test-class can have their own lightweight StateFixture for the sole purpose of sharing state/data amongst multiple test-methods of that test-class. This leads to better test run performance as each unique SUT is only created once no matter how many test classes use the same derived AppFixture.

Integration Test Samples

Please refer to the following sample projects 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, swapping out test/fake services, 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));
});

It is also necessary to register any dependencies like above if your endpoint Mapper uses dependency injection or manual resolving.

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

Units with Command executions or Event publishes

If a code path you're unit testing has command executions or event publishes, fake handlers for those commands/events can be registered as shown below.

Registering Fake Command Handlers

[Fact]
public async Task FakeCommandHandlerIsExecuted()
{    
    var fakeHandler = A.Fake<ICommandHandler<GetFullNameCommand, string>>();
    
    A.CallTo(() => fakeHandler.ExecuteAsync(
        A<GetFullNameCommand>.Ignored, 
        A<CancellationToken>.Ignored))
     .Returns(Task.FromResult("Fake Result"));     
    
    fakeHandler.RegisterForTesting(); //register the fake handler

    //emulating command being executed
    //typically the unit you're testing will be the executor
    var command = new GetFullNameCommand { FirstName = "a", LastName = "b" };
    var result = await command.ExecuteAsync();

    Assert.Equal("Fake Result", result);
}

Registering Fake Event Handlers

[Fact]
public async Task FakeEventHandlerIsExecuted()
{
    var fakeHandler = new FakeEventHandler();

    Factory.RegisterTestServices( //register fake handler
        s =>
        {
            s.AddSingleton<IEventHandler<NewItemAddedToStock>>(fakeHandler);
        });

    //emulating event being published
    //typically the unit you're testing will be the publisher
    await new NewItemAddedToStock { Name = "xyz" }.PublishAsync();

    fakeHandler.Name.Should().Be("xyz");
}

Unit Test Examples

More unit testing examples can be found here.


© FastEndpoints 2024