Integration Tests with In-Memory MongoDB

Athan Bonis
tech.thesignalgroup
9 min readDec 20, 2023
Image Generated by DALL-E with a MongoDB TM addition

Adding Integration Tests to your API is a crucial step towards a robust system. Especially when many different parts try to integrate, Tests can provide a great deal of peace of mind when they cover many different aspects of the system.

In this article, I am going to give a detailed example of how you can implement Integration Tests for your .NET Web API using an In-Memory MongoDB instead of dealing with a Real Database. There are 3 main reasons for using an in-memory database for Integration Tests:

  • Speed of execution — In-memory databases are typically faster than disk-based databases since they operate in the system’s memory.
  • Test case isolation — Each test starts with a clean state of the database ensuring that tests do not interfere with each other
  • Simplicity — Using in-memory databases you reduce a lot the Setup and Teardown of the tests

Steps 1, 2, and 3 will be regarding the setup of a .NET Web API along with a MongoDB, so if you already have everything about it, feel free to skip them to Step 4.

First, we’ll create a new .NET Web API that will demonstrate two simple operations of a Fruit Market:

  • Add new Item with Price (price per kg)
  • Get a List of Items

These two operations will be written and read from a real MongoDB, which we will set up in a simple Docker container. If you already have MongoDB running on your machine you can skip the first step.

Step 1 — Run a MongoDB instance via Docker

If you don’t have Docker installed already, you will need to download and install it first.

First, let’s pull our image, just by running the following command:

docker pull mongo:latest

Once the mongo image is downloaded, let’s run it:

docker run -d --name mongodb -p 27017:27017 mongo

That’s it. MongoDB is up and running in a Docker container. In case you want to access it, you can use MongoDB Compass and you can connect using the connection string:

mongodb://localhost:27017

Step 2 — Setup the .NET Web API

For this example, I am going to use the brand new version of .NET which was released on November 14, the .NET8 which is LTs, but it does not matter at all, since we are not going to use any .NET8 specific.

As I mentioned earlier, we are going to demonstrate a Fruit Market API. Let’s go and create it:

dotnet new web -n MangoMarket

So, nothing fancy up until now. Here is how our Program.cs looks like:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

What will we need first? Surely an Item for our Fruit Market. Let’s create it:

public class Item
{
public Item(string name, double price)
{
Name = name;
Price = price;
}

public string Name { get; }
public double Price { get; }
}

Simple as that for now, it is just Name and Price here.

Now that we have our Item let’s define the two endpoints that we are going to demonstrate as mentioned.

Let’s add also some good additions that will help us. Swagger of course!

Install the packages:

dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Swashbuckle.AspNetCore

Just before the builder.Build() add:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

and after the app is built:

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

Also not required but good to have as well:

app.UseHttpsRedirection();

We are going to use the Minimal APIs approach and if you want to have an introduction to them first you can follow one of my previous articles that goes step by step into them: Introduction to .NET Minimal APIs.

app.MapPost("/addItem", ([FromQuery]string name, [FromQuery] double price) =>
{
var item = new Item(name, price);
})
.WithName("AddItem")
.WithOpenApi();

app.MapGet("/list", () =>
{
return Array.Empty<Item>();
})
.WithName("GetListOfItems")
.WithOpenApi();

Currently, our endpoints do nothing. In the next step, we are going to use the MongoDB that we set up in our previous step.

Step 3 — Store and Retrieve data from MongoDB

To connect to our MongoDB instance we will use the MongoDB.Driver which is provided officially by MongoDB. Let’s install the library:

dotnet add package MongoDB.Driver

Let’s register the MongoClient in our DI Container as well:

builder.Services.AddSingleton<IMongoClient, MongoClient>(_ => new MongoClient("mongodb://localhost:27017"));

Since each Item will be a document in the MongoDB, the Item needs also an Id type of ObjectId and will need a setter for each field as well. Eventually we will end up with the following:

public class Item
{
public Item(string name, double price)
{
Name = name;
Price = price;
}

public ObjectId Id { get; init; }

public string Name { get; init; }
public double Price { get; init; }
}

We are using theinit keyword here to prevent value changes after the creation of the object.

So that’s it about the setup. First, we are going to modify the /addItem endpoint:

app.MapPost("/addItem", (
[FromServices] IMongoClient mongoClient,
[FromQuery] string name,
[FromQuery] double price) =>
{
var item = new Item(name, price);
var database = mongoClient.GetDatabase("MangoMarket");
var collection = database.GetCollection<Item>("Items");
collection.InsertOneAsync(item);
})
.WithName("AddItem")
.WithOpenApi();

For the sake of simplicity, I am using directly the Connection String here, but keep in mind that you should always prefer to store it in the appsettings and access it through the IConfiguration, since most probably you will have different databases per environment, and not forget, never put the credentials into the appsettings, never, ever! The same I am doing with many things that is not our focus for this article.

