TestWebApplicationFactory instances shared the same in-memory database
because EF Core's UseInMemoryDatabase("InMemory") returns the same
backing store to every DbContext that asks for it under the same
connection string, in the same process. Whichever fixture started
first defined the state, and every subsequent fixture inherited it,
making tests silently order-dependent and flaky.
Fix:
- Yavsc.Tests.Shared/InMemoryDatabaseName: helper that suffixes the
in-memory connection string with a per-fixture GUID.
- TestWebApplicationFactory: instance GUID + ConnectionStrings__
YavscConnection set as an environment variable in the constructor
and cleared in Dispose, so each factory gets its own backing store.
Env var is needed because IdentityServer8.EntityFramework exposes
ConfigureDbContext as Action<DbContextOptionsBuilder> with no
service-provider access, so the connection string is captured at
registration time. AddEnvironmentVariables is the last provider in
the config pipeline and wins regardless.
- WebServerFixture: process-static GUID (WebHostFixture is a
per-process singleton by design, so the test collection shares one
store; the GUID still isolates from TestWebApplicationFactory).
- AddIdentityDBAndStores: read the connection string at DbContext
construction time via the (sp, options) overload of AddDbContext,
so test fixtures can override it via the host's IConfiguration.
IdentityServer stores cannot do the same without subclassing the
framework's DbContexts; the env var path is the documented escape
hatch in HostingExtensions.AddIdentityServer.
- UsesInMemoryProvider: StartsWith instead of equality, so
'InMemory-{guid}' is still recognised as an in-memory connection
string.
Regression sentinel in
Controllers/TestWebApplicationFactoryIsolationTests: two factories
seed a marker client in the first, the second must not see it.
Suite: 45/45 over 3 stable runs, 13-15s each.
|
||
|---|---|---|
| .. | ||
| Directory.Packages.props | ||
| InMemoryDatabaseName.cs | ||
| README.md | ||
| TestAuthPolicyProvider.cs | ||
| TestTokenIssuer.cs | ||
| WebHostFixture.cs | ||
| Yavsc.Tests.Shared.csproj | ||
Yavsc.Tests.Shared
Scaffold partagé pour les tests d'intégration ASP.NET Core de
Yavsc. Ce projet n'est pas lui-même un projet de tests — il
n'a pas xUnit ni de test runner. Il expose des fixtures
réutilisables que les projets de tests consommateurs
(Yavsc.Org.Tests, Yavsc.Blogs.Tests, etc.) héritent ou
instancient.
Contenu
| Fichier | Rôle |
|---|---|
WebHostFixture.cs |
Base abstraite : Kestrel HTTPS, certificat auto-signé, port dynamique, host partagé inter-fixtures |
TestAuthPolicyProvider.cs |
IAuthorizationPolicyProvider de test, lit X-Test-Role au lieu d'interroger la DB |
TestTokenIssuer.cs |
Émet des JWT HS256 signés avec une clé statique, pour les tests d'API qui montent un AddJwtBearer réel |
WebHostFixture
WebHostFixture est la base de toute fixture d'intégration.
Une seule instance de WebApplication tourne par process ; les
fixtures qui héritent partagent le host. Kestrel est bindé sur
127.0.0.1:0 (port dynamique) avec un certificat auto-signé
généré lazily.
Cycle de vie
- Premier ctor d'une fixture concrète →
InitializeAsync()lanceBuildApp(builder)puisConfigurePipelineAsync(app)puisapp.StartAsync(). L'IServerAddressesFeatureest lu pour peuplerAddresses. - Ctors suivants (xUnit instancie une fixture par
IClassFixture<T>) → reprise de l'état partagé viaCopySpecialisedSharedState()(vide par défaut, surchargeable). - Dernier
Dispose→app.StopAsync(), reset des slots statiques.
Hooks à surcharger
| Hook | Quand | Quoi y mettre |
|---|---|---|
BuildApp(builder) |
Toujours | Enregistrement des services, configuration in-memory, seeding éventuel |
ConfigurePipelineAsync(app) |
Optionnel | Pipeline middleware spécifique (sinon : pas de pipeline custom) |
CopySpecialisedSharedState() |
Optionnel | Recopie des slots statiques de la spécialisation sur les propriétés d'instance |
Exemple : fixture de portée minimale
public sealed class MyFixture : WebHostFixture
{
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// In-memory config, services, etc.
return builder.Build();
}
}
MyFixture n'a pas de test runner propre ; c'est l'assembly
consommateur (par exemple Yavsc.MyModule.Tests) qui déclare
les [Fact] et utilise IClassFixture<MyFixture>.
Spécifications : fixtures concrètes
Deux fixtures héritent de WebHostFixture dans le repo :
Yavsc.Org.Tests.WebServerFixture
Pour le host principal de Yavsc.Org. Caractéristiques :
- Configure
InMemorypour laConnectionStringsYavsc - Remplace
IAuthorizationPolicyProviderparTestAuthPolicyProvideravantConfigureWebAppServices(qui freeze la collection de services) - Stub
ISmtpClientFactoryparRecordingSmtpClientFactorypour capturer les envois sans SMTP réel - Seed IdentityServer8 : un
Client+ uneApiScope"test" + unApplicationUser"Tester" - Configure le pipeline via
app.ConfigurePipeline(...)avec un manifeste de static assets explicite (le MSBuild targetCopyYavscOrgStaticAssetsdu csproj miroir les manifests Yavsc.Org sous le nom Yavsc.Org.Tests.* dans le bin de test)
Cf. src/Yavsc.Org.Tests/WebServerFixture.cs.
Yavsc.Blogs.Tests.BlogsWebServerFixture
Pour le host API de Yavsc.Blogs. Caractéristiques :
UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)— unInMemoryDatabaseRootpartagé pour que POST + GET voient le même storeBlogSpotServiceréel (pas de mock)PermissionHandlerréel (le handler d'authorization qui résoutIsOwner(user, blog))AddJwtBearerréel avec HS256, validation contreTestTokenIssuer.SigningKey— pas d'OIDC discovery, pas d'IdP- Politique
BlogScopeverbatim (RequireAuthenticatedUser+RequireClaim("scope", "blogs"))
Cf. src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs.
TestAuthPolicyProvider
IAuthorizationPolicyProvider de test qui lit le rôle dans
l'en-tête HTTP X-Test-Role au lieu d'interroger la
UserManager. Permet aux smoke tests d'exercer [Authorize ("AdministratorOnly")] sans seed de rôle réel.
Activation : enregistré par les fixtures spécialisées avant
ConfigureWebAppServices (qui call builder.Build() et
fige la collection). La sémantique last-write-wins du
AddSingleton fait que le test provider prend le pas.
TestTokenIssuer
Émet un JWT HS256 avec une SigningKey statique, exposé en
TestTokenIssuer.SigningKey (et Issuer). Les fixtures qui
montent un AddJwtBearer réutilisent cette clé pour valider
les tokens localement, sans OIDC discovery.
Helpers :
TestTokenIssuer.Issue(subject, scope, lifetime)→ chaîne"Bearer <jwt>"prête pour un header HTTPTestTokenIssuer.SigningKey—SymmetricSecurityKeyà passer auTokenValidationParametersduAddJwtBearer
Tests statiques sur du code compilé
Pour tester un display template Razor sans monter un host
ASP.NET, on peut s'appuyer sur la lecture du fichier source
et asserter des invariants syntaxiques. Cf.
Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests
pour un exemple : on asserte que le cshtml ne porte plus
Model.UserName directement, ce qui aurait rouvert la
non-régression du 500 sur /BlogSpot/Details/{id}.
C'est pragmatique : la mise en place d'un RazorProjectEngine
pour compiler et rendre une vue hors host coûte plus cher que
ce qu'elle protège pour un seul template.
Pour aller plus loin
doc/testing.mdà la racine : vue d'ensemble de la stratégie de testsrc/Yavsc.Org.Tests/NonRegression/etsrc/Yavsc.Blogs.Tests/: exemples d'utilisation