In this article, we are going to learn how to implement Functional tests on .NET using xUnit.
This is part of a series of videos,
- Implementing Clean Architecture On .NET
- Implementing Unit And Integration Tests On .NET With xUnit
- Deploy .NET API to Azure App Service through Azure DevOps
- Implement Functional tests on .NET with xUnit
Prerequisites:
- Visual Studio 2022 with .NET 6 SDK
- Download the project from here
The tested project is frequently called the System Under Test, or "SUT" for short.
Functional tests follow a sequence of events that include the usual Arrange, Act, and Assert test steps:
- The SUT's web host is configured.
- A test server client is created to submit requests to the app.
- The Arrange test step is executed: The test app prepares a request.
- The Act test step is executed: The client submits the request and receives the response.
- The Assert test step is executed: The actual response is validated as a pass or fail based on an expected response.
- The process continues until all of the tests are executed.
- The test results are reported.
Let's start developing.
You should have the following structure:
In the tests folder, right-click. Add / New Project ... / xUnit Test project, named Store.FunctionalTests and select .NET 6 as target framework.
Add the reference to Store.SharedDatabaseSetup and Store.WebApi projects.
Install the following packages:
- Microsoft.AspNetCore.Mvc.Testing
- Microsoft.EntityFrameworkCore.InMemory
ASP.NET Core 6 introduced WebApplication which removed the need for a Startup class. To test with WebApplicationFactory without a Startup class, an ASP.NET Core 6 app needs to expose the implicitly defined Program class. So, in Store.WebApi project, we need to make the Program class public using a partial class declaration:
...
public partial class Program { }
After making the changes in the web application, the test project now can use the Program class for the WebApplicationFactory.
WebApplicationFactory<TEntryPoint> is used to create a TestServer for the Functional tests. TEntryPoint is the entry point class of the SUT, usually the Startup class.
In Store.FunctionalTests project, create CustomWebApplicationFactory class.
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Store.Infrastructure.Persistence.Contexts;
using Store.SharedDatabaseSetup;
using System;
using System.Linq;
namespace Store.FunctionalTests
{
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the app's StoreContext registration.
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<StoreContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add StoreContext using an in-memory database for testing.
services.AddDbContext<StoreContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForFunctionalTesting");
});
// Get service provider.
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
var storeDbContext = scopedServices.GetRequiredService<StoreContext>();
storeDbContext.Database.EnsureCreated();
try
{
DatabaseSetup.SeedData(storeDbContext);
}
catch (Exception ex)
{
logger.LogError(ex, $"An error occurred seeding the Store database with test messages. Error: {ex.Message}");
}
}
});
}
public void CustomConfigureServices(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Get service provider.
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
var storeDbContext = scopedServices.GetRequiredService<StoreContext>();
try
{
DatabaseSetup.SeedData(storeDbContext);
}
catch (Exception ex)
{
logger.LogError(ex, $"An error occurred seeding the Store database with test messages. Error: {ex.Message}");
}
}
});
}
}
}
CustomWebApplicationFactory inherits from WebApplicationFactory and overrides ConfigureWebHost. The IWebHostBuilder allows the configuration of the service collection with ConfigureServices.
The SUT's database context is registered in its Startup.ConfigureServices method. The test app's builder.ConfigureServices callback is executed after the app's Startup.ConfigureServices code is executed.
The sample app finds the service descriptor for the database context and uses the descriptor to remove the service registration. Next, the factory adds a new StoreContext that uses an in-memory database for the tests.
Test classes implement a class fixture interface (IClassFixture) to indicate the class contains tests and provide shared object instances across the tests in the class.
Create Controllers folder and inside this create BaseControllerTests class.
using System.Net.Http;
using Xunit;
namespace Store.FunctionalTests.Controllers
{
public class BaseControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly CustomWebApplicationFactory<Program> _factory;
public BaseControllerTests(CustomWebApplicationFactory<Program> factory)
{
_factory = factory;
}
public HttpClient GetNewClient()
{
var newClient = _factory.WithWebHostBuilder(builder =>
{
_factory.CustomConfigureServices(builder);
}).CreateClient();
return newClient;
}
}
}
CustomConfigureServices method in CustomWebApplicationFactory class was created to customize the client with WithWebHostBuilder. Because another test can perform an operation that makes a change in the database and may run before other tests.
Create ProductsControllerTests class and extend from BaseControllerTests.
using Newtonsoft.Json;
using Store.ApplicationCore.DTOs;
using Store.FunctionalTests.Models;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace Store.FunctionalTests.Controllers
{
public class ProductsControllerTests : BaseControllerTests
{
public ProductsControllerTests(CustomWebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task GetProducts_ReturnsAllRecords()
{
var client = this.GetNewClient();
var response = await client.GetAsync("/api/Products");
response.EnsureSuccessStatusCode();
var stringResponse = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<IEnumerable<ProductResponse>>(stringResponse).ToList();
var statusCode = response.StatusCode.ToString();
Assert.Equal("OK", statusCode);
Assert.True(result.Count == 10);
}
[Fact]
public async Task GetProductById_ProductExists_ReturnsCorrectProduct()
{
var productId = 5;
var client = this.GetNewClient();
var response = await client.GetAsync($"/api/Products/{productId}");
response.EnsureSuccessStatusCode();
var stringResponse = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse);
var statusCode = response.StatusCode.ToString();
Assert.Equal("OK", statusCode);
Assert.Equal(productId, result.Id);
Assert.NotNull(result.Name);
Assert.True(result.Price > 0);
Assert.True(result.Stock > 0);
}
[Theory]
[InlineData(0)]
[InlineData(20)]
public async Task GetProductById_ProductDoesntExist_ReturnsNotFound(int productId)
{
var client = this.GetNewClient();
var response = await client.GetAsync($"/api/Products/{productId}");
var statusCode = response.StatusCode.ToString();
Assert.Equal("NotFound", statusCode);
}
[Fact]
public async Task PostProduct_ReturnsCreatedProduct()
{
var client = this.GetNewClient();
// Create product
var request = new CreateProductRequest
{
Description = "Description",
Name = "Test product",
Price = 25.3
};
var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
var response1 = await client.PostAsync("/api/Products", stringContent);
response1.EnsureSuccessStatusCode();
var stringResponse1 = await response1.Content.ReadAsStringAsync();
var createdProduct = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse1);
var statusCode1 = response1.StatusCode.ToString();
Assert.Equal("Created", statusCode1);
// Get created product
var response2 = await client.GetAsync($"/api/Products/{createdProduct.Id}");
response2.EnsureSuccessStatusCode();
var stringResponse2 = await response2.Content.ReadAsStringAsync();
var result2 = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse2);
var statusCode2 = response2.StatusCode.ToString();
Assert.Equal("OK", statusCode2);
Assert.Equal(createdProduct.Id, result2.Id);
Assert.Equal(createdProduct.Name, result2.Name);
Assert.Equal(createdProduct.Description, result2.Description);
Assert.Equal(createdProduct.Stock, result2.Stock);
}
[Fact]
public async Task PostProduct_InvalidData_ReturnsErrors()
{
var client = this.GetNewClient();
// Create product
var request = new CreateProductRequest
{
Description = "Description",
Name = null,
Price = 0
};
var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/Products", stringContent);
var stringResponse = await response.Content.ReadAsStringAsync();
var badRequest = JsonConvert.DeserializeObject<BadRequestModel>(stringResponse);
var statusCode = response.StatusCode.ToString();
Assert.Equal("BadRequest", statusCode);
Assert.NotNull(badRequest.Title);
Assert.NotNull(badRequest.Errors);
Assert.Equal(2, badRequest.Errors.Count);
Assert.Contains(badRequest.Errors.Keys, k => k == "Name");
Assert.Contains(badRequest.Errors.Keys, k => k == "Price");
}
[Fact]
public async Task PutProduct_ReturnsUpdatedProduct()
{
var client = this.GetNewClient();
// Update product
var productId = 6;
var request = new UpdateProductRequest
{
Description = "Description",
Name = "Test product",
Price = 17.67,
Stock = 94
};
var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
var response1 = await client.PutAsync($"/api/Products/{productId}", stringContent);
response1.EnsureSuccessStatusCode();
var stringResponse1 = await response1.Content.ReadAsStringAsync();
var updatedProduct = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse1);
var statusCode1 = response1.StatusCode.ToString();
Assert.Equal("OK", statusCode1);
// Get updated product
var response2 = await client.GetAsync($"/api/Products/{updatedProduct.Id}");
response2.EnsureSuccessStatusCode();
var stringResponse2 = await response2.Content.ReadAsStringAsync();
var result2 = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse2);
var statusCode2 = response2.StatusCode.ToString();
Assert.Equal("OK", statusCode2);
Assert.Equal(updatedProduct.Id, result2.Id);
Assert.Equal(updatedProduct.Name, result2.Name);
Assert.Equal(updatedProduct.Description, result2.Description);
Assert.Equal(updatedProduct.Stock, result2.Stock);
}
[Fact]
public async Task DeleteProductById_ReturnsNoContent()
{
var client = this.GetNewClient();
var productId = 5;
// Delete product
var response1 = await client.DeleteAsync($"/api/Products/{productId}");
var statusCode1 = response1.StatusCode.ToString();
Assert.Equal("NoContent", statusCode1);
// Get deleted product
var response2 = await client.GetAsync($"/api/Products/{productId}");
var statusCode2 = response2.StatusCode.ToString();
Assert.Equal("NotFound", statusCode2);
}
}
}
Right-click to the solution and click on Run Tests.
As you can see in the Test Explorer, all the tests passed.
You can find the source code here.
Thanks for reading
Thank you very much for reading, I hope you found this article interesting and may be useful in the future. If you have any questions or ideas that you need to discuss, it will be a pleasure to be able to collaborate and exchange knowledge together.