So what am I doing here? I am getting the Database and then the Collection that I am interested in and I am inserting a new Document for this Item. Similarly, the /list endpoint:

app.MapGet("/list", ([FromServices] IMongoClient mongoClient) =>
{
var database = mongoClient.GetDatabase("MangoMarket");
var collection = database.GetCollection<Item>("Items");
return collection.Find(_ => true).ToListAsync();
})
.WithName("GetListOfItems")
.WithOpenApi();

So everything regarding our API is set. Let’s dive now into the main reason for the article. The Integration Tests of it.

Step 4 — Setup Integration Tests

First, let’s create a new project for the Integration Tests. In this example, we are going to use xUnit. You can either create it through your IDE or using dotnet CLI:

dotnet new xunit -n MangoMarket.IntegrationTests

Then add a reference to our API project, either using IDE, dotnet CLI or directly into the csproj file:

<ItemGroup>
<ProjectReference Include="..\MangoMarket\MangoMarket.csproj" />
</ItemGroup>

We have our brand new Integration Tests project, we referenced our API project, and now we need our tests!

But! This article is about running Mongo in memory for Integration Tests. We will need to set it up, right? Correct.

What we will use for our case is the Mongo2Go package. Navigate to the Integration Tests folder and install it:

dotnet add package Mongo2Go

Great.

To run Integration Tests to our existing API we will need the following package as well since we are going to use the WebApplicationFactory:

dotnet add package Microsoft.AspNetCore.Mvc.Testing

Now we are going to create a Fixture. What is aFixture in Unit/Integration Testing?

It’s a way of setting up a consistent, repeatable environment for a set of tests. The definition could be that a Fixture refers to a set of preconditions needed to perform tests consistently and reliably.

So what are our preconditions? Of course — the setup of the in-memory MongoDB!

Create a new class ItemsFixture:

namespace MangoMarket.IntegrationTests;

public class MongoDbFixture
{

}

This class will need to Start the in-memory MongoDB at the beginning of our test set and initialize a MongoClient as well:

namespace MangoMarket.IntegrationTests;

public class MongoDbFixture
{
public MongoDbRunner Runner { get; private set; }
public MongoClient Client { get; private set; }

public MongoDbFixture()
{
Runner = MongoDbRunner.Start();
Client = new MongoClient(Runner.ConnectionString);
}
}

The MongoDbRunner is a class provided by the Mongo2Go library and is responsible for starting a MongoDB instance in memory. So, what else should we do for the already-started MongoDB instance when the set of tests is finished? We need to Dispose the Runner! Here we go:

namespace MangoMarket.IntegrationTests;

public class MongoDbFixture : IDisposable
{
public MongoDbRunner Runner { get; }
public MongoClient Client { get; }

public MongoDbFixture()
{
Runner = MongoDbRunner.Start();
Client = new MongoClient(Runner.ConnectionString);
}

public void Dispose()
{
Runner.Dispose();
}
}

Since we have our Fixture, let’s create a new ItemsTests class to write our Tests:

namespace MangoMarket.IntegrationTests;

public class ItemsTests : IClassFixture<MongoDbFixture>
{

}

That’s how we can define the Fixture that we are going to use in our ItemsTests class. xUnit Library provides the IClassFixture<T> interface and the rest is on us.

Before going to the next steps, we need to make a small change in the Program.cs, to be able to configure and run the tests. You just need to explicitly define the Program class. So eventually you will end up with this:

namespace MangoMarket;

public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IMongoClient, MongoClient>(_ => new MongoClient("mongodb://localhost:27017"));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.MapPost("/addItem", (
[FromServices] IMongoClient mongoClient,
[FromQuery] string name,
[FromQuery] double price) =>
{
var item = new Item(name, price);
var database = mongoClient.GetDatabase("MangoMarket");
var collection = database.GetCollection<Item>("Items");
collection.InsertOneAsync(item);
})
.WithName("AddItem")
.WithOpenApi();

app.MapGet("/list", ([FromServices] IMongoClient mongoClient) =>
{
var database = mongoClient.GetDatabase("MangoMarket");
var collection = database.GetCollection<Item>("Items");
return collection.Find(_ => true).ToListAsync();
})
.WithName("GetListOfItems")
.WithOpenApi();

app.Run();
}
}

What’s next? Of course, take the brand new in-memory MongoDB instance and give it to our API to use!

namespace MangoMarket.IntegrationTests;

public class ItemsTests : IClassFixture<MongoDbFixture>
{
private readonly MongoDbFixture _fixture;
private readonly HttpClient _client;

public ItemsTests(MongoDbFixture fixture)
{
_fixture = fixture;
var appFactory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IMongoClient>();
services.AddSingleton<IMongoClient>(
(_) => _fixture.Client);
});
});
_client = appFactory.CreateClient();
}
}

