The Basics of .NET Integration Testing

Matheus Xavier
C# Programming
Published in
13 min readMar 2, 2023

In my last post, I talked about unit tests which are the most superficial layer of testing, now I will address integration tests which I consider the second layer of testing. In this article, I assume that you are already familiar with the basics of unit testing, if you are not, check out The Basics of .NET Unit Testing article. Integration tests are more complex than unit tests and if you don’t write them correctly they can bring more risks than benefits, so in this post I’ll try to demystify integration tests by giving some advice and practical code examples, addressing the following points:

  • Difference between integration and unit tests
  • Integration testing traps
  • What to test
  • Foundation to write integration tests
  • Writing tests in a practical way
Test pyramid with unit and integration tests

Difference between integration and unit tests

In unit testing, we test each component of our application in isolation, in integration tests we ensure that these components are working correctly in an integrated way, considering the application’s infrastructure, such as the database, network and even authentication.

Integration tests are more complex than unit tests because they end up requiring more code and take longer to run, because of that we should not test all possible scenarios, they should serve as a unit testing complement, so first we test everything which makes sense with unit tests, then we use integration test just to make sure the components are working together, but by no means should integration tests be used to test every little application behaviour.

Integration testing traps

For me there are two big pitfalls when we are writing integration testing:

1. Try to test all application behaviours

As I said in the topic above, this is not the responsibility of integration tests and if we do that, we will end up with a huge integration test suite that takes so long to run that it will end up doing more harm than good and you will probably give up writing integration tests.

2. Do not be concerned about integration testing behaviour when running them in parallel

I will approach this point using an example, let’s imagine that I have two endpoints in my application, the first one that registers a new product and the second that returns all the products that I have registered, our integration tests would have the following behaviour:

  • Product registration test: It calls the endpoint to register a new product and ensure the product was added to the application’s database on test assertion.
  • Product listing test: It adds a product to our database and then calls the endpoint that returns all the products that we have registered to check if our product is on the list.

If in the Assert of the second test we validate that the list needs to be just one product, matching exactly what we registered in Arrange we will have a problem, because if the first test runs before there will be more than one product in the database impacting our assertion. So we must always think that our integration test suite will run in parallel and that one test can impact the other and the test assertion must take this into account, how to do that? Don’t worry, I’ll show you in this post.

What to test

To answer this question in a practical way, which is the purpose of this post, we will need a bit of code, so let’s imagine that we have a .NET Web API where we have a Product entity with the following code:

public class Product
{
public Guid Id { get; }
public string Name { get; private set; }
public double Value { get; private set; }
public bool Active { get; private set; }

public Product(Guid id, string name, double value, bool active)
{
Id = id;
Name = name;
Value = value;
Active = active;
}

public void Update(string name, double value)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
}

if (name.Length < 4)
{
throw new ArgumentException($"'{nameof(name)}' must be more than 3 characters.", nameof(name));
}

if (value <= 0)
{
throw new ArgumentException($"'{nameof(value)}' cannot be less or equal to zero", nameof(value));
}

Name = name;
Value = value;
}
}

This project is using Entity Framework to manage database access and to simplify this sample all interactions are directly in controller, our ProductsController has the following endpoints:

  • Get a specific product by id
  • Get all paginated products
  • Create a new product
  • Update a product

This is the ProductsController code:

