How to perform Integration Testing in ASP.NET Core

How to perform Integration Testing in ASP.NET Core

Integration Testing ensures that the app components work correctly together. These components could be a controller and Entity Framework Core, integration testing will ensure that they work correctly together and produce a desired result. In this tutorial I will perform Integrations Testing in ASP.NET Core app.

The source codes of this tutorial can be downloaded from my GitHub Repository.

Project Setup

To create the project setup to perform Integration Testing, you should have 2 projects in your solution.

  1. An ASP.NET Core 5.0 project.
  2. A Class Library (.NET Core) project which should use .NET 5.0 version. This project will be used for doing Integration Testing of the ASP.NET Core project. Install 4 packages from NuGet to this class library project.

These packages are:

  1. Microsoft.AspNetCore.Mvc.Testing
  2. xunit
  3. xunit.runner.visualstudio
  4. Microsoft.NET.Test.Sdk
Microsoft.AspNetCore.Mvc.Testing

Also added the reference to the ASP.NET Core project in the Class Library project. See the below image which confirms it.

Adding Dependencies

I have named my ASP.NET Core project as MyAppT and named the Class Library (.NET Core) project as IntegrationTestingProject.

For any confusion regarding creating the projects simply download the source codes for this tutorial from the link given at the bottom. You can also visit my last tutorial How to perform Unit Testing with xUnit in ASP.NET Core where I explained how to create new projects in a solution for performing testing.
What Integration Testing will test ?

In my ASP.NET Core Project there is a RegisterControllerwhich performs CRUD operations (Create, Read, Update and Delete of records) to an In-Memory database. Here I will perform the integration testing for the Read & Create actions, there code is shown below.

public async Task<IActionResult> Read()
{
    var rl = await context.ListAsync();
    return View(rl);
}
public IActionResult Create()
{
    return View();
}

[HttpPost]
public async Task<IActionResult> Create(Register register)
{
    if (ModelState.IsValid)
    {
        await context.CreateAsync(register);
        return RedirectToAction("Read");
    }
    else
        return View();
}

I will strongly recommend you to have a look to my tutorial called How to use Moq and xUnit for Unit Testing Controllers in ASP.NET Core to look for the procedure of creating the in-memory database in Entity Framework Core.

If you know how to create in-memory database then simply download the source codes for this tutorial, download link is given at the end.

Creating Test Server with WebApplicationFactory<T> class

I will create an In-Memory Test Server against whom the Integration Tests will be performed. Test server is created with the help of WebApplicationFactory <T> class of the Microsoft.AspNetCore.Mvc.Testing package. Here “T” is the entry point which is usually the Startup.cs class of the project being tested.

Recall that I have installed the Microsoft.AspNetCore.Mvc.Testing package to my IntegrationTestingProject. So, start by creating a new class called TestingWebAppFactory.cs in the IntegrationTestingProject. Add the below given code to it.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MyAppT;
using MyAppT.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace IntegrationTestingProject
{
    public class TestingWebAppFactory<T> : WebApplicationFactory<Startup>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var dbContext = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

                if (dbContext != null)
                    services.Remove(dbContext);

                var serviceProvider = new ServiceCollection().AddEntityFrameworkInMemoryDatabase().BuildServiceProvider();

                services.AddDbContext<AppDbContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemoryEmployeeTest");
                    options.UseInternalServiceProvider(serviceProvider);
                });
                var sp = services.BuildServiceProvider();

                using (var scope = sp.CreateScope())
                {
                    using (var appContext = scope.ServiceProvider.GetRequiredService<AppDbContext>())
                    {
                        try
                        {
                            appContext.Database.EnsureCreated();
                        }
                        catch (Exception ex)
                        {
                            //Log errors
                            throw;
                        }
                    }
                }
            });
        }
    }
}

Let me explain you it’s working.

Our class implements the WebApplicationFactory<Startup> class and overrides the ConfigureWebHost method. In this method I removed the AppDbContext from the Startup.cs and added Entity Framework in-memory database. These things are done by the below code lines.

var dbContext = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

if (dbContext != null)
    services.Remove(dbContext);

var serviceProvider = new ServiceCollection().AddEntityFrameworkInMemoryDatabase().BuildServiceProvider();