The important trick here is the following:

builder.ConfigureTestServices(services =>
{
services.RemoveAll<IMongoClient>();
services.AddSingleton<IMongoClient>((_) => _fixture.Client);
});

Since our .NET Web API does not know at all about this in-memory MongoDB instance, we need somehow to say: Hey! Don’t use what you have configured as a MongoDB Client, take this one.

What essentially these two lines are doing is that first, it removes all the Registered Services of type (T) and then registers a new Service for the IMongoClient interface, which is the one that we started from our previously created Fixture!

The rest is:

  1. Initialize an instance of your Web API that you are going to test
  2. Create a new HttpClient to access this API

Let’s create our first test, about getting the list of fruits:

[Fact]
public async Task GetAllFruits_ShouldReturnFruits()
{
// Arrange
var mangoMarketDb = _fixture.Client.GetDatabase("MangoMarket");
var collection = mangoMarketDb.GetCollection<MangoMarket.Item>("Items");
await collection.InsertOneAsync(new MangoMarket.Item("Test Item", 3));

// Act
var res = await _client.GetAsync("/list");
res.EnsureSuccessStatusCode();
var content = await res.Content.ReadAsStringAsync();
var items = JsonSerializer.Deserialize<ICollection<Item>>(content, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

// Assert
Assert.NotNull(items);
Assert.NotEmpty(items);
Assert.Equal(1, items.Count);
var resultItem = items.FirstOrDefault();
Assert.NotNull(resultItem);
}

internal record Item(string Name, double Price);

Arrange:
We get the Database and then the Collection and we insert one item in the collection.

Act:
We call the Web API /list endpoint, and after we Ensure that the StatusCode is a successful one, we deserialize the response.

Assert:
Since we are sure that we have one item in our MongoDB Items collection we need to make sure that:

  • The Response is Not Null
  • The Response is Not Empty
  • There is exactly one item in the response
  • The one item is Not Null
  • The item has the same Name and the same Price with the expected one

You may notice that I am using:

internal record Item(string Name, double Price);

for the response deserialization. That’s because I want to explicitly point out that this is the response that is coming from an External API.

Everything seems fine with our first test, right? Almost. As a best practice, each test must be isolated and not depend on the data left behind by other tests. Before running each test, the database state should be predictable.

That’s not the case with our current implementation. Why? Because we insert a document, but we never clean it up! So, what we eventually will do is to implement IDisposable in ItemsTests:

public void Dispose()
{
var mangoMarketDb = _fixture.Client.GetDatabase("MangoMarket");
var collection = mangoMarketDb.GetCollection<MangoMarket.Item>("Items");
collection.DeleteManyAsync(_ => true).Wait();
}

By cleaning up after each test, you maintain predictability and you are one hundred percent sure that the database is in the correct state.

You can run the tests by going into the Integration Tests project that we created and run the following:

dotnet test

Just to be sure that this is the case, we can create another Test that does not add anything to the database and be sure that the result is empty:

[Fact]
public async Task GetAllFruits_NoFruitsInDb_ShouldReturnNoFruits()
{
// Arrange

// Act
var res = await _client.GetAsync("/list");
res.EnsureSuccessStatusCode();
var content = await res.Content.ReadAsStringAsync();
var items = JsonSerializer.Deserialize<ICollection<Item>>(content, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

// Assert
Assert.NotNull(items);
Assert.Empty(items);
}

As you will find out, both tests are Green ✅.

Let’s add another Test, for our /addItem endpoint:

[Fact]
public async Task AddItem_ShouldAddNewItem()
{
// Arrange
var itemName = "Bananas";
var itemPrice = 3.02;

// Act
var res = await _client.PostAsync($"/addItem?name={itemName}&price={itemPrice}", null);
res.EnsureSuccessStatusCode();

// Assert
var mangoMarketDb = _fixture.Client.GetDatabase("MangoMarket");
var collection = mangoMarketDb.GetCollection<MangoMarket.Item>("Items");
var item = await collection.Find(x => x.Name == itemName).FirstOrDefaultAsync();

Assert.NotNull(item);
Assert.Equal(itemName, item.Name);
Assert.Equal(itemPrice, item.Price);
}

You may notice something that can be weird for some people. In the Assert part, I am checking the Database directly and I am not going to the API.

But why do you do that?

Checking the database directly ensures that the API alters correctly the data, so eventually it does its job correctly. Of course, you can also add the /list endpoint call as well and ensure that it’s there as well, nothing stops you.

That’s it! You now have Integration Tests running with an in-memory MongoDB.

For more articles consider making a follow on my account.

--

--

Athan Bonis
tech.thesignalgroup

Ηighly motivated Software Engineer. I have a strong passion for problem-solving and learning new technologies, targeting to produce clean and structured code.