Logo
Overview
Un Talos européen de qualité - Part X - Dev et Tests d'intégration

Un Talos européen de qualité - Part X - Dev et Tests d'intégration

October 31, 2025
12 min read
part-10

Objectif 🎯

Dans cette partie de la série, nous allons faire un interlude dédié au développement d’un exemple d’application en WebApi ASP.NET simple, dont l’objectif sera son intégration complète à notre toute nouvelle infrastructure flambant neuve, incluant tests d’intégration sur une réelle base de données PostgreSQL, qualité / métriques de code et couverture de tests.

Initialisation

Créer un répertoire projet vide nommé conduit puis utiliser Mise pour installer la dernière version du sdk dotnet via la commande mise use dotnet@10. Enfin, initialisez le projet comme suit :

Terminal window
dotnet new webapi -o Conduit.WebApi
dotnet new gitignore
dotnet new editorconfig
dotnet new sln
dotnet sln add Conduit.WebApi
dotnet new classlib -o Conduit.Business
dotnet sln add Conduit.Business
dotnet add Conduit.WebApi reference Conduit.Business

Créer le repo Git ohmytalos/conduit (ohmytalos en tant qu’organisation ici, mais rien n’empêche d’utiliser votre propre compte) sur votre nouvelle instance Gitea bien fraîche et poussez-y votre code :

Terminal window
g init
gaa
gcmsg "init"
git remote add origin git@ssh.ohmytalos.io:ohmytalos/conduit.git
git push -u origin main
Note (Remarque)

On utilise SSH pour le push de code sur Gitea, assurez-vous donc d’avoir ajouté vos clés SSH publiques à votre compte Gitea.

Entités et EF Core

Créez 2 entités :

Conduit.Business/Models/User.cs
using System.ComponentModel.DataAnnotations;
namespace Conduit.Business.Models;
public class User
{
private readonly List<Article> _articles = [];
public int Id { get; set; }
[MaxLength(255)]
public required string Name { get; set; }
[MaxLength(255)]
public required string Email { get; set; }
public string? Bio { get; set; }
[MaxLength(255)]
public string? Image { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public virtual IReadOnlyCollection<Article> Articles => _articles;
}

Installer les dépendances ORM :

Terminal window
dotnet add Conduit.Business package Microsoft.Extensions.Configuration.Abstractions
dotnet add Conduit.Business package Microsoft.Extensions.DependencyInjection.Abstractions
dotnet add Conduit.Business package Microsoft.EntityFrameworkCore
dotnet add Conduit.Business package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add Conduit.Business package Microsoft.EntityFrameworkCore.Proxies
dotnet add Conduit.WebApi package Microsoft.EntityFrameworkCore.Design
Conduit.Business/Contexts/AppDbContext.cs
using Conduit.Business.Models;
using Microsoft.EntityFrameworkCore;
namespace Conduit.Business.Contexts;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<Article> Articles => Set<Article>();
}
Conduit.Business/Extensions/BusinessServicesExtensions.cs
using Conduit.Business.Contexts;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Conduit.Business.Extensions;
public static class BusinessServicesExtensions
{
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
return services
.AddDbContext<AppDbContext>(options =>
options
.UseLazyLoadingProxies()
.UseNpgsql(
configuration.GetConnectionString("DefaultConnection")
)
);
}
}
Conduit.WebApi/appsettings.Development.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=conduit;Username=conduit;Password=conduit"
}
}

Installer les outils de migration DB et de qualité de code :

Terminal window
dotnet new tool-manifest
dotnet tool install dotnet-ef
dotnet tool install dotnet-sonarscanner
dotnet tool install coverlet.console

Lancer un postgres local :

compose.yaml
services:
db:
image: postgres:18
environment:
POSTGRES_USER: conduit
POSTGRES_PASSWORD: conduit
POSTGRES_DB: conduit
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Terminal window
docker compose up -d

À ce stade, nous pouvons générer les fichiers de migration et les appliquer. Nous utiliserons les tasks intégrées dans mise pour centraliser les commandes récurrentes.

mise.toml
[tools]
dotnet = "10"
[tasks.dev]
run = "dotnet run --project Conduit.WebApi"
[tasks.migrations-add]
usage = '''
arg "<name>" help="Name of migration"
'''
run = '''
dotnet ef migrations add ${usage_name?} --project Conduit.Business --startup-project Conduit.WebApi
'''
[tasks.migrations-remove]
run = "dotnet ef migrations remove --project Conduit.Business --startup-project Conduit.WebApi"
[tasks.db-update]
run = "dotnet ef database update --project Conduit.Business --startup-project Conduit.WebApi"
Terminal window
mise migrations-add InitialCreate
mise db-update