[ApiController]
public class ProductsController : ControllerBase
{
private readonly ProductContext _productContext;

public ProductsController(ProductContext productContext)
{
_productContext = productContext;
}

[HttpGet("api/v1/products/{id:guid}")]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(Domain.Entities.Product), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductByIdAsync(Guid id)
{
Domain.Entities.Product? product = await _productContext.Products
.SingleOrDefaultAsync(p => p.Id == id);

if (product is null)
{
return NotFound();
}

return Ok(product);
}

[HttpGet("api/v1/products")]
[ProducesResponseType(typeof(List<Domain.Entities.Product>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetAllProductsAsync([FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
{
List<Domain.Entities.Product> productsOnPage = await _productContext.Products
.Skip(pageIndex * pageIndex)
.Take(pageSize)
.ToListAsync();

return Ok(productsOnPage);
}

[HttpPost("api/v1/products")]
[ProducesResponseType(typeof(Domain.Entities.Product), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateProductAsync(CreateProductViewModel createProductViewModel)
{
Domain.Entities.Product product = new(
id: Guid.NewGuid(),
createProductViewModel.Name,
createProductViewModel.Value,
createProductViewModel.Active);

await _productContext.Products.AddAsync(product);
await _productContext.SaveChangesAsync();

return Created("api/v1/products", product);
}

[HttpPut("api/v1/products")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UpdateProductAsync(UpdateProductViewModel updateProductViewModel)
{
Domain.Entities.Product? product = await _productContext.Products
.SingleOrDefaultAsync(p => p.Id == updateProductViewModel.Id);

if (product is null)
{
return NotFound();
}

try
{
product.Update(updateProductViewModel.Name, updateProductViewModel.Value);

_productContext.Products.Update(product);
await _productContext.SaveChangesAsync();

return Ok();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}

PS: You can check the full project code on github.

Now let’s talk method by method what we can test in each of them.

GetProductByIdAsync

This method searches in the database for a product with a specific id, if it finds it returns the information about that product, otherwise it returns a status of not found. In this case we can test two scenarios:

  1. Finding the product.
  2. Not finding the product.

It is important to test the scenario of not finding to guarantee that the application will behave correctly in this scenario and that it won’t throw an exception, for example.

GetAllProductsAsync

It returns a paginated list of products based on the size and page number it received as parameter. We only have one scenario to validate in this case, which is basically returning a list of products.

CreateProductAsync

Receives information about a new product and persists it in the database. The test for this endpoint is basically to make sure that the product we are creating is being correctly persisted in the database.

UpdateProductAsync

This method receives the information of a product to edit it, first we search for a product with the received id in database, if it cannot find we return a status code 404, otherwise we change the information of that product, if there is any incorrect information we will return a bad request, otherwise the new product information is persisted to database. On this endpoint, I see two scenarios for us to test:

  1. A product with a non-existent Id that will receive a not found result
  2. A product with correct information where it should persist the changes correctly in the database

You might think that validating the bad request scenario is something valid, but there is no interaction with the database in this scenario and we can easily test it using a unit test, because of that we will not create an integration test for this scenario, remember what I said earlier, we should not test all the behaviors of our application with the integration test, if a behavior can be tested using a unit or a integration test, always choose unit testing.

Foundation to write integration tests

Integration tests seek to test how the application components will behave together in a production environment, to accomplish that we need to create a server to submit the requests that we want to test and ensure that is working as expected.

To help us creating this test server that will receive the requests we will use the Microsoft.AspNetCore.Mvc.Testing library which is specific to help us write integration tests for our .NET applications that simplifies test creation and execution, check this Microsoft Documentation to see everything that you can do using it. In our case we will configure our application to run inside the TestServer that allows us to create a new HttpClient to make the requests to endpoints that we want to test.

Code time! The first thing I like to do is create a custom TestServer, this will allow us to add some specific properties that will help us during our tests, so let’s created the ProductTestServer:

public class ProductTestServer : TestServer
{
public ProductTestServer(IWebHostBuilder builder) : base(builder)
{
ProductContext = Host.Services.GetRequiredService<ProductContext>();
}

public ProductContext ProductContext { get; set; }
}

At first it is very simple but notice that now we already have the reference of our entity framework context, this will help us to write our test scenarios.

Now we are going to create our base test class, this class will be responsible for initiating our test server and it will also contain some methods that will be common to all tests:

public class ProductScenariosBase
{
}

The most important method of this class will be the CreateServer() which is responsible for configuring our server, before creating it, we need two things:

  • Creating an appsettings.json file in the Product.IntegrationTests project, this file will be responsible for adding specific settings to our application and will prevent us from using the same settings as when we run the web api project.
{
"ConnectionString": "Server=(localdb)\\mssqllocaldb;Initial Catalog=product-integration-tests-db;Trusted_Connection=True;MultipleActiveResultSets=true"
}

At the moment we will only have one property inside the file which will be a database connection string, notice that I used a specific database name for the tests.

  • Create an extension method that based on IWebHost applies all our database migrations:
public static class HostExtensions
{
public static IWebHost MigrateDbContext<TContext>(
this IWebHost webHost,
Action<TContext, IServiceProvider> seeder)
where TContext : notnull, DbContext
{
using IServiceScope scope = webHost.Services.CreateScope();

MigrateDbContext(scope, seeder);

return webHost;
}


public static IHost MigrateDbContext<TContext>(
this IHost host,
Action<TContext, IServiceProvider> seeder)
where TContext : notnull, DbContext
{
using IServiceScope scope = host.Services.CreateScope();

MigrateDbContext(scope, seeder);

return host;
}

private static void MigrateDbContext<TContext>(
IServiceScope scope,
Action<TContext, IServiceProvider> seeder)
where TContext : notnull, DbContext
{
IServiceProvider services = scope.ServiceProvider;
ILogger<TContext> logger = services.GetRequiredService<ILogger<TContext>>();
TContext context = services.GetRequiredService<TContext>();

try
{
logger.LogInformation(
"Migrating database used on context {context}",
typeof(TContext).Name);

context.Database.Migrate();
seeder(context, services);

logger.LogInformation(
"Database used on context {context} migrated succesfully",
typeof(TContext).Name);
}
catch (Exception ex)
{
logger.LogError(
ex,
"An error occurred while migrating the datatabase used on context {context}",
typeof(TContext).Name);
}
}
}

This method basically gets a DbContext reference and applies all pending migrations. Now we can configure our TestServer:

public class ProductScenariosBase
{
public static ProductTestServer CreateServer()
{
IWebHostBuilder hostBuilder = new WebHostBuilder()
.UseContentRoot(Directory.GetCurrentDirectory())
.ConfigureAppConfiguration((context, configurationBuilder) =>
{
configurationBuilder
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddEnvironmentVariables();
})
.UseStartup<Startup>();

ProductTestServer testServer = new(hostBuilder);

testServer.Host.MigrateDbContext<ProductContext>((_, __) => { });

return testServer;
}
}

Here we are basically creating a WebHostBuilder and adding some configurations to it, for example, indicating that it will use the appsettings.json that we created earlier and that it will also use the same Startup.cs class of our web api project, then we create our ProductTestServer using the host builder and apply the migrations of our database and that’s it! Our server is now configured and we can start writing our tests.

Writing tests in a practical way

Finally we are going to write our tests. Let’s create our ProductScenarios class which inherited from ProductScenariosBase:

public class ProductScenarios : ProductScenariosBase
{
}

Now we can write our first test that will test the GET — api/v1/products endpoint which returns a paginated list of products:

[Fact]
public async Task Get_all_product_items_and_response_status_code_ok()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

// Act
HttpResponseMessage response = await httpClient.GetAsync("api/v1/products");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}

In the arrange session we are using the CreateServer() method of our base class to initialize our test server and then using the TestServer’s CreateClient() to create an HttpClient that will use to make the requests for our controller methods.

In the act session we perform a get request for the route of our endpoint.

In the assert session we only ensure that the status code of the request is 200 indicating the success of the operation, we could even add a new product to our database in the arrange session and then guarantee that this product is being returned, but as per as our database grows and we have more products, we will not be sure that this product will be returned and we may fall into one of the integration testing traps that I listed earlier.

Now we are going to test the endpoint that returns a specific product by id, as I mentioned before we are going to test two scenarios, the first one where we pass a valid id and the product is found and the second using an invalid id where the product is not found in the database, let’s start with the scenario where the product does not exist:

[Fact]
public async Task Get_product_by_id_and_response_status_code_notfound()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

// Act
HttpResponseMessage response = await httpClient
.GetAsync($"api/v1/products/{Guid.NewGuid()}");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

The Arrange session is exactly the same as the previous test that I already explained how it works. In the Act, we are making a get request for our endpoint’s route using a newly generated guid to ensure that there are no products with that id. In the Assert session, we ensure that the request status is 404 — Not Found, which is the expected status code for this scenario.

Now we are going to create another test for the same endpoint testing the scenario where the product exists, so in the test arrangement we are going to add a new product directly in the database, for this scenario the database context property that we added inside the ProductTestServer will be extremely useful:

[Fact]
public async Task Get_product_by_id_and_response_status_code_ok()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

API.Domain.Entities.Product product = new(
Guid.NewGuid(),
name: "Product name",
value: 1.2,
active: true);

await server.ProductContext.Products.AddAsync(product);
await server.ProductContext.SaveChangesAsync();

// Act
HttpResponseMessage response = await httpClient
.GetAsync($"api/v1/products/{product.Id}");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);

API.Domain.Entities.Product? content =
await GetRequestContent<API.Domain.Entities.Product>(response);

content.Should().NotBeNull();
content.Should().BeEquivalentTo(product);
}

As I mentioned above in the test arrange it was added a new product directly to the database and then we use the id of this product as a parameter in the request, in the test assertion we first check the status code of the request which needs to be 200, then we deserialize the request content result, to help us with this deserialization and reuse this logic in other tests I created a new method in the base class called GetRequestContent:

public static async Task<T?> GetRequestContent<T>(
HttpResponseMessage httpResponseMessage)
{
JsonSerializerOptions jsonSettings = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};

return JsonSerializer.Deserialize<T>(
await httpResponseMessage.Content.ReadAsStringAsync(),
jsonSettings);
}

After deserializing the result, we guarantee that the product information that came in the request result has the same product properties that we registered directly in the database.

Now we are going to write the test for the endpoint that creates a new product, in this test we will need to submit the product information that will be created in the request, for that we need to serialize the product data, to reuse this serialization logic I created a new method inside the ProductScenariosBase:

public static StringContent BuildRequestContent<T>(T content)
{
string serialized = JsonSerializer.Serialize(content);

return new StringContent(serialized, Encoding.UTF8, "application/json");
}

So the integration test for creating a new product looked like this:

[Fact]
public async Task Post_create_a_new_product_and_response_status_code_ok()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

CreateProductViewModel productToCreate = new("New Product Name", 1.2, true);
StringContent requestContent = BuildRequestContent(productToCreate);

// Act
HttpResponseMessage response = await httpClient
.PostAsync("api/v1/products", requestContent);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);

API.Domain.Entities.Product? content =
await GetRequestContent<API.Domain.Entities.Product>(response);

content.Should().NotBeNull();

API.Domain.Entities.Product? dbProduct =
await server.ProductContext.Products.FindAsync(content?.Id);

dbProduct.Should().NotBeNull();
dbProduct.Should().BeEquivalentTo(content);
}

In the Arrange part we build the data that we will submit, then in the Act section we simply make a Post request with the product serialized data. In the Assert, first we validate the status code of the request to ensure that it is 201, after that we deserialize the content of the request and look directly in the database for a product with the Id that was returned to ensure that this product was actually persisted correctly in the database.

Now we will go to our last endpoint to be tested, which is the one that changes the information of a previously registered product. As I mentioned earlier, we will need two tests:

  1. A product with a non-existent Id that will receive a not found result
  2. A product with correct information where it should persist the changes correctly in the database

Let’s start with the first scenário:

[Fact]
public async Task Put_update_product_and_response_status_code_notfound()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

UpdateProductViewModel productToUpdated = new(
Guid.NewGuid(),
"Updated name",
1.0);
StringContent requestContent = BuildRequestContent(productToUpdated);

// Act
HttpResponseMessage response = await httpClient
.PutAsync("api/v1/products", requestContent);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

At this point I think we no longer need so many explanations, you already understood the idea, we basically make a request to the update endpoint using the id of a product that does not exist and we make sure that the endpoint is returning a not found status code.

And now the last integration test that will test scenario two:

[Fact]
public async Task Put_update_product_and_response_status_code_ok()
{
// Arrange
using ProductTestServer server = CreateServer();
using HttpClient httpClient = server.CreateClient();

API.Domain.Entities.Product product = new(
Guid.NewGuid(),
name: "Product name",
value: 1.2,
active: true);

await server.ProductContext.Products.AddAsync(product);
await server.ProductContext.SaveChangesAsync();

UpdateProductViewModel productToUpdated = new(
product.Id, "Updated name (1)", 100.34);
StringContent requestContent = BuildRequestContent(productToUpdated);

// Act
HttpResponseMessage response = await httpClient
.PutAsync("api/v1/products", requestContent);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);

API.Domain.Entities.Product? dbProduct = await server.ProductContext.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == product.Id);

dbProduct.Should().NotBeNull();
dbProduct.Should().BeEquivalentTo(new API.Domain.Entities.Product(
product.Id,
"Updated name (1)",
100.34,
active: true));
}

We create a new product directly in the database and after that we make a request using the id of that product but with new information, in the assert part it is checked if the status code returned is ok and then we look for the information about that product directly in the database to ensure it matches the data we submitted to the updated endpoint.

That is all, we write integration test for all endpoints of our application.

Conclusion

Integration tests are more complex than unit tests, both in writing and execution, and they also raise more questions, furthermore if you don’t write good integration tests as your project suite grows you can end up having a big headache, I hope that with this post I have helped you avoiding integration testing pitfalls and also contributed to you writing more resilient integration tests.

Project github with full sample code.

Thank you for reading and feel free to contact me if you have any questions.

--

--