Next, I added the Entity Framework Core in-memory database, as a service, to the IServiceCollection. It will be used for the tests.

services.AddDbContext<AppDbContext>(options =>
{
    options.UseInMemoryDatabase("InMemoryEmployeeTest");
    options.UseInternalServiceProvider(serviceProvider);
});

Next, I created the service provider and the scope of this service. This is done so that the new service (Entity Framework Core in-memory database) can be provided to other class through Dependency Injection. The below code lines do this work.

var sp = services.BuildServiceProvider();
using (var scope = sp.CreateScope())
{
    using (var appContext = scope.ServiceProvider.GetRequiredService<AppDbContext>())
    {
        try
        {
            appContext.Database.EnsureCreated();
            // call seed database function
        }
        catch (Exception ex)
        {
            //Log errors
            throw;
        }
    }
}

Inside the try method I called the appContext.Database.EnsureCreated() method of EF core which ensures that the database for the context exists. If it exists, no action is taken, if it does not exist then the database and all its schema are created.

If you want to seed the database then call the seed database function after EnsureCreated. I do not need to seed my database so I have not done the seeding part.

With these preparations, we can start writing our Integration Tests.

Integration Test Class

Create a new class called RegisterControllerIntegrationTests.cs in the IntegrationTestingProject.

Here implement the TestingWebAppFactory class (which we just created above) with the IClassFixture interface and inject it in a constructor. In the constructor create an instance of the HttpClient. The code of this class is given below.

using Microsoft.Net.Http.Headers;
using MyAppT;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace IntegrationTestingProject
{
    public class RegisterControllerIntegrationTests : IClassFixture<TestingWebAppFactory<Startup>>
    {
        private readonly HttpClient _client;
        public RegisterControllerIntegrationTests(TestingWebAppFactory<Startup> factory)
        {
            _client = factory.CreateClient();
        }
    }
}

The IClassFixture interface indicates that tests in this class rely on another class which is our TestingWebAppFactory.cs.

Next, I am going to add integration tests to this class one by one.

Integration Testing of the Read Action

Add the test method called Read_GET_Action() whose code is given below.

using Microsoft.Net.Http.Headers;
using MyAppT;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace IntegrationTestingProject
{
    public class RegisterControllerIntegrationTests : IClassFixture<TestingWebAppFactory<Startup>>
    {
        private readonly HttpClient _client;
        public RegisterControllerIntegrationTests(TestingWebAppFactory<Startup> factory)
        {
            _client = factory.CreateClient();
        }

        [Fact]
        public async Task Read_GET_Action()
        {
            // Act
            var response = await _client.GetAsync("/Register/Read");

            // Assert
            response.EnsureSuccessStatusCode();
            var responseString = await response.Content.ReadAsStringAsync();

            Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
            Assert.Contains("<h1 class=\"bg-info text-white\">Records</h1>", responseString);
        }
    }
}

I called the Read action of the Register controller by making a GET request to it’s URL which is /Register/Read.

var response = await _client.GetAsync("/Register/Read");

Next, I tested with the EnsureSuccessStatusCode to check if the call is successful or not. The test will pass if it returns true and will fail if it returns false.

response.EnsureSuccessStatusCode();

The HTTP Response which is serialized to a string is stored in “responseString” variable.

var responseString = await response.Content.ReadAsStringAsync();

Next, I verified that the HTTP Headers content type is text/html; charset=utf-8. This is done by the below code line.

Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());

I also verified that the response contains an H1 tag with a text called “Records”.

Assert.Contains("<h1 class=\"bg-info text-white\">Records</h1>", responseString);

The above check is done because on opening the URL of the Read action method which is https://localhost:44386/Register/Read, you will find the “Records” heading on the top in h1 tag. I have marked it on the below image.

record heading integration test

By checking it, the test method will ensure that the Read action is indeed called.

integration test passes

Run this test in the Test explorer and it will get passed. Check below image.

Integration Testing of the CREATE Action (HTTP GET type)

Next, I will test the Create action (HTTP GET) of the Register controller. So add the test method called Create_GET_Action() whose code is given below.

[Fact]
public async Task Create_GET_Action()
{
    // Act
    var response = await _client.GetAsync("/Register/Create");

    // Assert
    response.EnsureSuccessStatusCode();
    var responseString = await response.Content.ReadAsStringAsync();

    Assert.Contains("Create Record", responseString);
}

