Photo by Jason Dent
Introduction
In the previous post we created a weather forecast API that returned hardcoded forecasts. We also created two integration tests that verified that our API returned the expected values.
But nobody likes hardcoded values, let's read the forecasts from a database instead.
Project structure
I've added three new projects to the solution.
MyWeatherApp.Common
Contains common reusable code, configuration extensions etc, nothing important for this blog post.
MyWeatherApp.Database
Contains migrations, DbContext and configuration for the database. I will use Postgres in this series.
MyWeatherApp.Database.Migrator
Simple console application responsible for migrating the database. In a real world scenario, this application would run as an init container before deploying the API in Kubernetes (for example).
The migrations are created by using the dotnet ef tool, example:
~/dev/MyWeatherApp/test/
dotnet ef migrations add Initial --project MyWeatherApp.Database --startup-project MyWeatherApp.Database.Migrator --context WeatherForecastDbContext
The --project
flag specifies where we want to put our migrations.
DbContext
I'm storing my domain objects in the database. All configuration regarding the entity is setup in the OnModelCreating method in my dbcontext. This allows me to keep EF specific stuff away from my domain model.
public class WeatherForecastDbContext : DbContext
{
public WeatherForecastDbContext(PostgresDatabaseOptions postgresDatabaseOptions)
: base(postgresDatabaseOptions.DbContextOptions)
{
}
public DbSet<WeatherForecast> WeatherForecasts { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<WeatherForecast>(x => x.HasKey(wf => wf.Id));
builder.Entity<WeatherForecast>().Property(x => x.TemperatureCelsius).IsRequired();
builder.Entity<WeatherForecast>()
.Property(x => x.Summary)
.HasMaxLength(WeatherForecast.SummaryMaxLength)
.IsRequired();
builder.Entity<WeatherForecast>().Property(x => x.Date).IsRequired();
}
}
The domain model looks like this:
public class WeatherForecast
{
public const int SummaryMaxLength = 256;
private WeatherForecast(Guid id, DateOnly date, int temperatureCelsius, string summary)
{
Id = id;
Date = date;
TemperatureCelsius = temperatureCelsius;
Summary = summary;
}
public Guid Id { get; }
public DateOnly Date { get; }
public int TemperatureCelsius { get; }
public string Summary { get; }
public static WeatherForecast Create(Guid id, DateOnly date, int temperatureCelsius, string summary)
{
// TODO Add validation - upcoming blog post.
return new WeatherForecast(id, date, temperatureCelsius, summary);
}
}
Notice that it's only possible to create a WeatherForecast instance by using the static Create method. We will add validation etc to that method in an upcoming blog post. EF Core will use the private constructor when reading the entities from the database. We will have a look at some common pitfalls regarding that as well in another upcoming post in this series. But for now, just remember that it's only possible to create an instance by using the Create method.
Database
I'm using Postgres, my instance is running locally using docker.
Using a real database when testing
Let's remove the hardcoded weather forecasts from our action method and use our database instead.
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly WeatherForecastDbContext _weatherForecastDbContext;
public WeatherForecastController(WeatherForecastDbContext weatherForecastDbContext)
{
_weatherForecastDbContext =
weatherForecastDbContext ?? throw new ArgumentNullException(nameof(weatherForecastDbContext));
}
[HttpGet]
public IAsyncEnumerable<WeatherForecastListResponse> List()
{
return _weatherForecastDbContext.WeatherForecasts
.Take(5) .OrderBy(x => x.Date)
.Select(x =>
new WeatherForecastListResponse(
x.Date, x.TemperatureCelsius, x.Summary))
.AsAsyncEnumerable();
}
}
Here I'm using the WeatherForecastDbContext directly in my action method. That's perfectly fine but usually I would use the CQS pattern and keep my controllers thin.
Database setup
First thing first, we want to use a specific database just for our tests, we want to add the _test suffix to the database name.
The existing connectionstring in our appsettings.Development.json looks like this:
"Postgres": {
"ConnectionString": "Host=127.0.0.1;Port=5432;Username=my_weather_app;Password=my_password;Database=my_weather_app"
}
We want to change the value of that connectionstring to this when we run our tests:
Host=127.0.0.1;Port=5432;Username=my_weather_app;Password=my_password;Database=my_weather_app_test
Let's update our MyWeatherAppApiTestConfiguration
that we introduced in the previous post so it looks like this:
public class MyWeatherAppApiTestConfiguration : TestConfiguration
{
public MyWeatherAppApiTestConfiguration()
{
this["ASPNETCORE_ENVIRONMENT"] = "TestRunner";
this["Postgres:ConnectionString"] =
"Host=127.0.0.1;Port=5432;Username=my_weather_app;Password=my_password;Database=my_weather_app_test";
}
public string PostgresConnectionString => this["Postgres:ConnectionString"];
}
We then create a new class, PostgresDatabaseFixture. This class will be responsible for migrating and resetting our test database.
public class PostgresDatabaseFixture : DatabaseFixture
{
private readonly PostgresDatabaseOptions _postgresDatabaseOptions;
public PostgresDatabaseFixture()
{
var configuration = new MyWeatherAppApiTestConfiguration();
_postgresDatabaseOptions = new PostgresDatabaseOptions
{
ConnectionString = configuration.PostgresConnectionString
};
}
public override async Task InitializeAsync()
{
await using var dbContext = new WeatherForecastDbContext(_postgresDatabaseOptions);
await dbContext.Database.MigrateAsync();
}
public override Task DisposeAsync()
{
return Task.CompletedTask;
}
public async Task ResetDatabase()
{
await using var npsqlConnection = new NpgsqlConnection(_postgresDatabaseOptions.ConnectionString);
await npsqlConnection.OpenAsync();
var respawner = await Respawner.CreateAsync(npsqlConnection,
new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { "public" },
TablesToIgnore = new Table[] { "__EFMigrationsHistory" }
});
await respawner.ResetAsync(npsqlConnection);
}
}
Some important concepts here:
- In the InitializeAsync method we migrate our database using our dbcontext.
- We've also introduced a ResetDatabase method. We will call this method in all our tests in the arrange phase. We're using a library called Respawn to reset our database state before running each test. I won't go into anymore details here, just remember that we are using it to get a clean slate when setting up our tests.
Only thing left to do is to connect our PostgresDatabaseFixture to our tests to ensure that we have a migrated database before running our tests.
When using xUnit, this can be achieved by using a ICollectionFixture.
It looks like this:
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<PostgresDatabaseFixture>
{
public const string Name = "Integration Test";
}
When we then apply the attribute on our test class two things will happen:
- The InitializeAsync method will run (since we're implementing the IAsyncLifetime interface) and migrate our database.
- We can now inject the PostgresDatabaseFixture in our test constructor like this:
[Collection(IntegrationTestCollection.Name)]
public class WeatherForecastTests : IClassFixture<MyWeatherAppApiFixture>
{
private readonly MyWeatherAppApiFixture _fixture;
private readonly PostgresDatabaseFixture _postgresDatabaseFixture;
public WeatherForecastTests(
MyWeatherAppApiFixture fixture,
PostgresDatabaseFixture databaseFixture)
{
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
_postgresDatabaseFixture = databaseFixture ?? throw new ArgumentNullException(nameof(databaseFixture));
}
........
If we now run our existing integration tests, one of them will fail.
[Fact]
public async Task GET_WeatherForecast_ShouldReturnExpectedForecast()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/weatherforecast");
var client = _fixture.CreateClient();
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStreamAsync();
var jsonResponse = await JsonDocument.ParseAsync(responseContent);
weatherForecastResponse.Count().ShouldBe(5);
.......
}
We expect to find 5 forecasts in the response, but instead we get 0.
That's expected though! Since we're now reading the weather forecasts from our database instead of using the hardcoded ones, we need to populate the database before this test can pass. Let's update our test:
[Fact]
public async Task GET_WeatherForecast_ShouldReturnExpectedForecast()
{
await _postgresDatabaseFixture.ResetDatabase();
var arrangeDbContext = _postgresDatabaseFixture.CreateWeatherForecastDbContext();
var existingWeatherForecasts = new List<Core.WeatherForecast>
{
Core.WeatherForecast.Create(Guid.NewGuid(), DateOnly.Parse("2022-10-17"), 12, "Freezing"),
Core.WeatherForecast.Create(Guid.NewGuid(), DateOnly.Parse("2022-10-18"), 13, "Bracing"),
Core.WeatherForecast.Create(Guid.NewGuid(), DateOnly.Parse("2022-10-19"), 14, "Chilly"),
Core.WeatherForecast.Create(Guid.NewGuid(), DateOnly.Parse("2022-10-20"), 14, "Cool"),
Core.WeatherForecast.Create(Guid.NewGuid(), DateOnly.Parse("2022-10-21"), 14, "Mild"),
};
arrangeDbContext.WeatherForecasts.AddRange(existingWeatherForecasts);
await arrangeDbContext.SaveChangesAsync();
......
}
We're now adding five weather forecasts to our database and when running the test again, it's now green!
Conclusion
This was a rather long post, but we've achieved quite a lot of things:
- We're now using a real postgres database in our application.
- We're now using a real postgres database when running our tests.
- We're able to migrate/create/reset/seed our database from our test project.
The next post will focus on our CI-pipeline, our tests are now failing since our GitHub Action has not been configured to spin up a Postgres database...