From 9dba56146acd3e3a7638bbee5cf3ffa1b9ca598b Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:34:21 +0200 Subject: [PATCH 01/13] docs(testing): focus testing guide on assertions --- doc/Testing.en.md | 280 +++++++++++++--------------------------------- 1 file changed, 79 insertions(+), 201 deletions(-) diff --git a/doc/Testing.en.md b/doc/Testing.en.md index 3ce29ba..c5d9581 100644 --- a/doc/Testing.en.md +++ b/doc/Testing.en.md @@ -1,26 +1,15 @@ -# Testing Guide +# Testing Outcomes and Errors 🌍 **Languages:** 🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./Testing.fr.md) -Errors and outcomes are values, so your tests should read like tests about -values — not like plumbing. The companion package **`FirstClassErrors.Testing`** -gives you three small things that make that easy: +Errors and outcomes are values. Their tests should therefore describe success, failure, and error semantics directly—not the plumbing used to inspect them. -* **Fluent assertions** on `Outcome`, `Outcome` and `Error`. -* **A freezable clock**, so `OccurredAt` is deterministic. -* **Freezable instance ids**, so `InstanceId` is deterministic. +The companion package **`FirstClassErrors.Testing`** provides framework-agnostic fluent assertions for `Outcome`, `Outcome`, and `Error`. -Two promises the package keeps: - -* **It imposes no test or assertion framework.** The assertions throw a plain - exception that xUnit, NUnit, MSTest — anything — reports as a failure. -* **It adds nothing to your production dependencies.** Everything lives in a - separate test-only package, and the overrides only ever affect code running - inside their `using` scope. +## Install the testing package ```xml - ``` @@ -29,100 +18,90 @@ using FirstClassErrors; using FirstClassErrors.Testing; ``` -> The examples below use xUnit for the scaffolding (`[Fact]`, `Assert`), but the -> FirstClassErrors assertions work the same in any framework. +The examples use xUnit for test scaffolding, but the FirstClassErrors assertions do not depend on xUnit, NUnit, MSTest, or another assertion library. ---- +The package belongs only in test projects and adds nothing to production dependencies. -## ✅ Asserting on outcomes +## Assert a successful `Outcome` -This is the part you'll use every day. Testing code that returns an `Outcome` -usually means unwrapping it by hand and reaching for the null-forgiving -`!` operator: +Without the testing package, a test usually checks the state and unwraps the result separately: ```csharp -// 😐 Before Outcome outcome = checkout.Pay(order); -Assert.True(outcome.IsFailure); -Assert.Equal("PAYMENT_DECLINED", outcome.Error!.Code.ToString()); -``` - -With the testing package, the intent comes first and the boilerplate disappears: - -```csharp -// 🙂 After -checkout.Pay(order) - .ShouldFail() - .WithCode("PAYMENT_DECLINED"); +Assert.True(outcome.IsSuccess); +Receipt receipt = outcome.GetResultOrThrow(); +Assert.Equal(order.Total, receipt.AmountCharged); ``` -### Successes return their value - -`ShouldSucceed()` asserts the outcome succeeded and hands you the carried value, -ready to assert on: +`ShouldSucceed()` performs the state assertion and returns the carried value: ```csharp [Fact] public void Paying_a_valid_order_produces_a_receipt() { - Outcome outcome = checkout.Pay(order); - - Receipt receipt = outcome.ShouldSucceed(); + Receipt receipt = checkout.Pay(order).ShouldSucceed(); Assert.Equal(order.Total, receipt.AmountCharged); } ``` -For the non-generic `Outcome`, `ShouldSucceed()` simply asserts success: +For non-generic `Outcome`, it simply asserts success: ```csharp inventory.Reserve(sku).ShouldSucceed(); ``` -### Failures return a fluent handle +## Assert a failure -`ShouldFail()` asserts the outcome failed and returns an `ErrorAssertion` you can -chain expectations on. Each step checks one facet of the error: +`ShouldFail()` asserts failure and returns an `ErrorAssertion`: ```csharp [Fact] public void Declining_a_payment_reports_a_diagnosable_error() { - Outcome outcome = checkout.Pay(declinedCard); - - outcome.ShouldFail() - .WithCode("PAYMENT_DECLINED") - .WithShortMessage("Your payment was declined.") - .WithDiagnosticMessage("Issuer refused authorization (code 51).") - .WithContextEntry("CardNetwork", "VISA"); + checkout.Pay(declinedCard) + .ShouldFail() + .WithCode("PAYMENT_DECLINED") + .WithShortMessage("Your payment was declined.") + .WithDiagnosticMessage("Issuer refused authorization (code 51).") + .WithContextEntry("CardNetwork", "VISA"); } ``` -Available checks on `ErrorAssertion`: +This keeps the expected contract visible in one place: stable code, public message, internal diagnostic, and occurrence context. + +## Available error checks | Method | Asserts | | --- | --- | -| `WithCode("...")` / `WithCode(errorCode)` | the error's `Code` | -| `WithShortMessage("...")` | the public `ShortMessage` | -| `WithDiagnosticMessage("...")` | the internal `DiagnosticMessage` | -| `WithContextEntry("key")` | a context entry is present | -| `WithContextEntry("key", value)` | a context entry is present **and** equals `value` | +| `WithCode("...")` | the error code as text | +| `WithCode(errorCode)` | the strongly typed `ErrorCode` | +| `WithShortMessage("...")` | the public short message | +| `WithDiagnosticMessage("...")` | the internal diagnostic message | +| `WithContextEntry("key")` | that a context entry exists | +| `WithContextEntry("key", value)` | that the entry exists and equals the expected value | + +Use only the assertions that express the behavior owned by the test. Avoid asserting every field mechanically when only the code and one context value matter. + +## Access the underlying error -Need something the fluent surface doesn't cover? `Subject` gives you the raw -`Error` back: +`Subject` returns the asserted `Error` when the fluent surface does not cover a property: ```csharp -Error error = outcome.ShouldFail().WithCode("ORDER_NOT_FOUND").Subject; +Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .Subject; Assert.Empty(error.InnerErrors); +Assert.Equal(Transience.NonTransient, ((InfrastructureError)error).Transience); ``` -### Failure messages read well +Use `Subject` for targeted assertions, not to immediately rebuild all the manual plumbing that the fluent API removed. -When an expectation is not met, the assertions throw `OutcomeAssertionException` -with a message that tells you what actually happened — not the domain's own -exception: +## Failure messages + +When an expectation fails, the package throws `OutcomeAssertionException` with a message describing the mismatch: ```text Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: Issuer refused authorization (code 51). @@ -132,165 +111,64 @@ Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: Issuer r Expected the error to have code "ORDER_NOT_FOUND", but it was "ORDER_LOCKED". ``` ---- +The assertion failure is distinct from the domain exception. A failed test therefore reports what the test expected and what the outcome actually contained. -## 🕒 Freezing the clock +## What should a test assert? -Every `Error` records the moment it occurred in `OccurredAt`. In a test, the real -clock forces you into a time window: +Prefer assertions on the stable behavior of the error: -```csharp -// 😐 Before -DateTimeOffset before = DateTimeOffset.UtcNow; -DomainError error = MakeError(); -DateTimeOffset after = DateTimeOffset.UtcNow; +- the error code; +- the error category when relevant; +- public wording only when it is part of the intended contract; +- diagnostic wording when the exact diagnostic is deliberately specified; +- context keys and values used by consumers or operations; +- inner errors when composition is itself the behavior under test. -Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); -``` +Avoid coupling every test to incidental prose or timestamps unless those values are the subject of the test. -`Clock.UseFixed(...)` pins the time to an exact instant for the duration of a -`using` scope, so you can assert equality: +## Complete example ```csharp -// 🙂 After [Fact] -public void An_error_records_when_it_occurred() +public void Looking_up_a_missing_order_returns_the_expected_error() { - var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + Outcome outcome = orders.Find(missingOrderId); - using (Clock.UseFixed(instant)) - { - DomainError error = DomainError - .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "Order 42 was not found.") - .WithPublicMessage("This order does not exist."); + Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithShortMessage("The order does not exist.") + .WithContextEntry("OrderId", missingOrderId) + .Subject; - Assert.Equal(instant, error.OccurredAt); - } + Assert.IsType(error); + Assert.Empty(error.InnerErrors); } ``` -Need a clock you control across several reads (for example a clock that -advances)? Implement `IClock` and pass it to `Clock.Use(...)`: +The test reads as a description of the failure contract rather than a sequence of nullable checks and casts. -```csharp -sealed class StepClock : IClock -{ - private DateTimeOffset _now; - public StepClock(DateTimeOffset start) => _now = start; - - public DateTimeOffset UtcNow - { - get - { - DateTimeOffset current = _now; - _now = _now.AddSeconds(1); // each read advances by one second - return current; - } - } -} - -using (Clock.Use(new StepClock(start))) -{ - // errors created here get start, start + 1s, start + 2s, ... -} -``` +## Deterministic timestamps and instance ids -**How the scope behaves** +Every error occurrence contains an `OccurredAt` timestamp and a unique `InstanceId`. When a test or snapshot must assert those values, use the scoped overrides provided by the testing package. -* Outside a scope — i.e. in production — the clock is always the real system - clock. This type only affects code running inside a `using` block. -* Disposing the scope restores the previous clock. Always use `using`. -* The override flows with the current execution context, so it never leaks into - tests running in parallel. +See [Deterministic Error Tests](DeterministicTesting.en.md) for `Clock.UseFixed(...)`, `InstanceIds.UseFixed(...)`, custom sources, parallel-test behavior, and full-error snapshots. ---- +## Review checklist -## 🔱 Freezing instance ids +Before approving an error test, verify that: -Each error occurrence gets a unique `InstanceId` (a random `Guid`). That is -exactly what you want in production and exactly what breaks a snapshot or an -equality assertion over a whole error. `InstanceIds` lets you pin it. - -Pin a single id: - -```csharp -[Fact] -public void A_not_found_error_snapshots_cleanly() -{ - var id = new Guid("11111111-1111-1111-1111-111111111111"); - - using (InstanceIds.UseFixed(id)) - { - DomainError error = orders.Find(missingId).ShouldFail().Subject as DomainError; - - Assert.Equal(id, error!.InstanceId); - } -} -``` - -Or provide your own source — a counter, for instance, when a test creates -several errors and you want stable, distinct ids: - -```csharp -static Func Sequential() -{ - int n = 0; - return () => new Guid(++n, 0, 0, new byte[8]); // 00000001-..., 00000002-... -} - -using (InstanceIds.Use(Sequential())) -{ - // first error -> 00000001-0000-0000-0000-000000000000 - // second error -> 00000002-0000-0000-0000-000000000000 -} -``` - -`InstanceIds` follows the same scope rules as `Clock`: disposable, context-local, -and inert outside a `using` block. - ---- - -## đŸ§Ș Putting it together - -Fixing the clock and the id turns a whole error into a completely deterministic -value — ideal for a single, readable assertion or a snapshot: - -```csharp -[Fact] -public void Looking_up_a_missing_order_fails_deterministically() -{ - var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); - var id = new Guid("11111111-1111-1111-1111-111111111111"); - - using (Clock.UseFixed(instant)) - using (InstanceIds.UseFixed(id)) - { - Outcome outcome = orders.Find(missingId); - - ErrorAssertion failure = outcome.ShouldFail() - .WithCode("ORDER_NOT_FOUND") - .WithContextEntry("OrderId", missingId); - - Assert.Equal(instant, failure.Subject.OccurredAt); - Assert.Equal(id, failure.Subject.InstanceId); - } -} -``` - ---- - -## 📎 Good to know - -* Everything is **scoped and disposable** — always reach for `using`. -* Overrides are **context-local**: they apply to the current async flow only, so - parallel tests don't interfere with each other. -* Nothing here changes production behavior, and the package pulls **no test or - assertion framework** into your project. +- it asserts behavior rather than implementation plumbing; +- success values are obtained through `ShouldSucceed()`; +- failures are asserted through `ShouldFail()`; +- the error code is checked when it identifies the scenario; +- exact prose is asserted only when intentionally contractual; +- `Subject` is used only for properties outside the fluent surface; +- time and identifiers are frozen only when the test needs them. --- ---- +--- \ No newline at end of file From c8ea95c62abaae55327dffd2eaf33a3d78ac9dee Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:35:09 +0200 Subject: [PATCH 02/13] docs(testing): focus French testing guide on assertions --- doc/Testing.fr.md | 288 +++++++++++++--------------------------------- 1 file changed, 80 insertions(+), 208 deletions(-) diff --git a/doc/Testing.fr.md b/doc/Testing.fr.md index 505b606..3be89bd 100644 --- a/doc/Testing.fr.md +++ b/doc/Testing.fr.md @@ -1,28 +1,15 @@ -# Guide des tests +# Tester les outcomes et les erreurs 🌍 **Langues :** đŸ‡«đŸ‡· Français (ce fichier) | 🇬🇧 [English](./Testing.en.md) -Les erreurs et les outcomes sont des valeurs : vos tests devraient donc se lire -comme des tests sur des valeurs — pas comme de la tuyauterie. Le package -compagnon **`FirstClassErrors.Testing`** apporte trois petites choses qui rendent -cela facile : +Les erreurs et les outcomes sont des valeurs. Leurs tests doivent donc dĂ©crire directement le succĂšs, l’échec et la sĂ©mantique de l’erreur — pas la tuyauterie nĂ©cessaire pour les inspecter. -* des **assertions fluentes** sur `Outcome`, `Outcome` et `Error` ; -* une **horloge figeable**, pour que `OccurredAt` soit dĂ©terministe ; -* des **identifiants d'instance figeables**, pour que `InstanceId` soit dĂ©terministe. +Le package compagnon **`FirstClassErrors.Testing`** fournit des assertions fluentes indĂ©pendantes du framework sur `Outcome`, `Outcome` et `Error`. -Deux promesses tenues par le package : - -* **Il n'impose aucun framework de test ni d'assertion.** Les assertions lĂšvent - une simple exception que xUnit, NUnit, MSTest — n'importe lequel — rapporte - comme un Ă©chec. -* **Il n'ajoute rien Ă  vos dĂ©pendances de production.** Tout vit dans un package - sĂ©parĂ© rĂ©servĂ© aux tests, et les overrides n'affectent jamais que le code qui - s'exĂ©cute Ă  l'intĂ©rieur de leur portĂ©e `using`. +## Installer le package de test ```xml - ``` @@ -31,272 +18,157 @@ using FirstClassErrors; using FirstClassErrors.Testing; ``` -> Les exemples ci-dessous utilisent xUnit pour l'Ă©chafaudage (`[Fact]`, -> `Assert`), mais les assertions de FirstClassErrors fonctionnent de la mĂȘme -> maniĂšre dans n'importe quel framework. +Les exemples utilisent xUnit pour la structure des tests, mais les assertions FirstClassErrors ne dĂ©pendent ni de xUnit, ni de NUnit, ni de MSTest, ni d’une autre bibliothĂšque d’assertions. ---- +Le package doit rester rĂ©servĂ© aux projets de test et n’ajoute aucune dĂ©pendance en production. -## ✅ Asserter sur les outcomes +## Asserter un `Outcome` rĂ©ussi -C'est la partie que vous utiliserez tous les jours. Tester du code qui renvoie un -`Outcome` revient d'ordinaire Ă  le dĂ©baller Ă  la main et Ă  sortir l'opĂ©rateur -`!` qui Ă©crase le nullable : +Sans le package de test, un test vĂ©rifie gĂ©nĂ©ralement l’état puis rĂ©cupĂšre sĂ©parĂ©ment le rĂ©sultat : ```csharp -// 😐 Avant Outcome outcome = checkout.Pay(order); -Assert.True(outcome.IsFailure); -Assert.Equal("PAYMENT_DECLINED", outcome.Error!.Code.ToString()); -``` - -Avec le package de test, l'intention passe en premier et le boilerplate disparaĂźt : - -```csharp -// 🙂 AprĂšs -checkout.Pay(order) - .ShouldFail() - .WithCode("PAYMENT_DECLINED"); +Assert.True(outcome.IsSuccess); +Receipt receipt = outcome.GetResultOrThrow(); +Assert.Equal(order.Total, receipt.AmountCharged); ``` -### Les succĂšs renvoient leur valeur - -`ShouldSucceed()` vĂ©rifie que l'outcome a rĂ©ussi et vous rend la valeur portĂ©e, -prĂȘte Ă  ĂȘtre asssertĂ©e : +`ShouldSucceed()` effectue l’assertion d’état et retourne la valeur portĂ©e : ```csharp [Fact] public void Payer_une_commande_valide_produit_un_recu() { - Outcome outcome = checkout.Pay(order); - - Receipt receipt = outcome.ShouldSucceed(); + Receipt receipt = checkout.Pay(order).ShouldSucceed(); Assert.Equal(order.Total, receipt.AmountCharged); } ``` -Pour l'`Outcome` non gĂ©nĂ©rique, `ShouldSucceed()` vĂ©rifie simplement le succĂšs : +Pour l’`Outcome` non gĂ©nĂ©rique, la mĂ©thode vĂ©rifie simplement le succĂšs : ```csharp inventory.Reserve(sku).ShouldSucceed(); ``` -### Les Ă©checs renvoient un handle fluent +## Asserter un Ă©chec -`ShouldFail()` vĂ©rifie que l'outcome a Ă©chouĂ© et renvoie une `ErrorAssertion` sur -laquelle enchaĂźner des attentes. Chaque Ă©tape vĂ©rifie une facette de l'erreur : +`ShouldFail()` vĂ©rifie l’échec et retourne une `ErrorAssertion` : ```csharp [Fact] public void Un_paiement_refuse_remonte_une_erreur_diagnosable() { - Outcome outcome = checkout.Pay(declinedCard); - - outcome.ShouldFail() - .WithCode("PAYMENT_DECLINED") - .WithShortMessage("Votre paiement a Ă©tĂ© refusĂ©.") - .WithDiagnosticMessage("L'Ă©metteur a refusĂ© l'autorisation (code 51).") - .WithContextEntry("CardNetwork", "VISA"); + checkout.Pay(declinedCard) + .ShouldFail() + .WithCode("PAYMENT_DECLINED") + .WithShortMessage("Votre paiement a Ă©tĂ© refusĂ©.") + .WithDiagnosticMessage("L’émetteur a refusĂ© l’autorisation (code 51).") + .WithContextEntry("CardNetwork", "VISA"); } ``` -VĂ©rifications disponibles sur `ErrorAssertion` : +Le contrat attendu reste visible au mĂȘme endroit : code stable, message public, diagnostic interne et contexte de l’occurrence. + +## VĂ©rifications disponibles | MĂ©thode | VĂ©rifie | | --- | --- | -| `WithCode("...")` / `WithCode(errorCode)` | le `Code` de l'erreur | -| `WithShortMessage("...")` | le `ShortMessage` public | -| `WithDiagnosticMessage("...")` | le `DiagnosticMessage` interne | -| `WithContextEntry("clĂ©")` | la prĂ©sence d'une entrĂ©e de contexte | -| `WithContextEntry("clĂ©", valeur)` | la prĂ©sence d'une entrĂ©e **et** son Ă©galitĂ© Ă  `valeur` | +| `WithCode("...")` | le code d’erreur sous forme de texte | +| `WithCode(errorCode)` | l’`ErrorCode` typĂ© | +| `WithShortMessage("...")` | le message public court | +| `WithDiagnosticMessage("...")` | le message de diagnostic interne | +| `WithContextEntry("clĂ©")` | la prĂ©sence d’une entrĂ©e de contexte | +| `WithContextEntry("clĂ©", valeur)` | la prĂ©sence de l’entrĂ©e et l’égalitĂ© Ă  la valeur attendue | + +N’utilisez que les assertions qui expriment le comportement couvert par le test. Évitez de vĂ©rifier mĂ©caniquement tous les champs lorsque seuls le code et une valeur de contexte importent. + +## AccĂ©der Ă  l’erreur sous-jacente -Besoin de quelque chose que la surface fluente ne couvre pas ? `Subject` vous -rend l'`Error` brute : +`Subject` retourne l’`Error` assertĂ©e lorsque la surface fluente ne couvre pas une propriĂ©tĂ© : ```csharp -Error error = outcome.ShouldFail().WithCode("ORDER_NOT_FOUND").Subject; +Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .Subject; Assert.Empty(error.InnerErrors); +Assert.Equal(Transience.NonTransient, ((InfrastructureError)error).Transience); ``` -### Les messages d'Ă©chec se lisent bien +Utilisez `Subject` pour des assertions ciblĂ©es, pas pour reconstruire immĂ©diatement toute la tuyauterie manuelle que l’API fluente vient de supprimer. -Quand une attente n'est pas satisfaite, les assertions lĂšvent une -`OutcomeAssertionException` dont le message dit ce qui s'est rĂ©ellement passĂ© — -et non l'exception propre au domaine : +## Messages d’échec + +Lorsqu’une attente Ă©choue, le package lĂšve `OutcomeAssertionException` avec un message dĂ©crivant l’écart : ```text -Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: L'Ă©metteur a refusĂ© l'autorisation (code 51). +Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: L’émetteur a refusĂ© l’autorisation (code 51). ``` ```text Expected the error to have code "ORDER_NOT_FOUND", but it was "ORDER_LOCKED". ``` ---- +L’échec d’assertion reste distinct de l’exception mĂ©tier. Le test indique donc ce qu’il attendait et ce que l’outcome contenait rĂ©ellement. -## 🕒 Figer l'horloge +## Que doit vĂ©rifier un test ? -Chaque `Error` enregistre l'instant oĂč elle est survenue dans `OccurredAt`. Dans -un test, l'horloge rĂ©elle vous force Ă  raisonner par fenĂȘtre de temps : +PrivilĂ©giez les assertions sur le comportement stable de l’erreur : -```csharp -// 😐 Avant -DateTimeOffset before = DateTimeOffset.UtcNow; -DomainError error = MakeError(); -DateTimeOffset after = DateTimeOffset.UtcNow; +- le code d’erreur ; +- la catĂ©gorie d’erreur lorsqu’elle est pertinente ; +- le texte public uniquement lorsqu’il fait partie du contrat voulu ; +- le diagnostic lorsque sa formulation exacte est volontairement spĂ©cifiĂ©e ; +- les clĂ©s et valeurs de contexte exploitĂ©es par des consommateurs ou par l’exploitation ; +- les erreurs internes lorsque la composition est elle-mĂȘme le comportement testĂ©. -Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); -``` +Évitez de coupler chaque test Ă  du texte secondaire, Ă  un horodatage ou Ă  un identifiant lorsque ces valeurs ne sont pas le sujet du test. -`Clock.UseFixed(...)` Ă©pingle le temps Ă  un instant exact pendant la durĂ©e d'une -portĂ©e `using`, ce qui permet d'asserter une Ă©galitĂ© : +## Exemple complet ```csharp -// 🙂 AprĂšs [Fact] -public void Une_erreur_enregistre_quand_elle_survient() +public void Chercher_une_commande_absente_retourne_l_erreur_attendue() { - var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + Outcome outcome = orders.Find(missingOrderId); - using (Clock.UseFixed(instant)) - { - DomainError error = DomainError - .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "La commande 42 est introuvable.") - .WithPublicMessage("Cette commande n'existe pas."); + Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithShortMessage("La commande n’existe pas.") + .WithContextEntry("OrderId", missingOrderId) + .Subject; - Assert.Equal(instant, error.OccurredAt); - } + Assert.IsType(error); + Assert.Empty(error.InnerErrors); } ``` -Besoin d'une horloge que vous contrĂŽlez sur plusieurs lectures (par exemple une -horloge qui avance) ? ImplĂ©mentez `IClock` et passez-la Ă  `Clock.Use(...)` : +Le test se lit comme une description du contrat d’échec plutĂŽt que comme une suite de vĂ©rifications nullable et de casts. -```csharp -sealed class StepClock : IClock -{ - private DateTimeOffset _now; - public StepClock(DateTimeOffset start) => _now = start; - - public DateTimeOffset UtcNow - { - get - { - DateTimeOffset current = _now; - _now = _now.AddSeconds(1); // chaque lecture avance d'une seconde - return current; - } - } -} - -using (Clock.Use(new StepClock(start))) -{ - // les erreurs créées ici obtiennent start, start + 1s, start + 2s, ... -} -``` +## Horodatages et identifiants dĂ©terministes -**Comportement de la portĂ©e** +Chaque occurrence d’erreur contient un horodatage `OccurredAt` et un `InstanceId` unique. Lorsqu’un test ou un snapshot doit vĂ©rifier ces valeurs, utilisez les overrides bornĂ©s fournis par le package de test. -* En dehors d'une portĂ©e — c'est-Ă -dire en production — l'horloge est toujours - l'horloge systĂšme rĂ©elle. Ce type n'affecte que le code exĂ©cutĂ© dans un bloc - `using`. -* Disposer la portĂ©e restaure l'horloge prĂ©cĂ©dente. Utilisez toujours `using`. -* L'override suit le contexte d'exĂ©cution courant : il ne fuit donc jamais dans - des tests exĂ©cutĂ©s en parallĂšle. +Voir [Tests d’erreur dĂ©terministes](DeterministicTesting.fr.md) pour `Clock.UseFixed(...)`, `InstanceIds.UseFixed(...)`, les sources personnalisĂ©es, le comportement en tests parallĂšles et les snapshots d’erreurs complĂštes. ---- +## Checklist de revue -## 🔱 Figer les identifiants d'instance +Avant d’approuver un test d’erreur, vĂ©rifiez que : -Chaque occurrence d'erreur reçoit un `InstanceId` unique (un `Guid` alĂ©atoire). -C'est exactement ce que l'on veut en production, et exactement ce qui casse un -snapshot ou une assertion d'Ă©galitĂ© sur une erreur entiĂšre. `InstanceIds` -permet de l'Ă©pingler. - -Épingler un identifiant unique : - -```csharp -[Fact] -public void Une_erreur_not_found_se_snapshote_proprement() -{ - var id = new Guid("11111111-1111-1111-1111-111111111111"); - - using (InstanceIds.UseFixed(id)) - { - DomainError error = orders.Find(missingId).ShouldFail().Subject as DomainError; - - Assert.Equal(id, error!.InstanceId); - } -} -``` - -Ou fournissez votre propre source — un compteur, par exemple, quand un test crĂ©e -plusieurs erreurs et que vous voulez des identifiants stables et distincts : - -```csharp -static Func Sequential() -{ - int n = 0; - return () => new Guid(++n, 0, 0, new byte[8]); // 00000001-..., 00000002-... -} - -using (InstanceIds.Use(Sequential())) -{ - // premiĂšre erreur -> 00000001-0000-0000-0000-000000000000 - // deuxiĂšme erreur -> 00000002-0000-0000-0000-000000000000 -} -``` - -`InstanceIds` suit les mĂȘmes rĂšgles de portĂ©e que `Clock` : jetable, local au -contexte, et inerte en dehors d'un bloc `using`. - ---- - -## đŸ§Ș Tout mettre ensemble - -Figer l'horloge et l'identifiant transforme une erreur entiĂšre en une valeur -totalement dĂ©terministe — idĂ©ale pour une assertion unique et lisible, ou pour -un snapshot : - -```csharp -[Fact] -public void Chercher_une_commande_absente_echoue_de_facon_deterministe() -{ - var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); - var id = new Guid("11111111-1111-1111-1111-111111111111"); - - using (Clock.UseFixed(instant)) - using (InstanceIds.UseFixed(id)) - { - Outcome outcome = orders.Find(missingId); - - ErrorAssertion failure = outcome.ShouldFail() - .WithCode("ORDER_NOT_FOUND") - .WithContextEntry("OrderId", missingId); - - Assert.Equal(instant, failure.Subject.OccurredAt); - Assert.Equal(id, failure.Subject.InstanceId); - } -} -``` - ---- - -## 📎 Bon Ă  savoir - -* Tout est **bornĂ© et jetable** — sortez toujours le `using`. -* Les overrides sont **locaux au contexte** : ils ne s'appliquent qu'au flux - asynchrone courant, si bien que des tests parallĂšles ne se marchent pas dessus. -* Rien ici ne change le comportement de production, et le package n'introduit - **aucun framework de test ni d'assertion** dans votre projet. +- il teste un comportement et non la tuyauterie d’implĂ©mentation ; +- les valeurs de succĂšs sont obtenues via `ShouldSucceed()` ; +- les Ă©checs sont vĂ©rifiĂ©s via `ShouldFail()` ; +- le code d’erreur est assertĂ© lorsqu’il identifie le scĂ©nario ; +- le texte exact n’est vĂ©rifiĂ© que lorsqu’il est volontairement contractuel ; +- `Subject` est rĂ©servĂ© aux propriĂ©tĂ©s hors de la surface fluente ; +- le temps et les identifiants ne sont figĂ©s que lorsque le test en a besoin. --- ---- +--- \ No newline at end of file From 9508c057252fa847519657f474f7b374022ada79 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:36:09 +0200 Subject: [PATCH 03/13] docs(testing): add deterministic error testing guide --- doc/DeterministicTesting.en.md | 204 +++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 doc/DeterministicTesting.en.md diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md new file mode 100644 index 0000000..cdad118 --- /dev/null +++ b/doc/DeterministicTesting.en.md @@ -0,0 +1,204 @@ +# Deterministic Error Tests + +🌍 **Languages:** +🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./DeterministicTesting.fr.md) + +Every error occurrence records two values that should vary in production: + +- `OccurredAt`, the UTC instant at which the error was created; +- `InstanceId`, a unique identifier for that occurrence. + +Those values improve observability, but they make whole-object assertions and snapshots unstable. `FirstClassErrors.Testing` provides scoped overrides for tests that deliberately need deterministic values. + +For fluent assertions on outcomes and errors, start with [Testing Outcomes and Errors](Testing.en.md). + +## Freeze `OccurredAt` + +Testing against the real clock usually requires an imprecise time window: + +```csharp +DateTimeOffset before = DateTimeOffset.UtcNow; +DomainError error = MakeError(); +DateTimeOffset after = DateTimeOffset.UtcNow; + +Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); +``` + +`Clock.UseFixed(...)` makes the expected instant explicit: + +```csharp +[Fact] +public void An_error_records_the_fixed_occurrence_time() +{ + var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + + using (Clock.UseFixed(instant)) + { + DomainError error = DomainError + .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "Order 42 was not found.") + .WithPublicMessage("The order does not exist."); + + Assert.Equal(instant, error.OccurredAt); + } +} +``` + +The override applies only inside the `using` scope. Disposing the scope restores the previous clock. + +## Use a custom clock + +When a test needs several controlled instants, implement `IClock` and pass it to `Clock.Use(...)`: + +```csharp +sealed class StepClock : IClock +{ + private DateTimeOffset _now; + + public StepClock(DateTimeOffset start) => _now = start; + + public DateTimeOffset UtcNow + { + get + { + DateTimeOffset current = _now; + _now = _now.AddSeconds(1); + return current; + } + } +} +``` + +```csharp +using (Clock.Use(new StepClock(start))) +{ + DomainError first = MakeError(); + DomainError second = MakeError(); + + Assert.Equal(start, first.OccurredAt); + Assert.Equal(start.AddSeconds(1), second.OccurredAt); +} +``` + +Use a custom clock only when progression itself matters. A fixed clock is simpler for most tests. + +## Freeze `InstanceId` + +A random `Guid` is correct in production but unstable in a snapshot: + +```csharp +[Fact] +public void A_missing_order_error_has_a_stable_instance_id() +{ + var id = new Guid("11111111-1111-1111-1111-111111111111"); + + using (InstanceIds.UseFixed(id)) + { + Error error = orders.Find(missingOrderId).ShouldFail().Subject; + + Assert.Equal(id, error.InstanceId); + } +} +``` + +As with the clock, the override ends when the scope is disposed. + +## Generate several stable identifiers + +When one test creates several errors, provide a deterministic source: + +```csharp +static Func SequentialIds() +{ + int value = 0; + return () => new Guid(++value, 0, 0, new byte[8]); +} +``` + +```csharp +using (InstanceIds.Use(SequentialIds())) +{ + Error first = MakeError(); + Error second = MakeError(); + + Assert.Equal("00000001-0000-0000-0000-000000000000", first.InstanceId.ToString()); + Assert.Equal("00000002-0000-0000-0000-000000000000", second.InstanceId.ToString()); +} +``` + +Prefer identifiers that are visibly synthetic so they cannot be confused with production values. + +## Freeze both values for a snapshot + +```csharp +[Fact] +public void A_missing_order_error_is_fully_deterministic() +{ + var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + var id = new Guid("11111111-1111-1111-1111-111111111111"); + + using (Clock.UseFixed(instant)) + using (InstanceIds.UseFixed(id)) + { + Error error = orders.Find(missingOrderId) + .ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithContextEntry("OrderId", missingOrderId) + .Subject; + + Assert.Equal(instant, error.OccurredAt); + Assert.Equal(id, error.InstanceId); + } +} +``` + +This is useful when serializing or snapshotting the complete error. If the test only cares about the error code or context, do not freeze unrelated fields merely because the helpers exist. + +## Scope and parallel tests + +The overrides are: + +- disposable and intended for `using` scopes; +- local to the current execution context; +- restored when the scope ends; +- inactive outside the scope; +- designed not to leak into unrelated parallel tests. + +Keep the scope as narrow as possible around the code that creates the errors. + +## Common mistakes + +### Forgetting to dispose the override + +Always use `using`. Manual lifetime management makes tests harder to reason about. + +### Freezing values in every test + +Most tests should assert stable semantics through `ShouldSucceed()` and `ShouldFail()`. Freeze occurrence data only when occurrence data is part of the assertion. + +### Treating test overrides as production configuration + +`Clock` and `InstanceIds` overrides are test aids. Production should keep the real UTC clock and unique identifiers. + +### Using the same fixed id for several errors when identity matters + +If the test verifies distinct occurrences, use a deterministic sequence rather than one repeated identifier. + +## Review checklist + +Before approving a deterministic error test, verify that: + +- the timestamp or instance id is genuinely relevant to the test; +- every override is enclosed in `using`; +- the scope is narrow; +- a fixed value is used when progression is unnecessary; +- a deterministic sequence is used when several distinct errors are created; +- synthetic values are obvious in snapshots; +- the test does not rely on real time or random identifiers accidentally. + +--- + + + +--- \ No newline at end of file From e28c306efc6caf552d5cbeb6548339307c8a9a17 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:37:14 +0200 Subject: [PATCH 04/13] docs(testing): add French deterministic testing guide --- doc/DeterministicTesting.fr.md | 204 +++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 doc/DeterministicTesting.fr.md diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md new file mode 100644 index 0000000..51cce22 --- /dev/null +++ b/doc/DeterministicTesting.fr.md @@ -0,0 +1,204 @@ +# Tests d’erreur dĂ©terministes + +🌍 **Langues :** +đŸ‡«đŸ‡· Français (ce fichier) | 🇬🇧 [English](./DeterministicTesting.en.md) + +Chaque occurrence d’erreur enregistre deux valeurs qui doivent varier en production : + +- `OccurredAt`, l’instant UTC de crĂ©ation de l’erreur ; +- `InstanceId`, un identifiant unique de cette occurrence. + +Ces valeurs amĂ©liorent l’observabilitĂ©, mais rendent instables les assertions sur l’objet complet et les snapshots. `FirstClassErrors.Testing` fournit des overrides bornĂ©s pour les tests qui ont rĂ©ellement besoin de valeurs dĂ©terministes. + +Pour les assertions fluentes sur les outcomes et les erreurs, commencez par [Tester les outcomes et les erreurs](Testing.fr.md). + +## Figer `OccurredAt` + +Tester avec l’horloge rĂ©elle impose gĂ©nĂ©ralement une fenĂȘtre de temps imprĂ©cise : + +```csharp +DateTimeOffset before = DateTimeOffset.UtcNow; +DomainError error = MakeError(); +DateTimeOffset after = DateTimeOffset.UtcNow; + +Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); +``` + +`Clock.UseFixed(...)` rend l’instant attendu explicite : + +```csharp +[Fact] +public void Une_erreur_enregistre_l_instant_fixe() +{ + var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + + using (Clock.UseFixed(instant)) + { + DomainError error = DomainError + .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "La commande 42 est introuvable.") + .WithPublicMessage("La commande n’existe pas."); + + Assert.Equal(instant, error.OccurredAt); + } +} +``` + +L’override ne s’applique qu’à l’intĂ©rieur de la portĂ©e `using`. La fin de la portĂ©e restaure l’horloge prĂ©cĂ©dente. + +## Utiliser une horloge personnalisĂ©e + +Lorsqu’un test nĂ©cessite plusieurs instants contrĂŽlĂ©s, implĂ©mentez `IClock` et passez-la Ă  `Clock.Use(...)` : + +```csharp +sealed class StepClock : IClock +{ + private DateTimeOffset _now; + + public StepClock(DateTimeOffset start) => _now = start; + + public DateTimeOffset UtcNow + { + get + { + DateTimeOffset current = _now; + _now = _now.AddSeconds(1); + return current; + } + } +} +``` + +```csharp +using (Clock.Use(new StepClock(start))) +{ + DomainError first = MakeError(); + DomainError second = MakeError(); + + Assert.Equal(start, first.OccurredAt); + Assert.Equal(start.AddSeconds(1), second.OccurredAt); +} +``` + +N’utilisez une horloge personnalisĂ©e que lorsque la progression elle-mĂȘme compte. Une horloge fixe est plus simple dans la plupart des tests. + +## Figer `InstanceId` + +Un `Guid` alĂ©atoire est correct en production, mais instable dans un snapshot : + +```csharp +[Fact] +public void Une_erreur_de_commande_absente_a_un_identifiant_stable() +{ + var id = new Guid("11111111-1111-1111-1111-111111111111"); + + using (InstanceIds.UseFixed(id)) + { + Error error = orders.Find(missingOrderId).ShouldFail().Subject; + + Assert.Equal(id, error.InstanceId); + } +} +``` + +Comme pour l’horloge, l’override prend fin lorsque la portĂ©e est disposĂ©e. + +## GĂ©nĂ©rer plusieurs identifiants stables + +Lorsqu’un test crĂ©e plusieurs erreurs, fournissez une source dĂ©terministe : + +```csharp +static Func SequentialIds() +{ + int value = 0; + return () => new Guid(++value, 0, 0, new byte[8]); +} +``` + +```csharp +using (InstanceIds.Use(SequentialIds())) +{ + Error first = MakeError(); + Error second = MakeError(); + + Assert.Equal("00000001-0000-0000-0000-000000000000", first.InstanceId.ToString()); + Assert.Equal("00000002-0000-0000-0000-000000000000", second.InstanceId.ToString()); +} +``` + +PrĂ©fĂ©rez des identifiants visiblement synthĂ©tiques afin qu’ils ne puissent pas ĂȘtre confondus avec des valeurs de production. + +## Figer les deux valeurs pour un snapshot + +```csharp +[Fact] +public void Une_erreur_de_commande_absente_est_totalement_deterministe() +{ + var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + var id = new Guid("11111111-1111-1111-1111-111111111111"); + + using (Clock.UseFixed(instant)) + using (InstanceIds.UseFixed(id)) + { + Error error = orders.Find(missingOrderId) + .ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithContextEntry("OrderId", missingOrderId) + .Subject; + + Assert.Equal(instant, error.OccurredAt); + Assert.Equal(id, error.InstanceId); + } +} +``` + +Cette approche est utile pour sĂ©rialiser ou snapshotter l’erreur complĂšte. Si le test ne porte que sur le code ou le contexte, ne figez pas des champs sans rapport simplement parce que les helpers existent. + +## PortĂ©e et tests parallĂšles + +Les overrides sont : + +- jetables et prĂ©vus pour des portĂ©es `using` ; +- locaux au contexte d’exĂ©cution courant ; +- restaurĂ©s Ă  la fin de la portĂ©e ; +- inactifs en dehors de cette portĂ©e ; +- conçus pour ne pas fuiter vers des tests parallĂšles sans rapport. + +Gardez la portĂ©e aussi Ă©troite que possible autour du code qui crĂ©e les erreurs. + +## Erreurs frĂ©quentes + +### Oublier de disposer l’override + +Utilisez toujours `using`. Une gestion manuelle de la durĂ©e de vie rend les tests plus difficiles Ă  comprendre. + +### Figer les valeurs dans tous les tests + +La majoritĂ© des tests doit vĂ©rifier la sĂ©mantique stable via `ShouldSucceed()` et `ShouldFail()`. Ne figez les donnĂ©es d’occurrence que lorsqu’elles font partie de l’assertion. + +### Traiter les overrides comme de la configuration de production + +Les overrides de `Clock` et `InstanceIds` sont des aides de test. La production doit conserver l’horloge UTC rĂ©elle et des identifiants uniques. + +### RĂ©utiliser le mĂȘme identifiant fixe lorsque l’identitĂ© compte + +Si le test vĂ©rifie plusieurs occurrences distinctes, utilisez une sĂ©quence dĂ©terministe plutĂŽt qu’un identifiant rĂ©pĂ©tĂ©. + +## Checklist de revue + +Avant d’approuver un test dĂ©terministe, vĂ©rifiez que : + +- l’horodatage ou l’identifiant est rĂ©ellement pertinent ; +- chaque override est entourĂ© d’un `using` ; +- la portĂ©e est Ă©troite ; +- une valeur fixe est utilisĂ©e lorsque la progression est inutile ; +- une sĂ©quence dĂ©terministe est utilisĂ©e lorsque plusieurs erreurs distinctes sont créées ; +- les valeurs synthĂ©tiques sont Ă©videntes dans les snapshots ; +- le test ne dĂ©pend pas accidentellement du temps rĂ©el ou d’identifiants alĂ©atoires. + +--- + + + +--- \ No newline at end of file From 0b3d4529cb52c5b321afe9c82a003f9eea19763d Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:38:24 +0200 Subject: [PATCH 05/13] docs(gendoc): focus operations guide on catalog delivery --- doc/OperationalIntegration.en.md | 223 +++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 69 deletions(-) diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md index d76f122..d486209 100644 --- a/doc/OperationalIntegration.en.md +++ b/doc/OperationalIntegration.en.md @@ -1,130 +1,215 @@ -# CI/CD and Operational Integration +# Generating and Publishing the Error Catalog 🌍 **Languages:** 🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./OperationalIntegration.fr.md) -FirstClassErrors reaches its full value when it is integrated into the delivery pipeline and operational tooling. The goal is not only to define error knowledge, but to make it automatically available to the people who need it: developers, support teams, and operators. +FirstClassErrors becomes operationally useful when the catalog is generated from the exact code being built and published where developers, support teams, and operators can reach it. -## 📩 Documentation as a build artifact +This guide covers the delivery workflow. For structured logging and production diagnostics, see [Logging and Operational Integration](LoggingIntegration.en.md). -Error documentation should be generated automatically during CI. +## The delivery flow -The `fce` generator is distributed as a .NET tool -([`FirstClassErrors.Cli`](https://www.nuget.org/packages/FirstClassErrors.Cli)), so a -pipeline installs it and runs it as build steps: +```mermaid +flowchart LR + A[Build application] --> B[Extract error knowledge] + B --> C[Render catalog] + C --> D[Publish artifact or site] + B --> E[Compare contract baseline] +``` + +A reliable pipeline should: + +1. build the application; +2. generate the catalog from the built code; +3. publish the generated files; +4. optionally compare the current contract with a committed baseline. -1. Install the generator: `dotnet tool install --global FirstClassErrors.Cli` -2. Build the solution -3. Run the generator (`fce generate`) to produce the error catalog (Markdown, HTML or JSON) -4. Publish it as a pipeline artifact or deploy it to a documentation portal +## Opt projects into generation + +Solution-level generation is opt-in. Add the marker directly to every `.csproj` that defines documented application errors: + +```xml + + true + +``` -Concretely: +The marker is read from the project file itself. A value inherited from `Directory.Build.props` is not detected. + +When no project opts in, the generator warns instead of silently presenting an empty catalog as a valid result. + +For ambiguous declarations, project discovery, and worker behavior, see [Architecture of the Documentation Pipeline](ArchitectureOfTheDocumentationPipeline.en.md). + +## Generate the catalog locally + +Install the CLI, build, then generate from the existing binaries: ```bash dotnet tool install --global FirstClassErrors.Cli dotnet build MyApp.sln -c Release -fce generate --solution MyApp.sln --no-build \ - --output artifacts/errors.md --format markdown --service-name my-api +fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format markdown \ + --output artifacts/errors.md \ + --service-name my-api ``` -Generation is **opt-in per project**: only projects whose project file (`.csproj`) sets `true` are scanned; a project without it is silently skipped. The marker must sit in the `.csproj` itself — it is read straight from the project XML, so a value inherited from a shared `Directory.Build.props` is not picked up. When not a single project opts in, the generator logs a warning naming the property rather than producing an empty catalog silently. If a fresh pipeline produces an empty catalog, check the opt-in first. See [Opting a project in](ArchitectureOfTheDocumentationPipeline.en.md#opting-a-project-in). +`--service-name` is required for Markdown and HTML because their RFC 9457 examples use problem types such as: -This ensures that documentation always matches the version of the system that is deployed. No manual updates are required, and no drift can occur. +```text +urn:problem:my-api:payment-declined +``` -You can emit the catalog per locale by adding `--language <
>` (e.g. a CI matrix over `en`, `fr`, `sv`); file names and anchors stay stable across languages. See [Internationalization](Internationalization.en.md). +JSON output does not require a service name. -The public RFC 9457 examples carry a problem `type` of the shape `urn:problem:{service}:{code}`. Provide the service segment with `--service-name ` (or `serviceName` in `fce.json`); it is required for the `markdown` and `html` formats — `fce generate` fails with a clear message when it is missing — while `json` (which carries no such example) does not need it. +## Recommended GitHub Actions workflow -## đŸ›Ąïž Guarding the error contract +```yaml +name: error-documentation -Error codes and context keys are a public contract: clients branch on them, dashboards alert on them, support procedures reference them. A CI step can guard that contract by comparing the current catalog against a committed baseline and failing the build when a code disappears by accident: +on: + pull_request: + push: + branches: [main] -```bash -fce catalog diff --solution MyApp.sln -``` +jobs: + generate-error-catalog: + runs-on: ubuntu-latest -See [Catalog Versioning](CatalogVersioning.en.md) for the baseline workflow, the change classification and the exit-code semantics. + steps: + - uses: actions/checkout@v4 -## 🌍 Publishing documentation + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' -The generated documentation can be: + - name: Install FirstClassErrors CLI + run: dotnet tool install --global FirstClassErrors.Cli -* published to an internal documentation portal -* exposed via a static site -* attached to release artifacts + - name: Build + run: dotnet build MyApp.sln -c Release -The important principle is: + - name: Generate catalog + run: | + fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format html \ + --layout split \ + --output artifacts/error-catalog \ + --service-name my-api -> The documentation must be reachable by the people investigating production issues. + - name: Publish catalog artifact + uses: actions/upload-artifact@v4 + with: + name: error-catalog + path: artifacts/error-catalog +``` -## 📜 Logging integration +The build and generation steps use the same configuration. `--no-build` prevents the generator from rebuilding a different set of binaries. -FirstClassErrors are designed to integrate naturally with structured logging. +## Generate several languages -Logs can include: +Run one generation per locale: -* the error code -* the unique instance ID -* the occurrence timestamp -* the error context +```yaml +strategy: + matrix: + language: [en, fr] -This makes logs not only readable but also correlatable across systems. +steps: + # checkout, setup, install, and build omitted -## 🔍 Logging inner errors + - name: Generate ${{ matrix.language }} catalog + run: | + fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format html \ + --layout split \ + --language "${{ matrix.language }}" \ + --output "artifacts/error-catalog-${{ matrix.language }}" \ + --service-name my-api +``` -By default, most logging setups treat exceptions as flat messages or stack traces. They do not automatically traverse and structure the diagnostic information carried by a `DiagnosableException` in a meaningful way for analysis. +File names and anchors remain stable across languages. See [Internationalization](Internationalization.en.md) for how content and renderer templates are localized. -A `DiagnosableException` does not set `Exception.InnerException`; instead, the diagnostic chain lives on its `Error`. Through `exception.Error.InnerErrors` (a list of `Error`), a logging filter or middleware should explicitly traverse and log the chain. Without this step, part of the diagnostic information carried by the model may remain unused in logs. +## Choose a publication target -This filter can: +The generated catalog may be: -* detect `DiagnosableException` -* read its `.Error` -* traverse `Error.InnerErrors` and log the full chain in a structured form +- retained as a pipeline artifact; +- attached to a release; +- deployed as a static site; +- copied into an internal documentation portal; +- published beside service operational documentation. -This preserves diagnostic depth and ensures that the richness of the error model is actually visible in operational logs. +The important requirement is not the platform. It is that the catalog for a deployed version is reachable by the people investigating that version. -## 🔗 Linking logs to documentation +## Keep versioned catalogs -A powerful pattern is to enrich the thrown exception (via its `Error`) with a documentation URL. +A single “latest” site is useful for daily work, but it does not explain an older production release after the contract has evolved. -During the documentation generation step, each error can be associated with a page or anchor. A logging filter can then populate: +For long-lived or support-critical systems, publish at least one immutable form per release: -``` -exception.HelpLink = "https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH" +```text +/errors/latest/ +/errors/releases/2.4.0/ +/errors/releases/2.3.1/ ``` -This makes production logs navigable: support can move directly from a log entry to the corresponding error documentation. +This lets an `InstanceId`, deployment version, or release tag lead support to the matching documentation. + +## Guard the error contract + +Generation answers “what does this version document?” Catalog versioning answers “did this version break a previously accepted contract?” + +```bash +fce catalog diff --solution MyApp.sln --configuration Release --no-build +``` -## đŸ§© Complementary to structured logging +Keep the accepted baseline in source control and run the comparison in pull requests. -FirstClassErrors do not replace structured logging, scopes, or correlation IDs. +See: -They complement them: +- [Catalog Versioning](CatalogVersioning.en.md) for the mental model and daily workflow; +- [Catalog Versioning CI/CD](CatalogVersioningCI.en.md) for complete GitHub Actions and GitLab examples. -* structured logging → technical context -* scopes → execution context -* FirstClassErrors → semantic error meaning +## Failure policy -Together, they provide a complete picture of what happened. +Treat these situations differently: -## 🎯 The objective +| Situation | Pipeline meaning | +| --- | --- | +| application build fails | the product cannot be produced | +| extraction or rendering fails | the catalog cannot be trusted | +| no project opted in | configuration is incomplete or the solution intentionally has no documented project | +| catalog contract breaks | human review is required before acceptance | +| publishing fails | the documentation is unavailable even if generation succeeded | -Industrial integration turns errors into a shared operational language. +Do not hide extraction or publication failures behind `continue-on-error` in a pipeline that promises operational documentation. -Errors become: +## Review checklist -* documented -* traceable -* searchable -* actionable +Before approving a catalog-delivery pipeline, verify that: -**automatically**, as part of the build and delivery process — without relying on manual documentation efforts. +- opted-in projects declare the marker in their own `.csproj`; +- generation uses the same binaries and configuration as the application build; +- `--service-name` is supplied for Markdown or HTML; +- generated files are published somewhere reachable; +- locale-specific outputs do not overwrite one another; +- released versions can retain immutable documentation; +- contract comparison is separate from catalog rendering; +- generation and publication failures are visible. --- --- \ No newline at end of file From 31bf108af4b3eabec9b5f559263d1cf710fddcc5 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:39:40 +0200 Subject: [PATCH 06/13] docs(gendoc): focus French operations guide on catalog delivery --- doc/OperationalIntegration.fr.md | 225 +++++++++++++++++++++---------- 1 file changed, 155 insertions(+), 70 deletions(-) diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md index bcbfe9c..e14d93f 100644 --- a/doc/OperationalIntegration.fr.md +++ b/doc/OperationalIntegration.fr.md @@ -1,130 +1,215 @@ -# CI/CD et intĂ©gration opĂ©rationnelle +# GĂ©nĂ©rer et publier le catalogue d’erreurs -🌍 **Langues:** +🌍 **Langues :** 🇬🇧 [English](./OperationalIntegration.en.md) | đŸ‡«đŸ‡· Français (ce fichier) -FirstClassErrors rĂ©vĂšle toute sa valeur lorsqu’il est intĂ©grĂ© dans la chaĂźne de livraison et les outils opĂ©rationnels. L’objectif n’est pas seulement de dĂ©finir la connaissance liĂ©e aux erreurs, mais de la rendre automatiquement accessible aux personnes qui en ont besoin : dĂ©veloppeurs, Ă©quipes de support et opĂ©rateurs. +FirstClassErrors devient rĂ©ellement utile pour l’exploitation lorsque le catalogue est gĂ©nĂ©rĂ© depuis le code exact en cours de build et publiĂ© Ă  un endroit accessible aux dĂ©veloppeurs, au support et aux opĂ©rateurs. -## 📩 La documentation comme artefact de build +Ce guide couvre la chaĂźne de livraison. Pour le logging structurĂ© et les diagnostics de production, voir [Logging et intĂ©gration opĂ©rationnelle](LoggingIntegration.fr.md). -La documentation des erreurs doit ĂȘtre gĂ©nĂ©rĂ©e automatiquement pendant la CI. +## Le flux de livraison -Le gĂ©nĂ©rateur `fce` est distribuĂ© comme outil .NET -([`FirstClassErrors.Cli`](https://www.nuget.org/packages/FirstClassErrors.Cli)) : un -pipeline l’installe puis l’exĂ©cute comme Ă©tapes de build : +```mermaid +flowchart LR + A[Compiler l’application] --> B[Extraire la connaissance d’erreur] + B --> C[Rendre le catalogue] + C --> D[Publier l’artefact ou le site] + B --> E[Comparer la baseline de contrat] +``` + +Un pipeline fiable doit : + +1. compiler l’application ; +2. gĂ©nĂ©rer le catalogue depuis le code compilĂ© ; +3. publier les fichiers gĂ©nĂ©rĂ©s ; +4. Ă©ventuellement comparer le contrat courant Ă  une baseline commitĂ©e. -1. Installer le gĂ©nĂ©rateur : `dotnet tool install --global FirstClassErrors.Cli` -2. Compiler la solution -3. ExĂ©cuter le gĂ©nĂ©rateur (`fce generate`) pour produire le catalogue d’erreurs (Markdown, HTML ou JSON) -4. Le publier comme artefact du pipeline ou le dĂ©ployer sur un portail documentaire +## Activer les projets + +La gĂ©nĂ©ration au niveau solution est opt-in. Ajoutez le marqueur directement dans chaque `.csproj` qui dĂ©finit des erreurs applicatives documentĂ©es : + +```xml + + true + +``` -ConcrĂštement : +Le marqueur est lu dans le fichier projet lui-mĂȘme. Une valeur hĂ©ritĂ©e de `Directory.Build.props` n’est pas dĂ©tectĂ©e. + +Lorsqu’aucun projet n’a optĂ©, le gĂ©nĂ©rateur Ă©met un avertissement plutĂŽt que de prĂ©senter silencieusement un catalogue vide comme un rĂ©sultat valide. + +Pour les dĂ©clarations ambiguĂ«s, la dĂ©couverte de projets et le fonctionnement des workers, voir [Architecture du pipeline de documentation](ArchitectureOfTheDocumentationPipeline.fr.md). + +## GĂ©nĂ©rer le catalogue en local + +Installez le CLI, compilez, puis gĂ©nĂ©rez depuis les binaires existants : ```bash dotnet tool install --global FirstClassErrors.Cli dotnet build MyApp.sln -c Release -fce generate --solution MyApp.sln --no-build \ - --output artifacts/errors.md --format markdown --service-name my-api +fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format markdown \ + --output artifacts/errors.md \ + --service-name my-api ``` -La gĂ©nĂ©ration est **opt-in par projet** : seuls les projets dont le fichier projet (`.csproj`) dĂ©finit `true` sont analysĂ©s ; un projet sans cette propriĂ©tĂ© est silencieusement ignorĂ©. Le marqueur doit figurer dans le `.csproj` lui-mĂȘme — il est lu directement dans le XML du projet, donc une valeur hĂ©ritĂ©e d’un `Directory.Build.props` partagĂ© n’est pas prise en compte. Quand aucun projet n’a optĂ©, le gĂ©nĂ©rateur journalise un avertissement nommant la propriĂ©tĂ© plutĂŽt que de produire un catalogue vide en silence. Si un pipeline neuf produit un catalogue vide, vĂ©rifiez d’abord l’opt-in. Voir [Activer un projet (opt-in)](ArchitectureOfTheDocumentationPipeline.fr.md#activer-un-projet-opt-in). +`--service-name` est requis pour Markdown et HTML car leurs exemples RFC 9457 utilisent des types de problĂšme comme : -Cela garantit que la documentation correspond toujours Ă  la version du systĂšme dĂ©ployĂ©e. Aucune mise Ă  jour manuelle n’est nĂ©cessaire et aucune dĂ©rive ne peut apparaĂźtre. +```text +urn:problem:my-api:payment-declined +``` -Vous pouvez produire le catalogue par langue en ajoutant `--language <
>` (par ex. une matrice CI sur `en`, `fr`, `sv`) ; les noms de fichiers et les ancres restent stables d’une langue Ă  l’autre. Voir [Internationalisation](Internationalisation.fr.md). +La sortie JSON ne nĂ©cessite pas de nom de service. -Les exemples publics RFC 9457 portent un `type` de problĂšme de la forme `urn:problem:{service}:{code}`. Fournissez le segment de service avec `--service-name ` (ou `serviceName` dans `fce.json`) ; il est requis pour les formats `markdown` et `html` — `fce generate` Ă©choue avec un message clair lorsqu’il est absent — tandis que `json` (qui ne contient pas cet exemple) n’en a pas besoin. +## Workflow GitHub Actions recommandĂ© -## đŸ›Ąïž ProtĂ©ger le contrat d'erreurs +```yaml +name: error-documentation -Les codes d'erreur et les clĂ©s de contexte sont un contrat public : des clients branchent leur logique dessus, des tableaux de bord alertent dessus, des procĂ©dures de support y font rĂ©fĂ©rence. Une Ă©tape de CI peut protĂ©ger ce contrat en comparant le catalogue courant Ă  une baseline commitĂ©e et en faisant Ă©chouer le build quand un code disparaĂźt par accident : +on: + pull_request: + push: + branches: [main] -```bash -fce catalog diff --solution MyApp.sln -``` +jobs: + generate-error-catalog: + runs-on: ubuntu-latest -Voir [Versionnage du catalogue](CatalogVersioning.fr.md) pour le workflow de baseline, la classification des changements et la sĂ©mantique des codes de sortie. + steps: + - uses: actions/checkout@v4 -## 🌍 Publication de la documentation + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' -La documentation gĂ©nĂ©rĂ©e peut ĂȘtre : + - name: Install FirstClassErrors CLI + run: dotnet tool install --global FirstClassErrors.Cli -* publiĂ©e sur un portail documentaire interne -* exposĂ©e via un site statique -* attachĂ©e aux artefacts de release + - name: Build + run: dotnet build MyApp.sln -c Release -Le principe clĂ© est : + - name: Generate catalog + run: | + fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format html \ + --layout split \ + --output artifacts/error-catalog \ + --service-name my-api -> La documentation doit ĂȘtre accessible aux personnes qui investiguent les incidents en production. + - name: Publish catalog artifact + uses: actions/upload-artifact@v4 + with: + name: error-catalog + path: artifacts/error-catalog +``` -## 📜 IntĂ©gration avec les logs +Le build et la gĂ©nĂ©ration utilisent la mĂȘme configuration. `--no-build` Ă©vite que le gĂ©nĂ©rateur reconstruise un autre ensemble de binaires. -FirstClassErrors est conçu pour s’intĂ©grer naturellement avec le logging structurĂ©. +## GĂ©nĂ©rer plusieurs langues -Les logs peuvent inclure : +ExĂ©cutez une gĂ©nĂ©ration par locale : -* le code d'erreur -* l'identifiant unique de l'erreur -* l'horodatage de l'occurrence -* le contexte d’erreur +```yaml +strategy: + matrix: + language: [en, fr] -Cela rend les logs non seulement lisibles, mais aussi corrĂ©lables entre systĂšmes. +steps: + # checkout, setup, installation et build omis -## 🔍 Logging des inner errors + - name: Generate ${{ matrix.language }} catalog + run: | + fce generate \ + --solution MyApp.sln \ + --configuration Release \ + --no-build \ + --format html \ + --layout split \ + --language "${{ matrix.language }}" \ + --output "artifacts/error-catalog-${{ matrix.language }}" \ + --service-name my-api +``` -Par dĂ©faut, la plupart des configurations de logging traitent les exceptions comme de simples messages ou des stack traces. Elles ne parcourent pas automatiquement l’information de diagnostic portĂ©e par une `DiagnosableException` de maniĂšre structurĂ©e et exploitable pour l’analyse. +Les noms de fichiers et les ancres restent stables d’une langue Ă  l’autre. Voir [Internationalisation](Internationalisation.fr.md) pour la localisation du contenu et des templates de renderer. -Une `DiagnosableException` ne renseigne pas `Exception.InnerException` ; la chaĂźne de diagnostic vit plutĂŽt sur son `Error`. Via `exception.Error.InnerErrors` (une liste d’`Error`), un filtre de logging ou un middleware devrait explicitement parcourir et logger cette chaĂźne. Sans cela, une partie de l’information de diagnostic portĂ©e par le modĂšle peut rester inutilisĂ©e dans les logs. +## Choisir une cible de publication -Ce filtre peut : +Le catalogue gĂ©nĂ©rĂ© peut ĂȘtre : -* dĂ©tecter les `DiagnosableException` -* lire son `.Error` -* parcourir `Error.InnerErrors` et logger toute la chaĂźne de maniĂšre structurĂ©e +- conservĂ© comme artefact de pipeline ; +- attachĂ© Ă  une release ; +- dĂ©ployĂ© comme site statique ; +- copiĂ© dans un portail documentaire interne ; +- publiĂ© Ă  cĂŽtĂ© de la documentation opĂ©rationnelle du service. -Cela prĂ©serve la profondeur diagnostique et garantit que la richesse du modĂšle d’erreur est rĂ©ellement visible dans les logs opĂ©rationnels. +La plateforme importe moins que l’accessibilitĂ© : le catalogue correspondant Ă  une version dĂ©ployĂ©e doit ĂȘtre atteignable par les personnes qui investiguent cette version. -## 🔗 Lier les logs Ă  la documentation +## Conserver des catalogues versionnĂ©s -Un pattern puissant consiste Ă  enrichir l’exception levĂ©e (via son `Error`) avec une URL vers la documentation. +Un site « latest » est pratique au quotidien, mais n’explique plus correctement une ancienne release de production aprĂšs l’évolution du contrat. -Lors de la gĂ©nĂ©ration de la documentation, chaque erreur peut ĂȘtre associĂ©e Ă  une page ou une ancre. Un filtre de logging peut alors renseigner : +Pour les systĂšmes durables ou critiques pour le support, publiez au moins une forme immuable par release : -``` -exception.HelpLink = "https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH" +```text +/errors/latest/ +/errors/releases/2.4.0/ +/errors/releases/2.3.1/ ``` -Les logs de production deviennent ainsi navigables : le support peut passer directement d’une entrĂ©e de log Ă  la documentation correspondante de l’erreur. +Un `InstanceId`, une version de dĂ©ploiement ou un tag de release peut alors mener le support vers la documentation correspondante. + +## ProtĂ©ger le contrat d’erreurs + +La gĂ©nĂ©ration rĂ©pond Ă  « que documente cette version ? ». Le versionnage rĂ©pond Ă  « cette version casse-t-elle un contrat dĂ©jĂ  acceptĂ© ? ». + +```bash +fce catalog diff --solution MyApp.sln --configuration Release --no-build +``` -## đŸ§© ComplĂ©mentaire au logging structurĂ© +Conservez la baseline acceptĂ©e dans le dĂ©pĂŽt et exĂ©cutez la comparaison dans les pull requests. -FirstClassErrors ne remplace ni le logging structurĂ©, ni les scopes, ni les correlation IDs. +Voir : -Il les complĂšte : +- [Versionnage du catalogue](CatalogVersioning.fr.md) pour le modĂšle mental et le workflow quotidien ; +- [IntĂ©gration CI/CD du versionnage](CatalogVersioningCI.fr.md) pour les exemples complets GitHub Actions et GitLab. -* logging structurĂ© → contexte technique -* scopes → contexte d’exĂ©cution -* FirstClassErrors → signification sĂ©mantique de l’erreur +## Politique d’échec -Ensemble, ils donnent une vision complĂšte de ce qui s’est passĂ©. +Traitez sĂ©parĂ©ment les situations suivantes : -## 🎯 L’objectif +| Situation | Signification dans le pipeline | +| --- | --- | +| le build applicatif Ă©choue | le produit ne peut pas ĂȘtre construit | +| l’extraction ou le rendu Ă©choue | le catalogue n’est pas fiable | +| aucun projet n’a optĂ© | la configuration est incomplĂšte ou la solution ne contient volontairement aucun projet documentĂ© | +| le contrat du catalogue casse | une revue humaine est nĂ©cessaire avant acceptation | +| la publication Ă©choue | la documentation reste indisponible malgrĂ© une gĂ©nĂ©ration rĂ©ussie | -L’intĂ©gration industrielle transforme les erreurs en langage opĂ©rationnel partagĂ©. +Ne masquez pas les erreurs d’extraction ou de publication avec `continue-on-error` dans un pipeline qui promet une documentation opĂ©rationnelle. -Les erreurs deviennent : +## Checklist de revue -* documentĂ©es -* traçables -* recherchables -* exploitables +Avant d’approuver un pipeline de livraison du catalogue, vĂ©rifiez que : -**automatiquement**, dans le cadre du processus de build et de livraison — sans dĂ©pendre d’efforts manuels de documentation. +- les projets activĂ©s dĂ©clarent le marqueur dans leur propre `.csproj` ; +- la gĂ©nĂ©ration utilise les mĂȘmes binaires et la mĂȘme configuration que le build applicatif ; +- `--service-name` est fourni pour Markdown ou HTML ; +- les fichiers gĂ©nĂ©rĂ©s sont publiĂ©s Ă  un endroit accessible ; +- les sorties par langue ne s’écrasent pas ; +- les releases peuvent conserver une documentation immuable ; +- la comparaison de contrat reste distincte du rendu du catalogue ; +- les Ă©checs de gĂ©nĂ©ration et de publication sont visibles. --- --- \ No newline at end of file From ffb83d70ee31da34e93a3d53d8d865f4de98235b Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:40:41 +0200 Subject: [PATCH 07/13] docs(core): add logging integration guide --- doc/LoggingIntegration.en.md | 260 +++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 doc/LoggingIntegration.en.md diff --git a/doc/LoggingIntegration.en.md b/doc/LoggingIntegration.en.md new file mode 100644 index 0000000..b311426 --- /dev/null +++ b/doc/LoggingIntegration.en.md @@ -0,0 +1,260 @@ +# Logging and Operational Integration + +🌍 **Languages:** +🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./LoggingIntegration.fr.md) + +FirstClassErrors does not replace structured logging. It adds stable semantic information to the technical and execution context already present in logs. + +This guide explains what to log, how to preserve inner errors, and how to connect an occurrence to the generated catalog. + +For catalog generation and publication, see [Generating and Publishing the Error Catalog](OperationalIntegration.en.md). + +## Three complementary layers + +| Layer | Answers | +| --- | --- | +| structured log properties | what happened technically? | +| scopes and correlation identifiers | in which request, message, or workflow did it happen? | +| FirstClassErrors | what does this recognized failure mean? | + +A useful production event combines all three rather than flattening everything into one message. + +## Minimum error properties to log + +For every `Error`, capture at least: + +| Property | Purpose | +| --- | --- | +| `Code` | stable grouping and alerting key | +| `InstanceId` | identity of this specific occurrence | +| `OccurredAt` | UTC creation time of the error | +| runtime error type | domain, infrastructure, primary port, or secondary port | +| `DiagnosticMessage` | internal explanation for developers and support | +| `Context` | structured facts specific to the occurrence | +| inner errors | causal or aggregated diagnostic depth | + +For `InfrastructureError`, also capture: + +- `Transience`; +- `InteractionDirection`. + +Do not expose `DiagnosticMessage` or unrestricted context in public API responses merely because they are present in logs. + +## Example structured event + +A serialized event might look like this: + +```json +{ + "level": "Error", + "message": "Payment authorization failed", + "traceId": "91c1d1bda3be4d0c", + "service": "checkout-api", + "deploymentVersion": "2.4.0", + "error": { + "code": "PAYMENT_PROVIDER_UNAVAILABLE", + "instanceId": "be1226ef-8464-4f88-9dca-9ab1f74da824", + "occurredAt": "2026-07-14T13:40:52.184Z", + "type": "SecondaryPortError", + "direction": "Outgoing", + "transience": "Transient", + "diagnosticMessage": "The payment provider timed out after 5 seconds.", + "context": { + "PaymentId": "f646943f-eec1-46bb-8989-32a97cba60fa", + "Provider": "ExamplePay" + }, + "innerErrors": [] + } +} +``` + +The exact JSON schema belongs to the application or logging adapter. The important point is to keep fields structured and queryable. + +## Log the `Error`, not only `Exception.Message` + +A `DiagnosableException` exposes its semantic model through `.Error`: + +```csharp +catch (DiagnosableException exception) +{ + Error error = exception.Error; + + logger.LogError( + exception, + "Operation failed with {ErrorCode} ({ErrorInstanceId})", + error.Code, + error.InstanceId); +} +``` + +Passing the exception preserves the stack trace. Logging named properties preserves the stable semantic keys. + +A formatter, enricher, filter, or middleware can then serialize the remaining `Error` properties consistently. + +## Preserve context as structured data + +Do not concatenate context into one diagnostic string: + +```text +Payment f646... failed for provider ExamplePay +``` + +Prefer separate fields that can be filtered and aggregated: + +```json +{ + "PaymentId": "f646943f-eec1-46bb-8989-32a97cba60fa", + "Provider": "ExamplePay" +} +``` + +Context keys are operational vocabulary. Keep their names stable and ensure the values are safe for the intended log destination. + +See [Error Context](ErrorContext.en.md) for key design and data-safety rules. + +## Traverse `InnerErrors` + +`DiagnosableException` does not use `Exception.InnerException` for the FirstClassErrors diagnostic tree. Causes and aggregated failures live in: + +```csharp +exception.Error.InnerErrors +``` + +A logging adapter must traverse that collection explicitly: + +```csharp +static object ToLogModel(Error error) +{ + return new + { + Code = error.Code.ToString(), + error.InstanceId, + error.OccurredAt, + Type = error.GetType().Name, + error.DiagnosticMessage, + Context = error.Context, + InnerErrors = error.InnerErrors.Select(ToLogModel).ToArray() + }; +} +``` + +This example shows the recursive shape; adapt context serialization and infrastructure-specific fields to the application. + +If only the outer error is logged, the most useful cause may disappear from operational analysis. + +## Keep the outer and inner meanings + +Consider an incoming request rejected because a domain value cannot be created: + +```text +PrimaryPortError: REQUEST_REJECTED +└── DomainError: AMOUNT_NEGATIVE +``` + +The outer error explains what happened at the boundary. The inner error explains the violated domain rule. Logging both preserves the complete diagnostic story. + +Do not flatten the tree into one synthetic code or replace the outer error with the deepest cause. + +## Correlate the occurrence + +`InstanceId` identifies one error occurrence. It complements, but does not replace: + +- trace and span identifiers; +- request or message identifiers; +- business identifiers stored in `ErrorContext`; +- deployment version. + +A useful investigation path is: + +```text +alert → traceId → error InstanceId → business context → catalog entry +``` + +Include the deployment version so support can open the catalog that matches the running code rather than the latest documentation by accident. + +## Link logs to the catalog + +A log event may include a documentation URL derived from the code and deployed catalog location: + +```text +https://docs.mycompany/errors/releases/2.4.0/payment-provider-unavailable +``` + +The URL may be emitted as a structured property such as `error.documentationUrl`. + +When an exception object is used by tooling that recognizes `Exception.HelpLink`, the application may also set it before logging or rethrowing: + +```csharp +exception.HelpLink = documentationUrl; +``` + +The generated catalog remains the source of explanation; the log stores the navigational link, not a duplicated copy of the documentation. + +## Logging outcomes without throwing + +An `Outcome` failure may never become an exception. Log its `Error` directly when the application decides the failure belongs in operational logs: + +```csharp +Outcome outcome = checkout.Pay(order); + +if (outcome.IsFailure) +{ + logger.LogWarning( + "Checkout failed with {ErrorCode} ({ErrorInstanceId})", + outcome.Error!.Code, + outcome.Error.InstanceId); +} +``` + +Do not throw solely to make the logging framework see the error. A shared serializer can handle both `Error` and `DiagnosableException.Error`. + +## Choose the log level from operational impact + +The existence of an `Error` does not automatically imply an error-level log. + +Examples: + +| Situation | Possible treatment | +| --- | --- | +| user input rejected as expected | information or warning, depending on policy | +| item rejected inside a normal batch | warning or metric, possibly sampled | +| transient outgoing dependency failure | warning or error, depending on retries and final outcome | +| exhausted retries causing request failure | error | +| domain failure already returned to a caller and fully expected | potentially no log at this layer | + +Use `Transience`, `InteractionDirection`, application outcome, and repetition rate to inform the policy. Avoid duplicate logs at every layer of the call stack. + +## Protect sensitive data + +Before logging context or diagnostic messages, verify that they do not contain: + +- secrets, passwords, tokens, or credentials; +- unrestricted personal data; +- full request or response payloads; +- payment data or other regulated values; +- unbounded collections or files. + +The internal audience permits technical detail; it does not remove security, privacy, retention, or access-control obligations. + +## Review checklist + +Before approving a logging integration, verify that: + +- the exception stack trace and the structured `Error` are both preserved when an exception exists; +- code, instance id, occurrence time, type, diagnostic message, and context are queryable fields; +- infrastructure direction and transience are logged; +- `InnerErrors` are traversed recursively; +- outcome failures can be logged without manufacturing exceptions; +- trace, business, occurrence, and deployment identifiers remain distinct; +- the log level reflects operational impact rather than merely the presence of an error; +- sensitive and unbounded data are excluded; +- documentation links target the catalog version matching the deployment; +- the same failure is not logged repeatedly by every layer. + +--- + + + +--- \ No newline at end of file From 972fad5584d16ba85a26939898baff808c9fdc93 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 16:42:01 +0200 Subject: [PATCH 08/13] docs(core): add French logging integration guide --- doc/LoggingIntegration.fr.md | 258 +++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 doc/LoggingIntegration.fr.md diff --git a/doc/LoggingIntegration.fr.md b/doc/LoggingIntegration.fr.md new file mode 100644 index 0000000..ae22bb1 --- /dev/null +++ b/doc/LoggingIntegration.fr.md @@ -0,0 +1,258 @@ +# Logging et intĂ©gration opĂ©rationnelle + +🌍 **Langues :** +🇬🇧 [English](./LoggingIntegration.en.md) | đŸ‡«đŸ‡· Français (ce fichier) + +FirstClassErrors ne remplace pas le logging structurĂ©. Il ajoute une information sĂ©mantique stable au contexte technique et d’exĂ©cution dĂ©jĂ  prĂ©sent dans les logs. + +Ce guide explique quoi logger, comment prĂ©server les erreurs internes et comment relier une occurrence au catalogue gĂ©nĂ©rĂ©. + +Pour la gĂ©nĂ©ration et la publication du catalogue, voir [GĂ©nĂ©rer et publier le catalogue d’erreurs](OperationalIntegration.fr.md). + +## Trois couches complĂ©mentaires + +| Couche | RĂ©pond Ă  | +| --- | --- | +| propriĂ©tĂ©s structurĂ©es du log | que s’est-il passĂ© techniquement ? | +| scopes et identifiants de corrĂ©lation | dans quelle requĂȘte, quel message ou quel workflow ? | +| FirstClassErrors | que signifie cette dĂ©faillance reconnue ? | + +Un Ă©vĂ©nement de production utile combine les trois au lieu de tout aplatir dans un seul message. + +## PropriĂ©tĂ©s minimales Ă  logger + +Pour chaque `Error`, capturez au moins : + +| PropriĂ©tĂ© | RĂŽle | +| --- | --- | +| `Code` | clĂ© stable de regroupement et d’alerte | +| `InstanceId` | identitĂ© de cette occurrence prĂ©cise | +| `OccurredAt` | instant UTC de crĂ©ation de l’erreur | +| type runtime de l’erreur | domaine, infrastructure, port primaire ou port secondaire | +| `DiagnosticMessage` | explication interne pour les dĂ©veloppeurs et le support | +| `Context` | faits structurĂ©s propres Ă  l’occurrence | +| erreurs internes | profondeur causale ou agrĂ©gĂ©e du diagnostic | + +Pour une `InfrastructureError`, capturez Ă©galement : + +- `Transience` ; +- `InteractionDirection`. + +N’exposez pas `DiagnosticMessage` ou un contexte non filtrĂ© dans une rĂ©ponse API publique sous prĂ©texte qu’ils sont prĂ©sents dans les logs. + +## Exemple d’évĂ©nement structurĂ© + +Un Ă©vĂ©nement sĂ©rialisĂ© peut ressembler Ă  ceci : + +```json +{ + "level": "Error", + "message": "Payment authorization failed", + "traceId": "91c1d1bda3be4d0c", + "service": "checkout-api", + "deploymentVersion": "2.4.0", + "error": { + "code": "PAYMENT_PROVIDER_UNAVAILABLE", + "instanceId": "be1226ef-8464-4f88-9dca-9ab1f74da824", + "occurredAt": "2026-07-14T13:40:52.184Z", + "type": "SecondaryPortError", + "direction": "Outgoing", + "transience": "Transient", + "diagnosticMessage": "The payment provider timed out after 5 seconds.", + "context": { + "PaymentId": "f646943f-eec1-46bb-8989-32a97cba60fa", + "Provider": "ExamplePay" + }, + "innerErrors": [] + } +} +``` + +Le schĂ©ma JSON exact appartient Ă  l’application ou Ă  l’adapter de logging. L’important est de conserver des champs structurĂ©s et requĂȘtables. + +## Logger l’`Error`, pas seulement `Exception.Message` + +Une `DiagnosableException` expose son modĂšle sĂ©mantique via `.Error` : + +```csharp +catch (DiagnosableException exception) +{ + Error error = exception.Error; + + logger.LogError( + exception, + "Operation failed with {ErrorCode} ({ErrorInstanceId})", + error.Code, + error.InstanceId); +} +``` + +Passer l’exception prĂ©serve la stack trace. Logger des propriĂ©tĂ©s nommĂ©es prĂ©serve les clĂ©s sĂ©mantiques stables. + +Un formatter, enricher, filtre ou middleware peut ensuite sĂ©rialiser les autres propriĂ©tĂ©s de l’`Error` de maniĂšre cohĂ©rente. + +## PrĂ©server le contexte comme donnĂ©es structurĂ©es + +Ne concatĂ©nez pas le contexte dans une seule chaĂźne de diagnostic : + +```text +Payment f646... failed for provider ExamplePay +``` + +PrĂ©fĂ©rez des champs distincts, filtrables et agrĂ©geables : + +```json +{ + "PaymentId": "f646943f-eec1-46bb-8989-32a97cba60fa", + "Provider": "ExamplePay" +} +``` + +Les clĂ©s de contexte constituent un vocabulaire opĂ©rationnel. Gardez leurs noms stables et vĂ©rifiez que les valeurs sont sĂ»res pour la destination de logs visĂ©e. + +Voir [Contexte d’erreur](ErrorContext.fr.md) pour la conception des clĂ©s et les rĂšgles de sĂ©curitĂ© des donnĂ©es. + +## Parcourir `InnerErrors` + +`DiagnosableException` n’utilise pas `Exception.InnerException` pour l’arbre diagnostique FirstClassErrors. Les causes et erreurs agrĂ©gĂ©es vivent dans : + +```csharp +exception.Error.InnerErrors +``` + +Un adapter de logging doit parcourir explicitement cette collection : + +```csharp +static object ToLogModel(Error error) +{ + return new + { + Code = error.Code.ToString(), + error.InstanceId, + error.OccurredAt, + Type = error.GetType().Name, + error.DiagnosticMessage, + Context = error.Context, + InnerErrors = error.InnerErrors.Select(ToLogModel).ToArray() + }; +} +``` + +Cet exemple montre la forme rĂ©cursive ; adaptez la sĂ©rialisation du contexte et les champs propres aux erreurs d’infrastructure Ă  l’application. + +Si seule l’erreur externe est loggĂ©e, la cause la plus utile peut disparaĂźtre de l’analyse opĂ©rationnelle. + +## Conserver le sens externe et interne + +Prenons une requĂȘte entrante rejetĂ©e parce qu’une valeur mĂ©tier ne peut pas ĂȘtre construite : + +```text +PrimaryPortError: REQUEST_REJECTED +└── DomainError: AMOUNT_NEGATIVE +``` + +L’erreur externe explique ce qui s’est passĂ© Ă  la frontiĂšre. L’erreur interne explique la rĂšgle mĂ©tier violĂ©e. Logger les deux conserve toute l’histoire diagnostique. + +N’aplatissez pas l’arbre en un code synthĂ©tique unique et ne remplacez pas l’erreur externe par la cause la plus profonde. + +## CorrĂ©ler l’occurrence + +`InstanceId` identifie une occurrence d’erreur. Il complĂšte, sans les remplacer : + +- les identifiants de trace et de span ; +- les identifiants de requĂȘte ou de message ; +- les identifiants mĂ©tier placĂ©s dans `ErrorContext` ; +- la version de dĂ©ploiement. + +Un parcours d’investigation utile est : + +```text +alerte → traceId → InstanceId de l’erreur → contexte mĂ©tier → entrĂ©e du catalogue +``` + +Incluez la version de dĂ©ploiement afin que le support ouvre le catalogue correspondant au code exĂ©cutĂ©, et non la documentation la plus rĂ©cente par erreur. + +## Relier les logs au catalogue + +Un Ă©vĂ©nement peut inclure une URL de documentation dĂ©rivĂ©e du code et de l’emplacement du catalogue dĂ©ployĂ© : + +```text +https://docs.mycompany/errors/releases/2.4.0/payment-provider-unavailable +``` + +L’URL peut ĂȘtre publiĂ©e comme propriĂ©tĂ© structurĂ©e telle que `error.documentationUrl`. + +Lorsque l’objet exception est utilisĂ© par un outil qui comprend `Exception.HelpLink`, l’application peut aussi le renseigner avant le logging ou la relance : + +```csharp +exception.HelpLink = documentationUrl; +``` + +Le catalogue gĂ©nĂ©rĂ© reste la source d’explication ; le log conserve le lien de navigation, pas une copie dupliquĂ©e de la documentation. + +## Logger un outcome sans lever + +Un Ă©chec d’`Outcome` peut ne jamais devenir une exception. Loggez directement son `Error` lorsque l’application dĂ©cide que l’échec doit apparaĂźtre dans les logs opĂ©rationnels : + +```csharp +Outcome outcome = checkout.Pay(order); + +if (outcome.IsFailure) +{ + logger.LogWarning( + "Checkout failed with {ErrorCode} ({ErrorInstanceId})", + outcome.Error!.Code, + outcome.Error.InstanceId); +} +``` + +Ne levez pas une exception uniquement pour que le framework de logging voie l’erreur. Un sĂ©rialiseur partagĂ© peut gĂ©rer Ă  la fois `Error` et `DiagnosableException.Error`. + +## Choisir le niveau selon l’impact opĂ©rationnel + +La prĂ©sence d’une `Error` n’implique pas automatiquement un log de niveau erreur. + +| Situation | Traitement possible | +| --- | --- | +| saisie utilisateur rejetĂ©e comme prĂ©vu | information ou warning selon la politique | +| Ă©lĂ©ment rejetĂ© dans un batch normal | warning ou mĂ©trique, Ă©ventuellement Ă©chantillonnĂ© | +| dĂ©pendance sortante en Ă©chec transitoire | warning ou erreur selon les retries et le rĂ©sultat final | +| retries Ă©puisĂ©s provoquant l’échec de la requĂȘte | erreur | +| erreur mĂ©tier dĂ©jĂ  retournĂ©e Ă  l’appelant et totalement attendue | Ă©ventuellement aucun log Ă  cette couche | + +Utilisez `Transience`, `InteractionDirection`, le rĂ©sultat applicatif et la frĂ©quence pour guider la politique. Évitez les logs dupliquĂ©s Ă  chaque couche de la stack. + +## ProtĂ©ger les donnĂ©es sensibles + +Avant de logger le contexte ou les messages de diagnostic, vĂ©rifiez qu’ils ne contiennent pas : + +- secrets, mots de passe, tokens ou credentials ; +- donnĂ©es personnelles non maĂźtrisĂ©es ; +- corps complets de requĂȘte ou de rĂ©ponse ; +- donnĂ©es de paiement ou autres valeurs rĂ©glementĂ©es ; +- collections ou fichiers non bornĂ©s. + +Le public interne autorise davantage de dĂ©tail technique ; il ne supprime pas les obligations de sĂ©curitĂ©, de confidentialitĂ©, de rĂ©tention ou de contrĂŽle d’accĂšs. + +## Checklist de revue + +Avant d’approuver une intĂ©gration de logging, vĂ©rifiez que : + +- la stack trace de l’exception et l’`Error` structurĂ©e sont toutes deux prĂ©servĂ©es lorsqu’une exception existe ; +- code, instance id, instant, type, message de diagnostic et contexte sont requĂȘtables ; +- la direction et la transience sont loggĂ©es pour l’infrastructure ; +- `InnerErrors` est parcouru rĂ©cursivement ; +- les Ă©checs d’outcome peuvent ĂȘtre loggĂ©s sans fabriquer d’exception ; +- les identifiants de trace, mĂ©tier, occurrence et dĂ©ploiement restent distincts ; +- le niveau de log reflĂšte l’impact opĂ©rationnel et non la simple prĂ©sence d’une erreur ; +- les donnĂ©es sensibles et non bornĂ©es sont exclues ; +- les liens documentaires ciblent la version du catalogue correspondant au dĂ©ploiement ; +- la mĂȘme erreur n’est pas loggĂ©e Ă  rĂ©pĂ©tition par chaque couche. + +--- + + + +--- \ No newline at end of file From 6a86ab82937f45a830bc837dbdbe6b03610ffeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 07:01:37 +0000 Subject: [PATCH 09/13] docs(testing): gloss operational terms at first use Several terms reached the reader before their defining page, in both languages: - gloss the committed baseline in the delivery flow and say what fce catalog diff compares, with links to catalog versioning; - explain --layout split before the GitHub Actions workflow that uses it, linking the HTML renderer page; - introduce DiagnosableException as the exception ToException() produces instead of assuming it; - gloss logging scopes, snapshot testing, and rephrase the assertion-vs-exception sentence around OutcomeAssertionException; - explain how a logged occurrence leads to the matching versioned catalog, and fix a garden-path sentence in the delivery intro. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LUaMS5EHzAdQHBJzrYvKTj --- doc/DeterministicTesting.en.md | 2 +- doc/DeterministicTesting.fr.md | 2 +- doc/LoggingIntegration.en.md | 4 ++-- doc/LoggingIntegration.fr.md | 4 ++-- doc/OperationalIntegration.en.md | 10 +++++++--- doc/OperationalIntegration.fr.md | 10 +++++++--- doc/Testing.en.md | 4 ++-- doc/Testing.fr.md | 4 ++-- 8 files changed, 24 insertions(+), 16 deletions(-) diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md index cdad118..a789624 100644 --- a/doc/DeterministicTesting.en.md +++ b/doc/DeterministicTesting.en.md @@ -8,7 +8,7 @@ Every error occurrence records two values that should vary in production: - `OccurredAt`, the UTC instant at which the error was created; - `InstanceId`, a unique identifier for that occurrence. -Those values improve observability, but they make whole-object assertions and snapshots unstable. `FirstClassErrors.Testing` provides scoped overrides for tests that deliberately need deterministic values. +Those values improve observability, but they make whole-object assertions and snapshots (tests comparing a serialized object against an approved reference file) unstable. `FirstClassErrors.Testing` provides scoped overrides for tests that deliberately need deterministic values. For fluent assertions on outcomes and errors, start with [Testing Outcomes and Errors](Testing.en.md). diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md index 51cce22..0114459 100644 --- a/doc/DeterministicTesting.fr.md +++ b/doc/DeterministicTesting.fr.md @@ -8,7 +8,7 @@ Chaque occurrence d’erreur enregistre deux valeurs qui doivent varier en produ - `OccurredAt`, l’instant UTC de crĂ©ation de l’erreur ; - `InstanceId`, un identifiant unique de cette occurrence. -Ces valeurs amĂ©liorent l’observabilitĂ©, mais rendent instables les assertions sur l’objet complet et les snapshots. `FirstClassErrors.Testing` fournit des overrides bornĂ©s pour les tests qui ont rĂ©ellement besoin de valeurs dĂ©terministes. +Ces valeurs amĂ©liorent l’observabilitĂ©, mais rendent instables les assertions sur l’objet complet et les snapshots (tests qui comparent un objet sĂ©rialisĂ© Ă  un fichier de rĂ©fĂ©rence approuvĂ©). `FirstClassErrors.Testing` fournit des overrides bornĂ©s pour les tests qui ont rĂ©ellement besoin de valeurs dĂ©terministes. Pour les assertions fluentes sur les outcomes et les erreurs, commencez par [Tester les outcomes et les erreurs](Testing.fr.md). diff --git a/doc/LoggingIntegration.en.md b/doc/LoggingIntegration.en.md index b311426..20860b4 100644 --- a/doc/LoggingIntegration.en.md +++ b/doc/LoggingIntegration.en.md @@ -14,7 +14,7 @@ For catalog generation and publication, see [Generating and Publishing the Error | Layer | Answers | | --- | --- | | structured log properties | what happened technically? | -| scopes and correlation identifiers | in which request, message, or workflow did it happen? | +| scopes (ambient logging scopes such as `ILogger.BeginScope`) and correlation identifiers | in which request, message, or workflow did it happen? | | FirstClassErrors | what does this recognized failure mean? | A useful production event combines all three rather than flattening everything into one message. @@ -72,7 +72,7 @@ The exact JSON schema belongs to the application or logging adapter. The importa ## Log the `Error`, not only `Exception.Message` -A `DiagnosableException` exposes its semantic model through `.Error`: +A `DiagnosableException` — the exception type that `error.ToException()` produces — exposes its semantic model through `.Error`: ```csharp catch (DiagnosableException exception) diff --git a/doc/LoggingIntegration.fr.md b/doc/LoggingIntegration.fr.md index ae22bb1..87283c8 100644 --- a/doc/LoggingIntegration.fr.md +++ b/doc/LoggingIntegration.fr.md @@ -14,7 +14,7 @@ Pour la gĂ©nĂ©ration et la publication du catalogue, voir [GĂ©nĂ©rer et publier | Couche | RĂ©pond Ă  | | --- | --- | | propriĂ©tĂ©s structurĂ©es du log | que s’est-il passĂ© techniquement ? | -| scopes et identifiants de corrĂ©lation | dans quelle requĂȘte, quel message ou quel workflow ? | +| scopes (portĂ©es de logging ambiantes, comme `ILogger.BeginScope`) et identifiants de corrĂ©lation | dans quelle requĂȘte, quel message ou quel workflow ? | | FirstClassErrors | que signifie cette dĂ©faillance reconnue ? | Un Ă©vĂ©nement de production utile combine les trois au lieu de tout aplatir dans un seul message. @@ -72,7 +72,7 @@ Le schĂ©ma JSON exact appartient Ă  l’application ou Ă  l’adapter de logging ## Logger l’`Error`, pas seulement `Exception.Message` -Une `DiagnosableException` expose son modĂšle sĂ©mantique via `.Error` : +Une `DiagnosableException` — le type d’exception produit par `error.ToException()` — expose son modĂšle sĂ©mantique via `.Error` : ```csharp catch (DiagnosableException exception) diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md index d486209..b33108d 100644 --- a/doc/OperationalIntegration.en.md +++ b/doc/OperationalIntegration.en.md @@ -3,7 +3,7 @@ 🌍 **Languages:** 🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./OperationalIntegration.fr.md) -FirstClassErrors becomes operationally useful when the catalog is generated from the exact code being built and published where developers, support teams, and operators can reach it. +FirstClassErrors becomes operationally useful when the catalog is generated from the exact code being built, and published where developers, support teams, and operators can reach it. This guide covers the delivery workflow. For structured logging and production diagnostics, see [Logging and Operational Integration](LoggingIntegration.en.md). @@ -22,7 +22,7 @@ A reliable pipeline should: 1. build the application; 2. generate the catalog from the built code; 3. publish the generated files; -4. optionally compare the current contract with a committed baseline. +4. optionally compare the current contract with a committed baseline — the last accepted snapshot of your error codes and context keys (see [Catalog Versioning](CatalogVersioning.en.md)). ## Opt projects into generation @@ -66,6 +66,8 @@ JSON output does not require a service name. ## Recommended GitHub Actions workflow +The workflow renders the catalog as HTML with `--layout split` (one page per error; see [The HTML Renderer](TheHtmlRenderer.en.md)): + ```yaml name: error-documentation @@ -162,12 +164,14 @@ For long-lived or support-critical systems, publish at least one immutable form /errors/releases/2.3.1/ ``` -This lets an `InstanceId`, deployment version, or release tag lead support to the matching documentation. +Log events that record the deployed version alongside the error then let support start from a logged occurrence — its `InstanceId` plus that version — and open the matching catalog. ## Guard the error contract Generation answers “what does this version document?” Catalog versioning answers “did this version break a previously accepted contract?” +`fce catalog diff` compares the catalog extracted from the current build against a baseline file committed to the repository and reports contract changes: + ```bash fce catalog diff --solution MyApp.sln --configuration Release --no-build ``` diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md index e14d93f..d2f9fd7 100644 --- a/doc/OperationalIntegration.fr.md +++ b/doc/OperationalIntegration.fr.md @@ -3,7 +3,7 @@ 🌍 **Langues :** 🇬🇧 [English](./OperationalIntegration.en.md) | đŸ‡«đŸ‡· Français (ce fichier) -FirstClassErrors devient rĂ©ellement utile pour l’exploitation lorsque le catalogue est gĂ©nĂ©rĂ© depuis le code exact en cours de build et publiĂ© Ă  un endroit accessible aux dĂ©veloppeurs, au support et aux opĂ©rateurs. +FirstClassErrors devient rĂ©ellement utile pour l’exploitation lorsque le catalogue est gĂ©nĂ©rĂ© depuis le code exact en cours de build, puis publiĂ© Ă  un endroit accessible aux dĂ©veloppeurs, au support et aux opĂ©rateurs. Ce guide couvre la chaĂźne de livraison. Pour le logging structurĂ© et les diagnostics de production, voir [Logging et intĂ©gration opĂ©rationnelle](LoggingIntegration.fr.md). @@ -22,7 +22,7 @@ Un pipeline fiable doit : 1. compiler l’application ; 2. gĂ©nĂ©rer le catalogue depuis le code compilĂ© ; 3. publier les fichiers gĂ©nĂ©rĂ©s ; -4. Ă©ventuellement comparer le contrat courant Ă  une baseline commitĂ©e. +4. Ă©ventuellement comparer le contrat courant Ă  une baseline commitĂ©e — le dernier snapshot acceptĂ© de vos codes d’erreur et clĂ©s de contexte (voir [Versionnage du catalogue](CatalogVersioning.fr.md)). ## Activer les projets @@ -66,6 +66,8 @@ La sortie JSON ne nĂ©cessite pas de nom de service. ## Workflow GitHub Actions recommandĂ© +Le workflow rend le catalogue en HTML avec `--layout split` (une page par erreur ; voir [Le renderer HTML](TheHtmlRenderer.fr.md)) : + ```yaml name: error-documentation @@ -162,12 +164,14 @@ Pour les systĂšmes durables ou critiques pour le support, publiez au moins une f /errors/releases/2.3.1/ ``` -Un `InstanceId`, une version de dĂ©ploiement ou un tag de release peut alors mener le support vers la documentation correspondante. +Des Ă©vĂ©nements de log qui enregistrent la version dĂ©ployĂ©e Ă  cĂŽtĂ© de l’erreur permettent alors au support de partir d’une occurrence loggĂ©e — son `InstanceId` plus cette version — et d’ouvrir le catalogue correspondant. ## ProtĂ©ger le contrat d’erreurs La gĂ©nĂ©ration rĂ©pond Ă  « que documente cette version ? ». Le versionnage rĂ©pond Ă  « cette version casse-t-elle un contrat dĂ©jĂ  acceptĂ© ? ». +`fce catalog diff` compare le catalogue extrait du build courant Ă  un fichier de baseline commitĂ© dans le dĂ©pĂŽt et signale les changements de contrat : + ```bash fce catalog diff --solution MyApp.sln --configuration Release --no-build ``` diff --git a/doc/Testing.en.md b/doc/Testing.en.md index c5d9581..74f89bd 100644 --- a/doc/Testing.en.md +++ b/doc/Testing.en.md @@ -111,7 +111,7 @@ Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: Issuer r Expected the error to have code "ORDER_NOT_FOUND", but it was "ORDER_LOCKED". ``` -The assertion failure is distinct from the domain exception. A failed test therefore reports what the test expected and what the outcome actually contained. +`OutcomeAssertionException` is a test-framework failure, not the exception `error.ToException()` would produce. A failed test therefore reports what the test expected and what the outcome actually contained. ## What should a test assert? @@ -149,7 +149,7 @@ The test reads as a description of the failure contract rather than a sequence o ## Deterministic timestamps and instance ids -Every error occurrence contains an `OccurredAt` timestamp and a unique `InstanceId`. When a test or snapshot must assert those values, use the scoped overrides provided by the testing package. +Every error occurrence contains an `OccurredAt` timestamp and a unique `InstanceId`. When a test or snapshot (a test comparing a serialized object against an approved reference file) must assert those values, use the scoped overrides provided by the testing package. See [Deterministic Error Tests](DeterministicTesting.en.md) for `Clock.UseFixed(...)`, `InstanceIds.UseFixed(...)`, custom sources, parallel-test behavior, and full-error snapshots. diff --git a/doc/Testing.fr.md b/doc/Testing.fr.md index 3be89bd..426aaa4 100644 --- a/doc/Testing.fr.md +++ b/doc/Testing.fr.md @@ -111,7 +111,7 @@ Expected the outcome to succeed, but it failed with [PAYMENT_DECLINED]: L’éme Expected the error to have code "ORDER_NOT_FOUND", but it was "ORDER_LOCKED". ``` -L’échec d’assertion reste distinct de l’exception mĂ©tier. Le test indique donc ce qu’il attendait et ce que l’outcome contenait rĂ©ellement. +`OutcomeAssertionException` est un Ă©chec du framework de test, pas l’exception que produirait `error.ToException()`. Le test indique donc ce qu’il attendait et ce que l’outcome contenait rĂ©ellement. ## Que doit vĂ©rifier un test ? @@ -149,7 +149,7 @@ Le test se lit comme une description du contrat d’échec plutĂŽt que comme une ## Horodatages et identifiants dĂ©terministes -Chaque occurrence d’erreur contient un horodatage `OccurredAt` et un `InstanceId` unique. Lorsqu’un test ou un snapshot doit vĂ©rifier ces valeurs, utilisez les overrides bornĂ©s fournis par le package de test. +Chaque occurrence d’erreur contient un horodatage `OccurredAt` et un `InstanceId` unique. Lorsqu’un test ou un snapshot (un test qui compare un objet sĂ©rialisĂ© Ă  un fichier de rĂ©fĂ©rence approuvĂ©) doit vĂ©rifier ces valeurs, utilisez les overrides bornĂ©s fournis par le package de test. Voir [Tests d’erreur dĂ©terministes](DeterministicTesting.fr.md) pour `Clock.UseFixed(...)`, `InstanceIds.UseFixed(...)`, les sources personnalisĂ©es, le comportement en tests parallĂšles et les snapshots d’erreurs complĂštes. From 45a334142eea0222d072bf6e93e126b50f590520 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:22:51 +0000 Subject: [PATCH 10/13] docs(testing): use NFluent for plain-value assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace xUnit's Assert.* with NFluent's Check.That(...) in the Testing and Deterministic Error Tests guides, matching the assertion library this repository's own test suite actually uses. ShouldSucceed, ShouldFail, and the ErrorAssertion fluent surface are untouched — they are FirstClassErrors.Testing's own framework-agnostic API, not something NFluent replaces. Checked the rest of the doc corpus (main and the other open PRs): no other page uses Assert.*, so this is a contained fix. --- doc/DeterministicTesting.en.md | 18 +++++++++--------- doc/DeterministicTesting.fr.md | 18 +++++++++--------- doc/Testing.en.md | 17 +++++++++-------- doc/Testing.fr.md | 17 +++++++++-------- 4 files changed, 36 insertions(+), 34 deletions(-) diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md index a789624..813dad6 100644 --- a/doc/DeterministicTesting.en.md +++ b/doc/DeterministicTesting.en.md @@ -21,7 +21,7 @@ DateTimeOffset before = DateTimeOffset.UtcNow; DomainError error = MakeError(); DateTimeOffset after = DateTimeOffset.UtcNow; -Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); +Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); ``` `Clock.UseFixed(...)` makes the expected instant explicit: @@ -38,7 +38,7 @@ public void An_error_records_the_fixed_occurrence_time() .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "Order 42 was not found.") .WithPublicMessage("The order does not exist."); - Assert.Equal(instant, error.OccurredAt); + Check.That(error.OccurredAt).IsEqualTo(instant); } } ``` @@ -74,8 +74,8 @@ using (Clock.Use(new StepClock(start))) DomainError first = MakeError(); DomainError second = MakeError(); - Assert.Equal(start, first.OccurredAt); - Assert.Equal(start.AddSeconds(1), second.OccurredAt); + Check.That(first.OccurredAt).IsEqualTo(start); + Check.That(second.OccurredAt).IsEqualTo(start.AddSeconds(1)); } ``` @@ -95,7 +95,7 @@ public void A_missing_order_error_has_a_stable_instance_id() { Error error = orders.Find(missingOrderId).ShouldFail().Subject; - Assert.Equal(id, error.InstanceId); + Check.That(error.InstanceId).IsEqualTo(id); } } ``` @@ -120,8 +120,8 @@ using (InstanceIds.Use(SequentialIds())) Error first = MakeError(); Error second = MakeError(); - Assert.Equal("00000001-0000-0000-0000-000000000000", first.InstanceId.ToString()); - Assert.Equal("00000002-0000-0000-0000-000000000000", second.InstanceId.ToString()); + Check.That(first.InstanceId.ToString()).IsEqualTo("00000001-0000-0000-0000-000000000000"); + Check.That(second.InstanceId.ToString()).IsEqualTo("00000002-0000-0000-0000-000000000000"); } ``` @@ -145,8 +145,8 @@ public void A_missing_order_error_is_fully_deterministic() .WithContextEntry("OrderId", missingOrderId) .Subject; - Assert.Equal(instant, error.OccurredAt); - Assert.Equal(id, error.InstanceId); + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); } } ``` diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md index 0114459..eb1a737 100644 --- a/doc/DeterministicTesting.fr.md +++ b/doc/DeterministicTesting.fr.md @@ -21,7 +21,7 @@ DateTimeOffset before = DateTimeOffset.UtcNow; DomainError error = MakeError(); DateTimeOffset after = DateTimeOffset.UtcNow; -Assert.True(error.OccurredAt >= before && error.OccurredAt <= after); +Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); ``` `Clock.UseFixed(...)` rend l’instant attendu explicite : @@ -38,7 +38,7 @@ public void Une_erreur_enregistre_l_instant_fixe() .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "La commande 42 est introuvable.") .WithPublicMessage("La commande n’existe pas."); - Assert.Equal(instant, error.OccurredAt); + Check.That(error.OccurredAt).IsEqualTo(instant); } } ``` @@ -74,8 +74,8 @@ using (Clock.Use(new StepClock(start))) DomainError first = MakeError(); DomainError second = MakeError(); - Assert.Equal(start, first.OccurredAt); - Assert.Equal(start.AddSeconds(1), second.OccurredAt); + Check.That(first.OccurredAt).IsEqualTo(start); + Check.That(second.OccurredAt).IsEqualTo(start.AddSeconds(1)); } ``` @@ -95,7 +95,7 @@ public void Une_erreur_de_commande_absente_a_un_identifiant_stable() { Error error = orders.Find(missingOrderId).ShouldFail().Subject; - Assert.Equal(id, error.InstanceId); + Check.That(error.InstanceId).IsEqualTo(id); } } ``` @@ -120,8 +120,8 @@ using (InstanceIds.Use(SequentialIds())) Error first = MakeError(); Error second = MakeError(); - Assert.Equal("00000001-0000-0000-0000-000000000000", first.InstanceId.ToString()); - Assert.Equal("00000002-0000-0000-0000-000000000000", second.InstanceId.ToString()); + Check.That(first.InstanceId.ToString()).IsEqualTo("00000001-0000-0000-0000-000000000000"); + Check.That(second.InstanceId.ToString()).IsEqualTo("00000002-0000-0000-0000-000000000000"); } ``` @@ -145,8 +145,8 @@ public void Une_erreur_de_commande_absente_est_totalement_deterministe() .WithContextEntry("OrderId", missingOrderId) .Subject; - Assert.Equal(instant, error.OccurredAt); - Assert.Equal(id, error.InstanceId); + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); } } ``` diff --git a/doc/Testing.en.md b/doc/Testing.en.md index 74f89bd..01a71f3 100644 --- a/doc/Testing.en.md +++ b/doc/Testing.en.md @@ -16,9 +16,10 @@ The companion package **`FirstClassErrors.Testing`** provides framework-agnostic ```csharp using FirstClassErrors; using FirstClassErrors.Testing; +using NFluent; ``` -The examples use xUnit for test scaffolding, but the FirstClassErrors assertions do not depend on xUnit, NUnit, MSTest, or another assertion library. +The examples use xUnit for test scaffolding and NFluent for plain-value assertions — the convention this repository's own test suite follows — but the FirstClassErrors assertions do not depend on xUnit, NUnit, MSTest, NFluent, or another assertion library. The package belongs only in test projects and adds nothing to production dependencies. @@ -29,9 +30,9 @@ Without the testing package, a test usually checks the state and unwraps the res ```csharp Outcome outcome = checkout.Pay(order); -Assert.True(outcome.IsSuccess); +Check.That(outcome.IsSuccess).IsTrue(); Receipt receipt = outcome.GetResultOrThrow(); -Assert.Equal(order.Total, receipt.AmountCharged); +Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ``` `ShouldSucceed()` performs the state assertion and returns the carried value: @@ -42,7 +43,7 @@ public void Paying_a_valid_order_produces_a_receipt() { Receipt receipt = checkout.Pay(order).ShouldSucceed(); - Assert.Equal(order.Total, receipt.AmountCharged); + Check.That(receipt.AmountCharged).IsEqualTo(order.Total); } ``` @@ -93,8 +94,8 @@ Error error = outcome.ShouldFail() .WithCode("ORDER_NOT_FOUND") .Subject; -Assert.Empty(error.InnerErrors); -Assert.Equal(Transience.NonTransient, ((InfrastructureError)error).Transience); +Check.That(error.InnerErrors).IsEmpty(); +Check.That(((InfrastructureError)error).Transience).IsEqualTo(Transience.NonTransient); ``` Use `Subject` for targeted assertions, not to immediately rebuild all the manual plumbing that the fluent API removed. @@ -140,8 +141,8 @@ public void Looking_up_a_missing_order_returns_the_expected_error() .WithContextEntry("OrderId", missingOrderId) .Subject; - Assert.IsType(error); - Assert.Empty(error.InnerErrors); + Check.That(error).IsInstanceOf(); + Check.That(error.InnerErrors).IsEmpty(); } ``` diff --git a/doc/Testing.fr.md b/doc/Testing.fr.md index 426aaa4..7e7c4f3 100644 --- a/doc/Testing.fr.md +++ b/doc/Testing.fr.md @@ -16,9 +16,10 @@ Le package compagnon **`FirstClassErrors.Testing`** fournit des assertions fluen ```csharp using FirstClassErrors; using FirstClassErrors.Testing; +using NFluent; ``` -Les exemples utilisent xUnit pour la structure des tests, mais les assertions FirstClassErrors ne dĂ©pendent ni de xUnit, ni de NUnit, ni de MSTest, ni d’une autre bibliothĂšque d’assertions. +Les exemples utilisent xUnit pour la structure des tests et NFluent pour les assertions sur les valeurs simples — la convention suivie par la suite de tests de ce dĂ©pĂŽt — mais les assertions FirstClassErrors ne dĂ©pendent ni de xUnit, ni de NUnit, ni de MSTest, ni de NFluent, ni d’une autre bibliothĂšque d’assertions. Le package doit rester rĂ©servĂ© aux projets de test et n’ajoute aucune dĂ©pendance en production. @@ -29,9 +30,9 @@ Sans le package de test, un test vĂ©rifie gĂ©nĂ©ralement l’état puis rĂ©cupĂš ```csharp Outcome outcome = checkout.Pay(order); -Assert.True(outcome.IsSuccess); +Check.That(outcome.IsSuccess).IsTrue(); Receipt receipt = outcome.GetResultOrThrow(); -Assert.Equal(order.Total, receipt.AmountCharged); +Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ``` `ShouldSucceed()` effectue l’assertion d’état et retourne la valeur portĂ©e : @@ -42,7 +43,7 @@ public void Payer_une_commande_valide_produit_un_recu() { Receipt receipt = checkout.Pay(order).ShouldSucceed(); - Assert.Equal(order.Total, receipt.AmountCharged); + Check.That(receipt.AmountCharged).IsEqualTo(order.Total); } ``` @@ -93,8 +94,8 @@ Error error = outcome.ShouldFail() .WithCode("ORDER_NOT_FOUND") .Subject; -Assert.Empty(error.InnerErrors); -Assert.Equal(Transience.NonTransient, ((InfrastructureError)error).Transience); +Check.That(error.InnerErrors).IsEmpty(); +Check.That(((InfrastructureError)error).Transience).IsEqualTo(Transience.NonTransient); ``` Utilisez `Subject` pour des assertions ciblĂ©es, pas pour reconstruire immĂ©diatement toute la tuyauterie manuelle que l’API fluente vient de supprimer. @@ -140,8 +141,8 @@ public void Chercher_une_commande_absente_retourne_l_erreur_attendue() .WithContextEntry("OrderId", missingOrderId) .Subject; - Assert.IsType(error); - Assert.Empty(error.InnerErrors); + Check.That(error).IsInstanceOf(); + Check.That(error.InnerErrors).IsEmpty(); } ``` From cc1138574b8b812deddfcd1ca4690f1435bfa1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:23:56 +0000 Subject: [PATCH 11/13] docs(testing): sharpen the deterministic-tests guide Address review feedback on the Deterministic Error Tests page: - Rename "Freeze both values for a snapshot" to "Freeze OccurredAt and InstanceId together": the section asserts two values, it does not produce a snapshot. Add an honest note that a snapshot library like Verify already scrubs volatile Guid/DateTime values to stable placeholders on its own, so freezing is for exact-value assertions and hand-written snapshots, not a prerequisite for every snapshot. - Add a shared-setup block defining MakeError(), start, orders, and missingOrderId so each snippet is self-contained, and explain why some examples show DomainError and others Error (the factory returns the concrete DomainError; ShouldFail().Subject exposes the base Error) rather than leaving the type switch unexplained. - Type every MakeError() result as DomainError consistently. Left the real-clock check as a boolean Check.That(...).IsTrue(): NFluent 3.1.0's IsAfterOrEqualTo/IsBeforeOrEqualTo bind to ICheck, but Error.OccurredAt is a DateTimeOffset, so the chained form does not compile here (verified against the assembly). --- doc/DeterministicTesting.en.md | 34 ++++++++++++++++++++++++++-------- doc/DeterministicTesting.fr.md | 34 ++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md index 813dad6..490c35d 100644 --- a/doc/DeterministicTesting.en.md +++ b/doc/DeterministicTesting.en.md @@ -8,10 +8,30 @@ Every error occurrence records two values that should vary in production: - `OccurredAt`, the UTC instant at which the error was created; - `InstanceId`, a unique identifier for that occurrence. -Those values improve observability, but they make whole-object assertions and snapshots (tests comparing a serialized object against an approved reference file) unstable. `FirstClassErrors.Testing` provides scoped overrides for tests that deliberately need deterministic values. +Those values improve observability, but they make whole-object assertions and snapshots (tests comparing a serialized object against an approved reference file) unstable. `FirstClassErrors.Testing` lets you temporarily replace the two generators behind them — the clock and the instance-id source — only within the scope of a test that deliberately needs deterministic values. For fluent assertions on outcomes and errors, start with [Testing Outcomes and Errors](Testing.en.md). +## Shared setup for the examples + +Every example below tells the same small story — looking up a missing order — through one factory and two stubs, so each snippet stays short: + +```csharp +// The documented error every example reports. +private static DomainError MakeError() => + DomainError + .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "Order 42 was not found.") + .WithPublicMessage("The order does not exist."); + +private static readonly DateTimeOffset start = new(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + +// A repository whose lookup fails for missingOrderId, returning a failed Outcome. +private readonly IOrderRepository orders = /* test double */; +private readonly OrderId missingOrderId = /* an id with no match */; +``` + +`MakeError()` returns the concrete `DomainError`. When an example instead pulls the error out of a failed outcome with `ShouldFail().Subject`, it comes back typed as the base `Error` — that is why some snippets below say `DomainError` and others `Error`. It is the same object seen at two levels of its type hierarchy, not two interchangeable types. + ## Freeze `OccurredAt` Testing against the real clock usually requires an imprecise time window: @@ -34,9 +54,7 @@ public void An_error_records_the_fixed_occurrence_time() using (Clock.UseFixed(instant)) { - DomainError error = DomainError - .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "Order 42 was not found.") - .WithPublicMessage("The order does not exist."); + DomainError error = MakeError(); Check.That(error.OccurredAt).IsEqualTo(instant); } @@ -117,8 +135,8 @@ static Func SequentialIds() ```csharp using (InstanceIds.Use(SequentialIds())) { - Error first = MakeError(); - Error second = MakeError(); + DomainError first = MakeError(); + DomainError second = MakeError(); Check.That(first.InstanceId.ToString()).IsEqualTo("00000001-0000-0000-0000-000000000000"); Check.That(second.InstanceId.ToString()).IsEqualTo("00000002-0000-0000-0000-000000000000"); @@ -127,7 +145,7 @@ using (InstanceIds.Use(SequentialIds())) Prefer identifiers that are visibly synthetic so they cannot be confused with production values. -## Freeze both values for a snapshot +## Freeze `OccurredAt` and `InstanceId` together ```csharp [Fact] @@ -151,7 +169,7 @@ public void A_missing_order_error_is_fully_deterministic() } ``` -This is useful when serializing or snapshotting the complete error. If the test only cares about the error code or context, do not freeze unrelated fields merely because the helpers exist. +Freeze both when a single test asserts both occurrence values, or when it feeds the whole error to a hand-written snapshot compared against a fixed reference string. A snapshot library such as [Verify](https://github.com/VerifyTests/Verify) is a different case: it already scrubs volatile `Guid` and `DateTime` values to stable placeholders (`Guid_1`, `DateTimeOffset_1`) on its own, so there you usually leave them alone and freeze only when the snapshot must show the exact value. Either way, if a test only cares about the error code or context, do not freeze unrelated fields merely because the helpers exist. ## Scope and parallel tests diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md index eb1a737..12b5ef5 100644 --- a/doc/DeterministicTesting.fr.md +++ b/doc/DeterministicTesting.fr.md @@ -8,10 +8,30 @@ Chaque occurrence d’erreur enregistre deux valeurs qui doivent varier en produ - `OccurredAt`, l’instant UTC de crĂ©ation de l’erreur ; - `InstanceId`, un identifiant unique de cette occurrence. -Ces valeurs amĂ©liorent l’observabilitĂ©, mais rendent instables les assertions sur l’objet complet et les snapshots (tests qui comparent un objet sĂ©rialisĂ© Ă  un fichier de rĂ©fĂ©rence approuvĂ©). `FirstClassErrors.Testing` fournit des overrides bornĂ©s pour les tests qui ont rĂ©ellement besoin de valeurs dĂ©terministes. +Ces valeurs amĂ©liorent l’observabilitĂ©, mais rendent instables les assertions sur l’objet complet et les snapshots (tests qui comparent un objet sĂ©rialisĂ© Ă  un fichier de rĂ©fĂ©rence approuvĂ©). `FirstClassErrors.Testing` permet de remplacer temporairement les deux gĂ©nĂ©rateurs qui les produisent — l’horloge et la source d’identifiants — uniquement dans la portĂ©e d’un test qui a dĂ©libĂ©rĂ©ment besoin de valeurs dĂ©terministes. Pour les assertions fluentes sur les outcomes et les erreurs, commencez par [Tester les outcomes et les erreurs](Testing.fr.md). +## Setup commun aux exemples + +Chaque exemple ci-dessous raconte la mĂȘme petite histoire — la recherche d’une commande absente — via une factory et deux stubs, afin que chaque extrait reste court : + +```csharp +// L’erreur documentĂ©e que chaque exemple rapporte. +private static DomainError MakeError() => + DomainError + .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "La commande 42 est introuvable.") + .WithPublicMessage("La commande n’existe pas."); + +private static readonly DateTimeOffset start = new(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); + +// Un repository dont la recherche Ă©choue pour missingOrderId, retournant un Outcome en Ă©chec. +private readonly IOrderRepository orders = /* test double */; +private readonly OrderId missingOrderId = /* un id sans correspondance */; +``` + +`MakeError()` retourne le `DomainError` concret. Lorsqu’un exemple rĂ©cupĂšre plutĂŽt l’erreur depuis un outcome en Ă©chec via `ShouldFail().Subject`, elle revient typĂ©e comme l’`Error` de base — c’est pourquoi certains extraits ci-dessous montrent `DomainError` et d’autres `Error`. C’est le mĂȘme objet vu Ă  deux niveaux de sa hiĂ©rarchie de types, pas deux types interchangeables. + ## Figer `OccurredAt` Tester avec l’horloge rĂ©elle impose gĂ©nĂ©ralement une fenĂȘtre de temps imprĂ©cise : @@ -34,9 +54,7 @@ public void Une_erreur_enregistre_l_instant_fixe() using (Clock.UseFixed(instant)) { - DomainError error = DomainError - .Create(ErrorCode.Create("ORDER_NOT_FOUND"), "La commande 42 est introuvable.") - .WithPublicMessage("La commande n’existe pas."); + DomainError error = MakeError(); Check.That(error.OccurredAt).IsEqualTo(instant); } @@ -117,8 +135,8 @@ static Func SequentialIds() ```csharp using (InstanceIds.Use(SequentialIds())) { - Error first = MakeError(); - Error second = MakeError(); + DomainError first = MakeError(); + DomainError second = MakeError(); Check.That(first.InstanceId.ToString()).IsEqualTo("00000001-0000-0000-0000-000000000000"); Check.That(second.InstanceId.ToString()).IsEqualTo("00000002-0000-0000-0000-000000000000"); @@ -127,7 +145,7 @@ using (InstanceIds.Use(SequentialIds())) PrĂ©fĂ©rez des identifiants visiblement synthĂ©tiques afin qu’ils ne puissent pas ĂȘtre confondus avec des valeurs de production. -## Figer les deux valeurs pour un snapshot +## Figer simultanĂ©ment `OccurredAt` et `InstanceId` ```csharp [Fact] @@ -151,7 +169,7 @@ public void Une_erreur_de_commande_absente_est_totalement_deterministe() } ``` -Cette approche est utile pour sĂ©rialiser ou snapshotter l’erreur complĂšte. Si le test ne porte que sur le code ou le contexte, ne figez pas des champs sans rapport simplement parce que les helpers existent. +Figez les deux lorsqu’un mĂȘme test vĂ©rifie les deux valeurs d’occurrence, ou lorsqu’il transmet l’erreur complĂšte Ă  un snapshot fait main comparĂ© Ă  une chaĂźne de rĂ©fĂ©rence fixe. Une bibliothĂšque de snapshot comme [Verify](https://github.com/VerifyTests/Verify) est un cas diffĂ©rent : elle scrubbe dĂ©jĂ  d’elle-mĂȘme les `Guid` et `DateTime` volatils en placeholders stables (`Guid_1`, `DateTimeOffset_1`), donc vous les laissez gĂ©nĂ©ralement tels quels et ne figez que lorsque le snapshot doit montrer la valeur exacte. Dans tous les cas, si un test ne porte que sur le code d’erreur ou le contexte, ne figez pas des champs sans rapport simplement parce que les helpers existent. ## PortĂ©e et tests parallĂšles From 6888ab5d309a0e467c55402e1158ab7e96914102 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:35:02 +0000 Subject: [PATCH 12/13] docs(testing): apply K&R brace style to C# examples Match the repository code style (FirstClassErrors.sln.DotSettings: TYPE_DECLARATION_BRACES, INVOCABLE_DECLARATION_BRACES, ACCESSOR_*, and OTHER_BRACES are all END_OF_LINE) in the testing and logging guides, which were written in Allman style. - Move every opening brace to the end of its declaration/statement line (methods, classes, property accessors, if, catch, object initializers). - Give each `using` statement its own braces instead of stacking two headers over one shared block, so the nesting is explicit. JSON payload blocks are left in their own brace style; only C# examples are affected. --- doc/DeterministicTesting.en.md | 53 ++++++++++++++-------------------- doc/DeterministicTesting.fr.md | 53 ++++++++++++++-------------------- doc/LoggingIntegration.en.md | 12 +++----- doc/LoggingIntegration.fr.md | 12 +++----- doc/Testing.en.md | 9 ++---- doc/Testing.fr.md | 9 ++---- 6 files changed, 56 insertions(+), 92 deletions(-) diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md index 490c35d..efad4b8 100644 --- a/doc/DeterministicTesting.en.md +++ b/doc/DeterministicTesting.en.md @@ -48,12 +48,10 @@ Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); ```csharp [Fact] -public void An_error_records_the_fixed_occurrence_time() -{ +public void An_error_records_the_fixed_occurrence_time() { var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); - using (Clock.UseFixed(instant)) - { + using (Clock.UseFixed(instant)) { DomainError error = MakeError(); Check.That(error.OccurredAt).IsEqualTo(instant); @@ -68,16 +66,13 @@ The override applies only inside the `using` scope. Disposing the scope restores When a test needs several controlled instants, implement `IClock` and pass it to `Clock.Use(...)`: ```csharp -sealed class StepClock : IClock -{ +sealed class StepClock : IClock { private DateTimeOffset _now; public StepClock(DateTimeOffset start) => _now = start; - public DateTimeOffset UtcNow - { - get - { + public DateTimeOffset UtcNow { + get { DateTimeOffset current = _now; _now = _now.AddSeconds(1); return current; @@ -87,8 +82,7 @@ sealed class StepClock : IClock ``` ```csharp -using (Clock.Use(new StepClock(start))) -{ +using (Clock.Use(new StepClock(start))) { DomainError first = MakeError(); DomainError second = MakeError(); @@ -105,12 +99,10 @@ A random `Guid` is correct in production but unstable in a snapshot: ```csharp [Fact] -public void A_missing_order_error_has_a_stable_instance_id() -{ +public void A_missing_order_error_has_a_stable_instance_id() { var id = new Guid("11111111-1111-1111-1111-111111111111"); - using (InstanceIds.UseFixed(id)) - { + using (InstanceIds.UseFixed(id)) { Error error = orders.Find(missingOrderId).ShouldFail().Subject; Check.That(error.InstanceId).IsEqualTo(id); @@ -125,16 +117,14 @@ As with the clock, the override ends when the scope is disposed. When one test creates several errors, provide a deterministic source: ```csharp -static Func SequentialIds() -{ +static Func SequentialIds() { int value = 0; return () => new Guid(++value, 0, 0, new byte[8]); } ``` ```csharp -using (InstanceIds.Use(SequentialIds())) -{ +using (InstanceIds.Use(SequentialIds())) { DomainError first = MakeError(); DomainError second = MakeError(); @@ -149,22 +139,21 @@ Prefer identifiers that are visibly synthetic so they cannot be confused with pr ```csharp [Fact] -public void A_missing_order_error_is_fully_deterministic() -{ +public void A_missing_order_error_is_fully_deterministic() { var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); var id = new Guid("11111111-1111-1111-1111-111111111111"); - using (Clock.UseFixed(instant)) - using (InstanceIds.UseFixed(id)) - { - Error error = orders.Find(missingOrderId) - .ShouldFail() - .WithCode("ORDER_NOT_FOUND") - .WithContextEntry("OrderId", missingOrderId) - .Subject; + using (Clock.UseFixed(instant)) { + using (InstanceIds.UseFixed(id)) { + Error error = orders.Find(missingOrderId) + .ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithContextEntry("OrderId", missingOrderId) + .Subject; - Check.That(error.OccurredAt).IsEqualTo(instant); - Check.That(error.InstanceId).IsEqualTo(id); + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); + } } } ``` diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md index 12b5ef5..562ba5a 100644 --- a/doc/DeterministicTesting.fr.md +++ b/doc/DeterministicTesting.fr.md @@ -48,12 +48,10 @@ Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); ```csharp [Fact] -public void Une_erreur_enregistre_l_instant_fixe() -{ +public void Une_erreur_enregistre_l_instant_fixe() { var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); - using (Clock.UseFixed(instant)) - { + using (Clock.UseFixed(instant)) { DomainError error = MakeError(); Check.That(error.OccurredAt).IsEqualTo(instant); @@ -68,16 +66,13 @@ L’override ne s’applique qu’à l’intĂ©rieur de la portĂ©e `using`. La fi Lorsqu’un test nĂ©cessite plusieurs instants contrĂŽlĂ©s, implĂ©mentez `IClock` et passez-la Ă  `Clock.Use(...)` : ```csharp -sealed class StepClock : IClock -{ +sealed class StepClock : IClock { private DateTimeOffset _now; public StepClock(DateTimeOffset start) => _now = start; - public DateTimeOffset UtcNow - { - get - { + public DateTimeOffset UtcNow { + get { DateTimeOffset current = _now; _now = _now.AddSeconds(1); return current; @@ -87,8 +82,7 @@ sealed class StepClock : IClock ``` ```csharp -using (Clock.Use(new StepClock(start))) -{ +using (Clock.Use(new StepClock(start))) { DomainError first = MakeError(); DomainError second = MakeError(); @@ -105,12 +99,10 @@ Un `Guid` alĂ©atoire est correct en production, mais instable dans un snapshot : ```csharp [Fact] -public void Une_erreur_de_commande_absente_a_un_identifiant_stable() -{ +public void Une_erreur_de_commande_absente_a_un_identifiant_stable() { var id = new Guid("11111111-1111-1111-1111-111111111111"); - using (InstanceIds.UseFixed(id)) - { + using (InstanceIds.UseFixed(id)) { Error error = orders.Find(missingOrderId).ShouldFail().Subject; Check.That(error.InstanceId).IsEqualTo(id); @@ -125,16 +117,14 @@ Comme pour l’horloge, l’override prend fin lorsque la portĂ©e est disposĂ©e. Lorsqu’un test crĂ©e plusieurs erreurs, fournissez une source dĂ©terministe : ```csharp -static Func SequentialIds() -{ +static Func SequentialIds() { int value = 0; return () => new Guid(++value, 0, 0, new byte[8]); } ``` ```csharp -using (InstanceIds.Use(SequentialIds())) -{ +using (InstanceIds.Use(SequentialIds())) { DomainError first = MakeError(); DomainError second = MakeError(); @@ -149,22 +139,21 @@ PrĂ©fĂ©rez des identifiants visiblement synthĂ©tiques afin qu’ils ne puissent ```csharp [Fact] -public void Une_erreur_de_commande_absente_est_totalement_deterministe() -{ +public void Une_erreur_de_commande_absente_est_totalement_deterministe() { var instant = new DateTimeOffset(2026, 7, 8, 10, 30, 0, TimeSpan.Zero); var id = new Guid("11111111-1111-1111-1111-111111111111"); - using (Clock.UseFixed(instant)) - using (InstanceIds.UseFixed(id)) - { - Error error = orders.Find(missingOrderId) - .ShouldFail() - .WithCode("ORDER_NOT_FOUND") - .WithContextEntry("OrderId", missingOrderId) - .Subject; + using (Clock.UseFixed(instant)) { + using (InstanceIds.UseFixed(id)) { + Error error = orders.Find(missingOrderId) + .ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithContextEntry("OrderId", missingOrderId) + .Subject; - Check.That(error.OccurredAt).IsEqualTo(instant); - Check.That(error.InstanceId).IsEqualTo(id); + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); + } } } ``` diff --git a/doc/LoggingIntegration.en.md b/doc/LoggingIntegration.en.md index 20860b4..e84e757 100644 --- a/doc/LoggingIntegration.en.md +++ b/doc/LoggingIntegration.en.md @@ -75,8 +75,7 @@ The exact JSON schema belongs to the application or logging adapter. The importa A `DiagnosableException` — the exception type that `error.ToException()` produces — exposes its semantic model through `.Error`: ```csharp -catch (DiagnosableException exception) -{ +catch (DiagnosableException exception) { Error error = exception.Error; logger.LogError( @@ -123,10 +122,8 @@ exception.Error.InnerErrors A logging adapter must traverse that collection explicitly: ```csharp -static object ToLogModel(Error error) -{ - return new - { +static object ToLogModel(Error error) { + return new { Code = error.Code.ToString(), error.InstanceId, error.OccurredAt, @@ -197,8 +194,7 @@ An `Outcome` failure may never become an exception. Log its `Error` directly whe ```csharp Outcome outcome = checkout.Pay(order); -if (outcome.IsFailure) -{ +if (outcome.IsFailure) { logger.LogWarning( "Checkout failed with {ErrorCode} ({ErrorInstanceId})", outcome.Error!.Code, diff --git a/doc/LoggingIntegration.fr.md b/doc/LoggingIntegration.fr.md index 87283c8..02415ed 100644 --- a/doc/LoggingIntegration.fr.md +++ b/doc/LoggingIntegration.fr.md @@ -75,8 +75,7 @@ Le schĂ©ma JSON exact appartient Ă  l’application ou Ă  l’adapter de logging Une `DiagnosableException` — le type d’exception produit par `error.ToException()` — expose son modĂšle sĂ©mantique via `.Error` : ```csharp -catch (DiagnosableException exception) -{ +catch (DiagnosableException exception) { Error error = exception.Error; logger.LogError( @@ -123,10 +122,8 @@ exception.Error.InnerErrors Un adapter de logging doit parcourir explicitement cette collection : ```csharp -static object ToLogModel(Error error) -{ - return new - { +static object ToLogModel(Error error) { + return new { Code = error.Code.ToString(), error.InstanceId, error.OccurredAt, @@ -197,8 +194,7 @@ Un Ă©chec d’`Outcome` peut ne jamais devenir une exception. Loggez directement ```csharp Outcome outcome = checkout.Pay(order); -if (outcome.IsFailure) -{ +if (outcome.IsFailure) { logger.LogWarning( "Checkout failed with {ErrorCode} ({ErrorInstanceId})", outcome.Error!.Code, diff --git a/doc/Testing.en.md b/doc/Testing.en.md index 01a71f3..f6b4b18 100644 --- a/doc/Testing.en.md +++ b/doc/Testing.en.md @@ -39,8 +39,7 @@ Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ```csharp [Fact] -public void Paying_a_valid_order_produces_a_receipt() -{ +public void Paying_a_valid_order_produces_a_receipt() { Receipt receipt = checkout.Pay(order).ShouldSucceed(); Check.That(receipt.AmountCharged).IsEqualTo(order.Total); @@ -59,8 +58,7 @@ inventory.Reserve(sku).ShouldSucceed(); ```csharp [Fact] -public void Declining_a_payment_reports_a_diagnosable_error() -{ +public void Declining_a_payment_reports_a_diagnosable_error() { checkout.Pay(declinedCard) .ShouldFail() .WithCode("PAYMENT_DECLINED") @@ -131,8 +129,7 @@ Avoid coupling every test to incidental prose or timestamps unless those values ```csharp [Fact] -public void Looking_up_a_missing_order_returns_the_expected_error() -{ +public void Looking_up_a_missing_order_returns_the_expected_error() { Outcome outcome = orders.Find(missingOrderId); Error error = outcome.ShouldFail() diff --git a/doc/Testing.fr.md b/doc/Testing.fr.md index 7e7c4f3..5e7a0f5 100644 --- a/doc/Testing.fr.md +++ b/doc/Testing.fr.md @@ -39,8 +39,7 @@ Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ```csharp [Fact] -public void Payer_une_commande_valide_produit_un_recu() -{ +public void Payer_une_commande_valide_produit_un_recu() { Receipt receipt = checkout.Pay(order).ShouldSucceed(); Check.That(receipt.AmountCharged).IsEqualTo(order.Total); @@ -59,8 +58,7 @@ inventory.Reserve(sku).ShouldSucceed(); ```csharp [Fact] -public void Un_paiement_refuse_remonte_une_erreur_diagnosable() -{ +public void Un_paiement_refuse_remonte_une_erreur_diagnosable() { checkout.Pay(declinedCard) .ShouldFail() .WithCode("PAYMENT_DECLINED") @@ -131,8 +129,7 @@ PrivilĂ©giez les assertions sur le comportement stable de l’erreur : ```csharp [Fact] -public void Chercher_une_commande_absente_retourne_l_erreur_attendue() -{ +public void Chercher_une_commande_absente_retourne_l_erreur_attendue() { Outcome outcome = orders.Find(missingOrderId); Error error = outcome.ShouldFail() From c36a228e740e7eaf0c53345e5b8be593fb7b3b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:39:31 +0000 Subject: [PATCH 13/13] docs(testing): trim snapshot section and clarify parallel-test scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce "Freeze OccurredAt and InstanceId together" to the example alone: the code shows the pattern, the surrounding prose added no guidance a reader needed at that point. - Rewrite "Scope and parallel tests" in plain language instead of an abstract bullet list of properties. State the concrete guarantee — an override follows the test's own execution flow (an AsyncLocal), so a value frozen in one test never affects another test running in parallel — and the practical consequence: keep the using tight. --- doc/DeterministicTesting.en.md | 12 +++--------- doc/DeterministicTesting.fr.md | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md index efad4b8..836d238 100644 --- a/doc/DeterministicTesting.en.md +++ b/doc/DeterministicTesting.en.md @@ -158,19 +158,13 @@ public void A_missing_order_error_is_fully_deterministic() { } ``` -Freeze both when a single test asserts both occurrence values, or when it feeds the whole error to a hand-written snapshot compared against a fixed reference string. A snapshot library such as [Verify](https://github.com/VerifyTests/Verify) is a different case: it already scrubs volatile `Guid` and `DateTime` values to stable placeholders (`Guid_1`, `DateTimeOffset_1`) on its own, so there you usually leave them alone and freeze only when the snapshot must show the exact value. Either way, if a test only cares about the error code or context, do not freeze unrelated fields merely because the helpers exist. - ## Scope and parallel tests -The overrides are: +An override takes effect when its `using` opens and is undone when the `using` is disposed. Outside that block, the clock and instance ids are back to their real behavior. -- disposable and intended for `using` scopes; -- local to the current execution context; -- restored when the scope ends; -- inactive outside the scope; -- designed not to leak into unrelated parallel tests. +The override is not a shared global: it follows the test's own execution flow (internally it is stored in an `AsyncLocal`). So a value frozen inside one test is invisible to any code running outside that test — including other tests running at the same time. Two tests can each freeze the clock to a different instant and run in parallel without disturbing each other. -Keep the scope as narrow as possible around the code that creates the errors. +Because of that, keep the `using` as tight as possible around the code that creates the errors: the frozen values then cover exactly what the test asserts, and nothing else. ## Common mistakes diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md index 562ba5a..f2081c3 100644 --- a/doc/DeterministicTesting.fr.md +++ b/doc/DeterministicTesting.fr.md @@ -158,19 +158,13 @@ public void Une_erreur_de_commande_absente_est_totalement_deterministe() { } ``` -Figez les deux lorsqu’un mĂȘme test vĂ©rifie les deux valeurs d’occurrence, ou lorsqu’il transmet l’erreur complĂšte Ă  un snapshot fait main comparĂ© Ă  une chaĂźne de rĂ©fĂ©rence fixe. Une bibliothĂšque de snapshot comme [Verify](https://github.com/VerifyTests/Verify) est un cas diffĂ©rent : elle scrubbe dĂ©jĂ  d’elle-mĂȘme les `Guid` et `DateTime` volatils en placeholders stables (`Guid_1`, `DateTimeOffset_1`), donc vous les laissez gĂ©nĂ©ralement tels quels et ne figez que lorsque le snapshot doit montrer la valeur exacte. Dans tous les cas, si un test ne porte que sur le code d’erreur ou le contexte, ne figez pas des champs sans rapport simplement parce que les helpers existent. - ## PortĂ©e et tests parallĂšles -Les overrides sont : +Un override prend effet Ă  l’ouverture de son `using` et est dĂ©fait Ă  sa libĂ©ration. En dehors de ce bloc, l’horloge et les identifiants retrouvent leur comportement rĂ©el. -- jetables et prĂ©vus pour des portĂ©es `using` ; -- locaux au contexte d’exĂ©cution courant ; -- restaurĂ©s Ă  la fin de la portĂ©e ; -- inactifs en dehors de cette portĂ©e ; -- conçus pour ne pas fuiter vers des tests parallĂšles sans rapport. +L’override n’est pas un Ă©tat global partagĂ© : il suit le flux d’exĂ©cution du test lui-mĂȘme (en interne, il est stockĂ© dans un `AsyncLocal`). Une valeur figĂ©e dans un test est donc invisible pour tout code s’exĂ©cutant en dehors de ce test — y compris les autres tests s’exĂ©cutant en mĂȘme temps. Deux tests peuvent chacun figer l’horloge Ă  un instant diffĂ©rent et s’exĂ©cuter en parallĂšle sans se gĂȘner. -Gardez la portĂ©e aussi Ă©troite que possible autour du code qui crĂ©e les erreurs. +Pour cette raison, gardez le `using` aussi serrĂ© que possible autour du code qui crĂ©e les erreurs : les valeurs figĂ©es couvrent alors exactement ce que le test vĂ©rifie, et rien d’autre. ## Erreurs frĂ©quentes