I called the URL of this create action by using GetAsync method.

var response = await _client.GetAsync("/Register/Create");

Then the tests are done to ensure it is called successfully and checking the response string for the text “Create Record”.

response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.Contains("Create Record", responseString);

These tests were similar to the previous tests.  You can run this test method and it will pass.

integration test passes

Invalid Model – Integration Testing of the CREATE Action (HTTP POST type)

The HTTP POST type of Create action is called when the user fills the form and submit it. There are 2 cases arising:

  • 1. When the user incompletely fills the form and tries to submit. This leads to invalid model test which I will do now.
  • 2. When the user completely fills the form (also correctly fills it) and on submitting, the record is created. This test I will do in the next test method.

So add a new test method called Create_POST_Action_InvalidModel() whose code is given below:

[Fact]
public async Task Create_POST_Action_InvalidModel()
{
    // Arrange
    var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Register/Create");
    var formModel = new Dictionary<string, string>
    {
        { "Name", "New Person" },
        { "Age", "25" }
    };
    postRequest.Content = new FormUrlEncodedContent(formModel);

    // Act
    var response = await _client.SendAsync(postRequest);

    // Assert
    response.EnsureSuccessStatusCode();
    var responseString = await response.Content.ReadAsStringAsync();
    Assert.Contains("The field Age must be between 40 and 60", responseString);
}

Let us understand this test method.

First, I am creating a HTTP Request message where I have specified the URL of this action and the HTTP type to be “Post”.

var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Register/Create");

Then I created form model which is a dictionary type. I add the 2 fields of the Register.cs class to it. The Register.cs class code is given below. You can notice that the validation on the age field is 40 to 60.

public class Register
{
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Range(40, 60)]
    public int Age { get; set; }
}

As I am providing the age field with a value of 25 therefore this will lead to invalid mode and validation error will come to place when the form is submitted. This is indeed what this integration test is written form.

var formModel = new Dictionary<string, string>
{
    { "Name", "New Person" },
    { "Age", "25" }
};

Next, the Create action is initiated by the below code and model value is provided to it.

var response = await _client.SendAsync(postRequest);

Finally, I serialize the response and make assertion verification.

response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.Contains("The field Age must be between 40 and 60", responseString);

The thing to note here is that when submitting the form with age value of 25, the validation error received due to the [Range(40, 60)] attribute will be “The field Age must be between 40 and 60”. The check is done to find this text in the response string.

Run the test and it will pass, see below image.

Integration Test passes

Valid Model – Integration Testing of the CREATE Action (HTTP POST type)

Now I will do the Integration Testing for the Create action (HTTP POST) but this time the model will be valid that is no validation error will come to picture. You guessed it correctly, the age value will be added to an allowed one.

So add the test method called Create_POST_Action_ValidModel() whose code is given below.

[Fact]
public async Task Create_POST_Action_ValidModel()
{
    // Arrange
    var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Register/Create");
    var formModel = new Dictionary<string, string>
    {
        { "Name", "New Person" },
        { "Age", "45" }
    };
    postRequest.Content = new FormUrlEncodedContent(formModel);

    // Act
    var response = await _client.SendAsync(postRequest);

    // Assert
    response.EnsureSuccessStatusCode();
    var responseString = await response.Content.ReadAsStringAsync();
    Assert.Contains("New Person", responseString);
    Assert.Contains("45", responseString);
}

Notice the form model value contains the correct age this time as 45 is between the allowed range of 40 to 60.

var formModel = new Dictionary<string, string>
{
    { "Name", "New Person" },
    { "Age", "45" }
};

So, this time the form will be submitted successfully and the record will be added to the in-memory database. The user will then be redirected to the “Read” action and the newly added record is shown on the browser.

I made the check in the test method to find if the response string contains the newly added value that is “New Person” and “45”.

Assert.Contains("New Person", responseString);
Assert.Contains("45", responseString);

Run this test and it will pass.

integration test passes

AntiForgeryToken in Integration Testing

The anti-forgery token can be used to help protect your application against cross-site request forgery. So far, no action method has included it. If I include the [ValidateAntiForgeryToken] attribute on the Create action of the Register Controller. Then the test method will fail. Let me show it to you.

