Blog: add test guarding the display-template fix + document test architecture
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.
- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
concatène plus directement Model.UserName (ancien code fautif)
et qu'il utilise bien le helper. Si quelqu'un revert la ligne
4 du cshtml, les tests cassent. Les autres usages de
Model.UserName (alt, title, asp-route-id) sont autorisés : ils
ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
(conventions NonRegression/Mandatory/Smoke/Controllers, EF
in-memory via InMemoryDatabaseRoot partagé, auth stubs,
quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
(WebHostFixture + son cycle de vie et ses hooks,
TestAuthPolicyProvider, TestTokenIssuer) et des deux
spécialisations dans le repo
(Yavsc.Org.Tests.WebServerFixture et
Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
This commit is contained in:
parent
bbdcc7f2ad
commit
cb7526de9d
4 changed files with 307 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
|
||||||
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
|
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
|
||||||
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
|
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
|
||||||
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
|
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
|
||||||
|
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
|
||||||
|
|
||||||
## Roadmap & design exploration
|
## Roadmap & design exploration
|
||||||
|
|
||||||
|
|
|
||||||
88
doc/testing.md
Normal file
88
doc/testing.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Stratégie de test
|
||||||
|
|
||||||
|
Yavsc utilise **xUnit** (`xunit.v3`) avec un mix d'unitaire pur
|
||||||
|
et d'intégration légère. Les projets de tests sont sous
|
||||||
|
`src/<projet>.Tests/` et consomment le scaffold partagé
|
||||||
|
`src/Yavsc.Tests.Shared/`.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
| Sujet | Document |
|
||||||
|
|---|---|
|
||||||
|
| Scaffold partagé (`WebHostFixture`, JWT de test, etc.) | [src/Yavsc.Tests.Shared/README.md](../src/Yavsc.Tests.Shared/README.md) |
|
||||||
|
| Convention des dossiers de tests | [Conventions](#conventions-des-dossiers-de-tests) |
|
||||||
|
| Driver EF Core en test | [EF Core en test](#ef-core-en-test) |
|
||||||
|
| Stubs d'authentification et de permissions | [Auth et permissions](#auth-et-permissions) |
|
||||||
|
|
||||||
|
## Conventions des dossiers de tests
|
||||||
|
|
||||||
|
Sous `src/<projet>.Tests/`, on trouve quatre dossiers de premier
|
||||||
|
niveau qui classifient les tests par intention :
|
||||||
|
|
||||||
|
| Dossier | Usage |
|
||||||
|
|---|---|
|
||||||
|
| `NonRegression/` | Régressions : un bug constaté, un test qui le détecte si on le réintroduit |
|
||||||
|
| `Mandatory/` | Tests bloquants : ils doivent passer avant tout merge |
|
||||||
|
| `Smoke/` | Smoke tests HTTP rapides, montent un host léger |
|
||||||
|
| `Controllers/` | Tests unitaires des contrôleurs (mock du service, assertions sur le mapping HTTP) |
|
||||||
|
|
||||||
|
Les `NonRegression` sont la cible par défaut quand on fixe un
|
||||||
|
bug : ils doivent être **rouges avant le fix, verts après**, et
|
||||||
|
continuer à **casser** si quelqu'un revert le fix. Pas de test
|
||||||
|
qui passe à vide.
|
||||||
|
|
||||||
|
## EF Core en test
|
||||||
|
|
||||||
|
Pour les tests qui ont besoin d'un `ApplicationDbContext`, on
|
||||||
|
utilise **`UseInMemoryDatabase`** avec un `InMemoryDatabaseRoot`
|
||||||
|
partagé au niveau de la fixture. Pas de SQLite, pas de Docker,
|
||||||
|
pas de mock du contexte : le service testé s'exécute contre
|
||||||
|
un vrai `DbContext` sur in-memory.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static readonly InMemoryDatabaseRoot _dbRoot = new();
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase("Yavsc.Org.Tests.MyFixture", _dbRoot)
|
||||||
|
.Options;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `InMemoryDatabaseRoot` partagé est important : sans lui, EF
|
||||||
|
crée un store indépendant par `DbContext` dans certaines
|
||||||
|
configurations, et un test qui seed + read sur deux contextes
|
||||||
|
voit un store vide. Le pattern est documenté dans
|
||||||
|
`BlogsWebServerFixture` ([src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs](../src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs)).
|
||||||
|
|
||||||
|
> **Limite connue** : le provider in-memory **ignore** les
|
||||||
|
> `Migration` EF et ne respecte pas les FK **sur les raw
|
||||||
|
> SQL** (`ExecuteSqlRaw`). Pour tester des contraintes FK, on
|
||||||
|
> écrit la configuration dans `OnModelCreating` et on s'appuie
|
||||||
|
> sur le fait qu'EF la respecte à l'`Add`/`SaveChanges`. Pour
|
||||||
|
> tester des migrations, c'est l'environnement de staging.
|
||||||
|
|
||||||
|
## Auth et permissions
|
||||||
|
|
||||||
|
L'authorization policy provider de prod est swappé contre
|
||||||
|
`TestAuthPolicyProvider` (dans `Yavsc.Tests.Shared`) par les
|
||||||
|
fixtures spécialisées. Les tests qui ont besoin qu'un user soit
|
||||||
|
"Administrator" envoient un header `X-Test-Rôle` ; ceux qui
|
||||||
|
veulent un user anonyme omettent le header.
|
||||||
|
|
||||||
|
Pour les tests unitaires qui n'ont pas besoin du pipeline
|
||||||
|
HTTP, on stub `IAuthorizationService` directement (cf.
|
||||||
|
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
|
||||||
|
pour éviter de monter un host complet.
|
||||||
|
|
||||||
|
## Quand ne PAS écrire de test
|
||||||
|
|
||||||
|
Un test qui ne détecte rien n'est pas un test. Si l'invariant
|
||||||
|
qu'on cherche à protéger est déjà enforced par EF, par le
|
||||||
|
compilateur, ou par une couche applicative en amont, le test
|
||||||
|
est du bruit. Mieux vaut :
|
||||||
|
- Un test qui assert un **comportement observable** (code
|
||||||
|
retour HTTP, exception typée, valeur de retour)
|
||||||
|
- Ou pas de test, et une note dans le code
|
||||||
|
|
||||||
|
La non-régression se prouve par un test qui casse si on
|
||||||
|
réintroduit le bug. Pas par un test qui passe aujourd'hui et
|
||||||
|
qui continuera à passer après un revert.
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Yavsc.Org.Tests.NonRegression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Régression du 500 sur <c>GET /BlogSpot/Details/{id}</c> (auteur
|
||||||
|
/// sans <c>UserName</c>) : le display template
|
||||||
|
/// <c>ApplicationUser.cshtml</c> ne doit plus accéder à
|
||||||
|
/// <c>Model.UserName</c> directement. Toute lecture passe par
|
||||||
|
/// <c>UserDisplayHelpers.AvatarSrc</c>, qui défend contre
|
||||||
|
/// <c>null</c> et contre les chaînes vides/whitespace.
|
||||||
|
///
|
||||||
|
/// On ne compile pas la vue Razor ici (coût de mise en place
|
||||||
|
/// disproportionné pour un seul display template) ; on asserte
|
||||||
|
/// statiquement que le cshtml ne porte plus l'accès fautif. Si
|
||||||
|
/// quelqu'un revert la ligne, ce test casse.
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationUserDisplayTemplateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ApplicationUser_cshtml_does_not_construct_avatar_path_from_Model_UserName()
|
||||||
|
{
|
||||||
|
// L'invariant qu'on protège : l'URL d'avatar ne doit plus
|
||||||
|
// être construite à partir de Model.UserName direct (le
|
||||||
|
// commit 2 du fix). Cette construction était la cause du
|
||||||
|
// 500 sur GET /BlogSpot/Details/{id} : avec
|
||||||
|
// <Nullable>enable</Nullable>, Razor émet un null-check
|
||||||
|
// implicite sur l'expression, et lève NPE si UserName est
|
||||||
|
// null. Le helper AvatarSrc défend contre ce cas.
|
||||||
|
//
|
||||||
|
// On n'interdit pas les autres usages de Model.UserName
|
||||||
|
// (alt, title, asp-route-id) : Razor les rend en chaîne
|
||||||
|
// vide si null, sans NPE. C'est laid, pas cassé.
|
||||||
|
var path = ResolveTemplatePath();
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
// L'ancien code fautif concaténait directement
|
||||||
|
// "/Avatars/" + Model.UserName + ".s.png".
|
||||||
|
Assert.DoesNotContain("Model.UserName + ", content);
|
||||||
|
Assert.DoesNotContain("Model.UserName+", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplicationUser_cshtml_uses_the_null_safe_helper_for_avatar()
|
||||||
|
{
|
||||||
|
var path = ResolveTemplatePath();
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
Assert.Contains("UserDisplayHelpers.AvatarSrc", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTemplatePath()
|
||||||
|
{
|
||||||
|
// Le test s'exécute depuis src/Yavsc.Org.Tests/bin/...,
|
||||||
|
// on remonte pour trouver la vue source.
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
for (var i = 0; i < 8 && dir is not null; i++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir,
|
||||||
|
"src", "Yavsc.Org", "Views", "Shared",
|
||||||
|
"DisplayTemplates", "ApplicationUser.cshtml");
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
dir = Path.GetDirectoryName(dir);
|
||||||
|
}
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
"Could not locate ApplicationUser.cshtml from " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/Yavsc.Tests.Shared/README.md
Normal file
149
src/Yavsc.Tests.Shared/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# 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()`
|
||||||
|
lance `BuildApp(builder)` puis `ConfigurePipelineAsync(app)`
|
||||||
|
puis `app.StartAsync()`. L'`IServerAddressesFeature` est lu
|
||||||
|
pour peupler `Addresses`.
|
||||||
|
- **Ctors suivants** (xUnit instancie une fixture par
|
||||||
|
`IClassFixture<T>`) → reprise de l'état partagé via
|
||||||
|
`CopySpecialisedSharedState()` (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
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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 `InMemory` pour la `ConnectionStrings` Yavsc
|
||||||
|
- Remplace `IAuthorizationPolicyProvider` par
|
||||||
|
`TestAuthPolicyProvider` **avant** `ConfigureWebAppServices`
|
||||||
|
(qui freeze la collection de services)
|
||||||
|
- Stub `ISmtpClientFactory` par `RecordingSmtpClientFactory`
|
||||||
|
pour capturer les envois sans SMTP réel
|
||||||
|
- Seed IdentityServer8 : un `Client` + une `ApiScope` "test" +
|
||||||
|
un `ApplicationUser` "Tester"
|
||||||
|
- Configure le pipeline via `app.ConfigurePipeline(...)` avec
|
||||||
|
un manifeste de static assets explicite (le MSBuild target
|
||||||
|
`CopyYavscOrgStaticAssets` du 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)` —
|
||||||
|
un `InMemoryDatabaseRoot` partagé pour que POST + GET voient
|
||||||
|
le même store
|
||||||
|
- `BlogSpotService` réel (pas de mock)
|
||||||
|
- `PermissionHandler` réel (le handler d'authorization qui
|
||||||
|
résout `IsOwner(user, blog)`)
|
||||||
|
- `AddJwtBearer` réel avec HS256, validation contre
|
||||||
|
`TestTokenIssuer.SigningKey` — pas d'OIDC discovery, pas
|
||||||
|
d'IdP
|
||||||
|
- Politique `BlogScope` verbatim (`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 HTTP
|
||||||
|
- `TestTokenIssuer.SigningKey` — `SymmetricSecurityKey` à
|
||||||
|
passer au `TokenValidationParameters` du `AddJwtBearer`
|
||||||
|
|
||||||
|
## 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 test
|
||||||
|
- `src/Yavsc.Org.Tests/NonRegression/` et
|
||||||
|
`src/Yavsc.Blogs.Tests/` : exemples d'utilisation
|
||||||
Loading…
Add table
Add a link
Reference in a new issue