Les tables Articles et Users sont maintenant créées dans la base de données, avec la clé étrangère qui va bien.

Prévoyez dès maintenant d’exclure les fichiers générés par EF Core de l’analyse de code :

.editorconfig
#...
[**/Migrations/*.cs]
generated_code = true

On en profite pour inscrire les bonnes pratiques en rajoutant un fichier Directory.Build.props à la racine du repo pour configurer les paramètres globaux à tous les projets, incluant l’enrichissement de l’analyseur de code Roslyn .NET existant avec SonarAnalyzer, le traitement des warnings en erreurs, le verrouillage des versions de packages NuGet :

Directory.Build.props
<Project>
<PropertyGroup>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="SonarAnalyzer.CSharp"
Version="10.15.0.120848"
PrivateAssets="all"
Condition="$(MSBuildProjectExtension) == '.csproj'"
/>
</ItemGroup>
<ItemGroup>
<SonarQubeSetting Include="sonar.exclusions">
<Value>**/Migrations/*.cs</Value>
</SonarQubeSetting>
<SonarQubeSetting Include="sonar.coverage.exclusions">
<Value>**/Migrations/*.cs</Value>
</SonarQubeSetting>
</ItemGroup>
</Project>

Minimal API

Installer les dépendances (explorateur OpenAPI et validations DTOs) :

Terminal window
dotnet add Conduit.WebApi package Scalar.AspNetCore
dotnet add Conduit.WebApi package FluentValidation
dotnet add Conduit.WebApi package FluentValidation.DependencyInjectionExtensions

Créez les DTOs :

Conduit.WebApi/Dtos/AuthorDto.cs
using System.ComponentModel.DataAnnotations;
using Conduit.Business.Models;
namespace Conduit.WebApi.Dtos;
public class AuthorDto
{
public required string Name { get; set; }
public required string Email { get; set; }
public string? Bio { get; set; }
public string? Image { get; set; }
}
public static class AuthorDtoExtensions
{
public static AuthorDto ToDto(this User author)
{
return new AuthorDto
{
Name = author.Name,
Email = author.Email,
Bio = author.Bio,
Image = author.Image
};
}
}

Un petit ensemble d’endpoints minimal API, avec récupération d’articles paginés, vue détail et création d’article avec validation :

Conduit.WebApi/Endpoints/ArticlesEndpoints.cs
using Conduit.Business.Contexts;
using Conduit.WebApi.Dtos;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Conduit.WebApi.Endpoints;
public static class ArticlesEndpoints
{
public static void MapArticlesEndpoints(this WebApplication app)
{
app.MapGet("/articles", async (AppDbContext dbContext, int offset = 0, int limit = 20) =>
{
var articles = await dbContext.Articles
.Skip(offset)
.Take(Math.Min(limit, 100))
.Include(a => a.Author)
.ToListAsync();
var total = await dbContext.Articles.CountAsync();
return Results.Ok(new ArticlesDto
{
Items = [.. articles.Select(a => a.ToDto())],
Total = total
});
})
.Produces<ArticlesDto>(StatusCodes.Status200OK);
app.MapGet("/articles/{slug}", async (AppDbContext dbContext, string slug) =>
{
var article = await dbContext.Articles
.FirstOrDefaultAsync(a => a.Slug == slug);
return article is null
? Results.NotFound()
: Results.Ok(article.ToDto());
})
.Produces<NotFoundResult>(StatusCodes.Status404NotFound)
.Produces<ArticleDto>(StatusCodes.Status200OK);
app.MapPost("/articles", async (AppDbContext dbContext, NewArticleDto articleDto, IValidator<NewArticleDto> validator) =>
{
var validationResult = await validator.ValidateAsync(articleDto);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var author = await dbContext.Users
.FirstOrDefaultAsync(u => u.Name == articleDto.Author);
if (author is null)
{
return Results.NotFound();
}
var article = articleDto.ToModel(author);
dbContext.Articles.Add(article);
await dbContext.SaveChangesAsync();
return Results.Created($"/articles/{article.Slug}", article.ToDto());
})
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<NotFoundResult>(StatusCodes.Status404NotFound)
.Produces<ArticleDto>(StatusCodes.Status201Created);
}
}

Puis le point d’entrée du programme :

Conduit.WebApi/Program.cs
using System.Reflection;
using Conduit.Business.Extensions;
using Conduit.WebApi.Endpoints;
using FluentValidation;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddBusinessServices(builder.Configuration)
.AddOpenApi()
.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
var app = builder.Build();
app.MapOpenApi();
app.MapScalarApiReference();
app.UseHttpsRedirection();
app.MapArticlesEndpoints();
await app.RunAsync();
public partial class Program
{
protected Program() { }
}

Lancer mise dev devrait fonctionner, et aller sur /articles, il devrait retourner une liste vide.

Seed de données

On va maintenant se créer une petite application console pour seed les données.

Terminal window
dotnet new console -o Conduit.Console
dotnet sln add Conduit.Console
dotnet add Conduit.Console reference Conduit.Business
dotnet add Conduit.Console package Microsoft.Extensions.Hosting
dotnet add Conduit.Console package Bogus
dotnet add Conduit.Console package Respawn
Conduit.Console/appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=conduit;Username=conduit;Password=conduit"
}
}
Conduit.Console/Commands/SeederCommand.cs
using Bogus;
using Conduit.Business.Contexts;
using Conduit.Business.Models;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Respawn;
namespace Conduit.Console.Commands;
public class SeederCommand(
AppDbContext dbContext
)
{
public async Task SeedAsync()
{
await RefreshDatabase();
var users = new Faker<User>()
.RuleFor(u => u.Name, f => f.Internet.UserName())
.RuleFor(u => u.Email, (f, u) => f.Internet.Email(u.Name))
.RuleFor(u => u.Bio, f => f.Lorem.Sentence())
.RuleFor(u => u.Image, f => f.Internet.Avatar())
.Generate(50);
dbContext.Users.AddRange(users);
await dbContext.SaveChangesAsync();
dbContext.Articles.AddRange(
new Faker<Article>()
.RuleFor(a => a.Title, f => f.Lorem.Sentence())
.RuleFor(a => a.Description, f => f.Lorem.Sentences(2))
.RuleFor(a => a.Body, f => f.Lorem.Paragraphs(3))
.RuleFor(a => a.CreatedAt, f => f.Date.Past().ToUniversalTime())
.RuleFor(a => a.UpdatedAt, (f, a) => a.CreatedAt)
.RuleFor(a => a.Slug, (f, a) => f.Lorem.Slug())
.RuleFor(a => a.Author, f => f.PickRandom(users))
.Generate(1000)
);
await dbContext.SaveChangesAsync();
}
private async Task RefreshDatabase()
{
using var connection = new NpgsqlConnection(
dbContext
.Database
.GetConnectionString()
);
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
TablesToIgnore =
[
"__EFMigrationsHistory"
],
SchemasToInclude =
[
"public"
],
DbAdapter = DbAdapter.Postgres
});
await respawner.ResetAsync(connection);
}
}
Conduit.Console/Program.cs
using Conduit.Business.Extensions;
using Conduit.Console.Commands;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddJsonFile("appsettings.json");
})
.ConfigureServices((context, services) =>
{
services.AddBusinessServices(context.Configuration);
services.AddTransient<SeederCommand>();
})
.Build();
var seeder = host.Services.GetRequiredService<SeederCommand>();
await seeder.SeedAsync();
mise.toml
# ...
[tasks.db-seed]
run = "dotnet run"
dir = "Conduit.Console"

Lancer mise db-seed pour remplir la base de données avec des utilisateurs et des articles factices. Puis confirmer le fonctionnement de l’endpoint /articles, qui devrait désormais retourner une liste d’articles proprement paginée.

N’hésiter pas à tester que la création fonctionne bien également avec la bonne validation d’unicité du slug et de l’auteur existant.

Tests d’intégration

Nous allons utiliser l’incontournable Testcontainers pour lancer une base de données PostgreSQL éphémère lors de nos tests d’intégration. On utilisera également Respawn pour s’assurer que la base de données est propre avant chaque test.

Terminal window
dotnet new xunit -o Conduit.Tests
dotnet sln add Conduit.Tests
dotnet add Conduit.Tests reference Conduit.WebApi
dotnet add Conduit.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Conduit.Tests package Testcontainers.PostgreSql
dotnet add Conduit.Tests package Respawn

On configure tout d’abord le Host applicatif qui sera l’instance WebApi sur lequel on exécutera tous les tests. Cette instance est configurée pour utiliser une base de données PostgreSQL lancée via Testcontainers.

Conduit.Tests/ConduitApiFixture.cs
using Conduit.Business.Contexts;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
namespace Conduit.Tests;
public class ConduitApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder()
.WithDatabase("conduit")
.WithUsername("conduit")
.WithPassword("conduit")
.WithImage("postgres:18")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder
.UseEnvironment("Testing")
.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddDbContext<AppDbContext>((options) =>
{
options
.UseLazyLoadingProxies()
.UseNpgsql(_postgreSqlContainer.GetConnectionString());
});
});
}
private async Task MigrateDatabase()
{
using var scope = Services.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();
}
public async Task InitializeAsync()
{
await _postgreSqlContainer.StartAsync();
await MigrateDatabase();
}
public new async Task DisposeAsync()
{
await _postgreSqlContainer.DisposeAsync();
await base.DisposeAsync();
}
}

On crée ensuite une classe de base pour toutes nos classes de tests, qui sera en charge de rafraîchir la base de données avant chaque test.

Conduit.Tests/TestBase.cs
using Conduit.Business.Contexts;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Respawn;
using Xunit.Abstractions;
namespace Conduit.Tests;
[CollectionDefinition(nameof(DatabaseCollectionTest))]
public class DatabaseCollectionTest : ICollectionFixture<ConduitApiFixture>;
[Collection(nameof(DatabaseCollectionTest))]
public abstract class TestBase(ConduitApiFixture factory, ITestOutputHelper output) : IAsyncLifetime
{
protected ITestOutputHelper Output => output;
protected ConduitApiFixture Factory => factory;
protected AppDbContext DbContext => Factory.Services.GetRequiredService<AppDbContext>();
public async Task RefreshDatabase()
{
using var connection = new NpgsqlConnection(
DbContext
.Database
.GetConnectionString()
);
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
TablesToIgnore =
[
"__EFMigrationsHistory"
],
SchemasToInclude =
[
"public"
],
DbAdapter = DbAdapter.Postgres
});
await respawner.ResetAsync(connection);
Output.WriteLine("Database refreshed");
}
public async Task InitializeAsync()
{
await RefreshDatabase();
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
}

Une classe d’exemple basique pour tester la récupération d’un article via l’API.

Conduit.Tests/Articles/ArticleGetTests.cs
using System.Net;
using System.Net.Http.Json;
using Conduit.WebApi.Dtos;
using Xunit.Abstractions;
namespace Conduit.Tests.Articles;
public class ArticleGetTests(ConduitApiFixture factory, ITestOutputHelper output) : TestBase(factory, output)
{
[Fact]
public async Task CannotGetNonExistentArticle()
{
var client = Factory.CreateClient();
var response = await client.GetAsync("/articles/slug-article");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task CanGetExistentArticle()
{
var user = await DbContext.Users.AddAsync(new()
{
Name = "johndoe",
Email = "johndoe@example.com",
});
await DbContext.Articles.AddAsync(new()
{
Slug = "slug-article",
Title = "Title Article",
Description = "Description Article",
Body = "Body Article",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Author = user.Entity
});
await DbContext.SaveChangesAsync();
var client = Factory.CreateClient();
var response = await client.GetAsync("/articles/slug-article");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var articleResponse = await response.Content.ReadFromJsonAsync<ArticleDto>();
Assert.Equivalent(new
{
Slug = "slug-article",
Title = "Title Article",
Description = "Description Article",
Body = "Body Article",
Author = new
{
Name = "johndoe",
Email = "johndoe@example.com"
}
}, articleResponse);
}
}
mise.toml
# ...
[tasks.test]
run = "dotnet test --logger \"console;verbosity=detailed\""

Voilà, il y a tout ce qu’il faut pour bien démarrer, lancer mise test pour exécuter les tests d’intégration. Les conteneurs de DBs seront automatiquement lancés et exécutés lors des tests et tout devrait passer au vert.

Conclusion

Dans cette partie, nous avons mis en place un exemple d’application .NET minimaliste avec une API REST, pushée sur notre instance Gitea. Nous avons vu comment configurer Entity Framework Core avec PostgreSQL, créer des entités et des DTOs, et implémenter des endpoints pour gérer les articles. Nous avons également mis en place des tests d’intégration robustes utilisant Testcontainers pour garantir la fiabilité de notre application.

Il est enfin temps d’en finir dans la dernière partie.