Go to the RegisterController.cs and add [ValidateAntiForgeryToken] attribute over the Create action of Post type.

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Register register)
{
    if (ModelState.IsValid)
    {
        await context.CreateAsync(register);
        return RedirectToAction("Read");
    }
    else
        return View();
}

Now run the tests again and you will notice that both the test for the Create action method (HTTP POST Types) fails this time. I have illustrated this in the below image.

ValidateAntiForgeryToken Integration testing failed
The test error received is – “System.Net.Http.HttpRequestException : Response status code does not indicate success: 400 (Bad Request).”

Let us now update the integration tests to include the AntiForgeryToken value so that these test methods can pass.

How to include AntiForgeryToken in Integration Testing

I will create a new class called AntiForgeryTokenExtractor.cs to the IntegrationTestingProject, it’s work will be to extract the anti-forgery cookie and field. The code of this class is given below.

using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace IntegrationTestingProject
{
    public static class AntiForgeryTokenExtractor
    {
        public static string Field { get; } = "AntiForgeryTokenField";
        public static string Cookie { get; } = "AntiForgeryTokenCookie";

        private static string ExtractCookieValue(HttpResponseMessage response)
        {
            string antiForgeryCookie = response.Headers.GetValues("Set-Cookie").FirstOrDefault(x => x.Contains(Cookie));

            if (antiForgeryCookie is null)
                throw new ArgumentException($"Cookie '{Cookie}' not found in HTTP response", nameof(response));

            string antiForgeryCookieValue = SetCookieHeaderValue.Parse(antiForgeryCookie).Value.ToString();

            return antiForgeryCookieValue;
        }

        private static string ExtractAntiForgeryToken(string htmlBody)
        {
            var requestVerificationTokenMatch = Regex.Match(htmlBody, [email protected]"\<input name=""{Field}"" type=""hidden"" value=""([^""]+)"" \/\>");
            if (requestVerificationTokenMatch.Success)
                return requestVerificationTokenMatch.Groups[1].Captures[0].Value;
            throw new ArgumentException($"Anti forgery token '{Field}' not found", nameof(htmlBody));
        }

        public static async Task<(string field, string cookie)> ExtractAntiForgeryValues(HttpResponseMessage response)
        {
            var cookie = ExtractCookieValue(response);
            var token = ExtractAntiForgeryToken(await response.Content.ReadAsStringAsync());

            return (token, cookie);
        }
    }
}

Let us discuss this class methods one by one.

ExtractCookieValue(HttpResponseMessage response)

This method fetches the value of the Set-Cookie property, from the Header of our response. It throws an exception if the cookie is not present.

ExtractAntiForgeryToken(string htmlBody)

This method uses regex expression to extract the HTML control from the html Body string, that contains the anti-forgery field value. An exception is thrown if it is not found.

Task<(string field, string cookie)> ExtractAntiForgeryValues(HttpResponseMessage response)

This method collects the results of the above 2 methods and returns them as a Tuple object.

Next move to the TestingWebAppFactory.cs class and inject the token details in IServiceCollection. I have shown this in highlighted manner.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MyAppT;
using MyAppT.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace IntegrationTestingProject
{
    public class TestingWebAppFactory<T> : WebApplicationFactory<Startup>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var dbContext = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

                if (dbContext != null)
                    services.Remove(dbContext);

                var serviceProvider = new ServiceCollection().AddEntityFrameworkInMemoryDatabase().BuildServiceProvider();

                services.AddDbContext<AppDbContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemoryEmployeeTest");
                    options.UseInternalServiceProvider(serviceProvider);
                });

                //antiforgery
                services.AddAntiforgery(t =>
                {
                    t.Cookie.Name = AntiForgeryTokenExtractor.Cookie;
                    t.FormFieldName = AntiForgeryTokenExtractor.Field;
                });

                var sp = services.BuildServiceProvider();

                using (var scope = sp.CreateScope())
                {
                    using (var appContext = scope.ServiceProvider.GetRequiredService<AppDbContext>())
                    {
                        try
                        {
                            appContext.Database.EnsureCreated();
                        }
                        catch (Exception ex)
                        {
                            //Log errors or do anything you think it's needed
                            throw;
                        }
                    }
                }
            });
        }
    }
}

