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