Implement Functional tests on .NET with xUnit
Tags: dotnet, testing, xunit, webapi
Published: 2021-12-21 03:37:28

In this article we are going to learn how to implement Functional tests on .NET API using xUnit.

**Prerequisites:**

- Visual Studio 2022 with .NET 6 SDK
- Download the project from [here](https://github.com/cristofima/StoreCleanArchitecture-NET/releases/tag/v2.1.1)

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:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wkb8lsv1rp050ve3gd6v.png)

In the tests folder, right click. Add / New Project ... / xUnit Test project, named Store.FunctionalTests and select .NET 6 as target framework.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55v430sjzegyk2e9m2qk.png)

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:

```csharp
...
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.

```csharp
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.

```csharp
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 make a change in the database and may run before other tests.

Create ProductsControllerTests class and extend from BaseControllerTests.

```csharp
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.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cr73odhrsbkqk3wnbzwl.png) 

You can find the source code [here](https://github.com/cristofima/StoreCleanArchitecture-NET/releases/tag/v2.2.0).

**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.