Now I need to modify the 2 integration test method that failed, these were.

  • 1. Create_POST_Action_InvalidModel
  • 2. Create_POST_Action_ValidModel

Modify the Integration Test for ValidateAntiForgeryToken

Now I will modify the Integration Test, that had failed previously, with an initial HTTP GET request. This GET request will send a response containing the anti-forgery values, I will extract the anti-forgery values from it. For this I will use the function AntiForgeryTokenExtractor.ExtractAntiForgeryValues() I created in the above section. The below 2 code lines do this work for me.

var initialRes = await _client.GetAsync("/Register/Create");
var antiForgeryVal = await AntiForgeryTokenExtractor.ExtractAntiForgeryValues(initialRes); 

Next, I will assign the –

  • a. cookie value to the Post request Header.
  • b.Field value in the formModel object.

The codes for these are:

postRequest.Headers.Add("Cookie", new CookieHeaderValue(AntiForgeryTokenExtractor.Cookie, antiForgeryVal.cookie).ToString());

var formModel = new Dictionary<string, string>
{
    { AntiForgeryTokenExtractor.Field, antiForgeryVal.field },//new
    { "Name", "New Person" },
    { "Age", "25" }
};

With all these let us perform the updation. I have highlighted the necessary changes that needs to be done.

[Fact]
public async Task Create_POST_Action_InvalidModel()
{
    // Arrange
    var initialRes = await _client.GetAsync("/Register/Create");
    var antiForgeryVal = await AntiForgeryTokenExtractor.ExtractAntiForgeryValues(initialRes);
    
    var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Register/Create");
    
    postRequest.Headers.Add("Cookie", new CookieHeaderValue(AntiForgeryTokenExtractor.Cookie, antiForgeryVal.cookie).ToString());
    
    var formModel = new Dictionary<string, string>
    {
        { AntiForgeryTokenExtractor.Field, antiForgeryVal.field },
        { "Name", "New Person" },
        { "Age", "25" }
    };
    postRequest.Content = new FormUrlEncodedContent(formModel);

    // Act
    var response = await _client.SendAsync(postRequest);

    // Assert
    response.EnsureSuccessStatusCode();
    var responseString = await response.Content.ReadAsStringAsync();
    Assert.Contains("The field Age must be between 40 and 60", responseString);
}

[Fact]
public async Task Create_POST_Action_ValidModel()
{
    // Arrange
    var initialRes = await _client.GetAsync("/Register/Create");
    var antiForgeryVal = await AntiForgeryTokenExtractor.ExtractAntiForgeryValues(initialRes);
    
    var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Register/Create");
    
    postRequest.Headers.Add("Cookie", new CookieHeaderValue(AntiForgeryTokenExtractor.Cookie, antiForgeryVal.cookie).ToString());
    
    var formModel = new Dictionary<string, string>
    {
        { AntiForgeryTokenExtractor.Field, antiForgeryVal.field },
        { "Name", "New Person" },
        { "Age", "45" }
    };
    postRequest.Content = new FormUrlEncodedContent(formModel);

    // Act
    var response = await _client.SendAsync(postRequest);

    // Assert
    response.EnsureSuccessStatusCode();
    var responseString = await response.Content.ReadAsStringAsync();
    Assert.Contains("New Person", responseString);
    Assert.Contains("45", responseString);
}

Let us run the test for the final time and they will all pass. Check below image.

ValidateAntiForgeryToken Integration testing passed

You can see those cookie and field values in the response by putting a breakpoint on the test case and then selecting Debug from Test Explorer. I have shown this in the below video.

Conclusion

In this tutorial you learned to perform Integration Testing in ASP.NET Core MVC. You also learned how to extract and inject anti forgery token value to integration test methods. I hope you enjoyed learning these things. So, make use of them in your testing job.

SHARE THIS ARTICLE

  • linkedin
  • reddit

ABOUT THE AUTHOR

I am Yogi S. I write DOT NET artciles on my sites hosting.work and yogihosting.com. You can connect with me on Twitter. I hope my articles are helping you in some way or the other, if you like my articles consider buying me a coffee - Buy Me A Coffee

Leave a Reply

Your email address will not be published.

Related Posts based on your interest