diff --git a/doc/DeterministicTesting.en.md b/doc/DeterministicTesting.en.md new file mode 100644 index 0000000..836d238 --- /dev/null +++ b/doc/DeterministicTesting.en.md @@ -0,0 +1,205 @@ +# 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 (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: + +```csharp +DateTimeOffset before = DateTimeOffset.UtcNow; +DomainError error = MakeError(); +DateTimeOffset after = DateTimeOffset.UtcNow; + +Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); +``` + +`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 = MakeError(); + + Check.That(error.OccurredAt).IsEqualTo(instant); + } +} +``` + +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(); + + Check.That(first.OccurredAt).IsEqualTo(start); + Check.That(second.OccurredAt).IsEqualTo(start.AddSeconds(1)); +} +``` + +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; + + Check.That(error.InstanceId).IsEqualTo(id); + } +} +``` + +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())) { + 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"); +} +``` + +Prefer identifiers that are visibly synthetic so they cannot be confused with production values. + +## Freeze `OccurredAt` and `InstanceId` together + +```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; + + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); + } + } +} +``` + +## Scope and parallel tests + +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. + +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. + +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 + +### 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. + +--- + +
+← Testing Outcomes and Errors · ↑ Table of contents · Generating and Publishing the Catalog → +
+ +--- \ No newline at end of file diff --git a/doc/DeterministicTesting.fr.md b/doc/DeterministicTesting.fr.md new file mode 100644 index 0000000..f2081c3 --- /dev/null +++ b/doc/DeterministicTesting.fr.md @@ -0,0 +1,205 @@ +# 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 (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 : + +```csharp +DateTimeOffset before = DateTimeOffset.UtcNow; +DomainError error = MakeError(); +DateTimeOffset after = DateTimeOffset.UtcNow; + +Check.That(error.OccurredAt >= before && error.OccurredAt <= after).IsTrue(); +``` + +`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 = MakeError(); + + Check.That(error.OccurredAt).IsEqualTo(instant); + } +} +``` + +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(); + + Check.That(first.OccurredAt).IsEqualTo(start); + Check.That(second.OccurredAt).IsEqualTo(start.AddSeconds(1)); +} +``` + +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; + + Check.That(error.InstanceId).IsEqualTo(id); + } +} +``` + +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())) { + 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"); +} +``` + +PrĂ©fĂ©rez des identifiants visiblement synthĂ©tiques afin qu’ils ne puissent pas ĂȘtre confondus avec des valeurs de production. + +## Figer simultanĂ©ment `OccurredAt` et `InstanceId` + +```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; + + Check.That(error.OccurredAt).IsEqualTo(instant); + Check.That(error.InstanceId).IsEqualTo(id); + } + } +} +``` + +## PortĂ©e et tests parallĂšles + +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. + +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. + +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 + +### 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. + +--- + +
+← Tester les outcomes et les erreurs · ↑ Table des matiĂšres · GĂ©nĂ©rer et publier le catalogue → +
+ +--- \ No newline at end of file diff --git a/doc/LoggingIntegration.en.md b/doc/LoggingIntegration.en.md new file mode 100644 index 0000000..e84e757 --- /dev/null +++ b/doc/LoggingIntegration.en.md @@ -0,0 +1,256 @@ +# 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 (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. + +## 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` — the exception type that `error.ToException()` produces — 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 diff --git a/doc/LoggingIntegration.fr.md b/doc/LoggingIntegration.fr.md new file mode 100644 index 0000000..02415ed --- /dev/null +++ b/doc/LoggingIntegration.fr.md @@ -0,0 +1,254 @@ +# 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 (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. + +## 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` — le type d’exception produit par `error.ToException()` — 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 diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md index d76f122..b33108d 100644 --- a/doc/OperationalIntegration.en.md +++ b/doc/OperationalIntegration.en.md @@ -1,130 +1,219 @@ -# 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 — the last accepted snapshot of your error codes and context keys (see [Catalog Versioning](CatalogVersioning.en.md)). + +## Opt projects into generation + +Solution-level generation is opt-in. Add the marker directly to every `.csproj` that defines documented application errors: + +```xml + + true + +``` + +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. -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 +For ambiguous declarations, project discovery, and worker behavior, see [Architecture of the Documentation Pipeline](ArchitectureOfTheDocumentationPipeline.en.md). -Concretely: +## 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 +The workflow renders the catalog as HTML with `--layout split` (one page per error; see [The HTML Renderer](TheHtmlRenderer.en.md)): -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: +```yaml +name: error-documentation -```bash -fce catalog diff --solution MyApp.sln -``` +on: + pull_request: + push: + branches: [main] + +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: +```text +/errors/latest/ +/errors/releases/2.4.0/ +/errors/releases/2.3.1/ ``` -exception.HelpLink = "https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH" -``` -This makes production logs navigable: support can move directly from a log entry to the corresponding error 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 +``` -## đŸ§© 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 diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md index bcbfe9c..d2f9fd7 100644 --- a/doc/OperationalIntegration.fr.md +++ b/doc/OperationalIntegration.fr.md @@ -1,130 +1,219 @@ -# 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, puis 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 — le dernier snapshot acceptĂ© de vos codes d’erreur et clĂ©s de contexte (voir [Versionnage du catalogue](CatalogVersioning.fr.md)). + +## 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 + +``` + +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. -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 +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). -ConcrĂštement : +## 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 +Le workflow rend le catalogue en HTML avec `--layout split` (une page par erreur ; voir [Le renderer HTML](TheHtmlRenderer.fr.md)) : -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 : +```yaml +name: error-documentation -```bash -fce catalog diff --solution MyApp.sln -``` +on: + pull_request: + push: + branches: [main] + +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 : +```text +/errors/latest/ +/errors/releases/2.4.0/ +/errors/releases/2.3.1/ ``` -exception.HelpLink = "https://docs.mycompany/errors/AMOUNT_CURRENCY_MISMATCH" -``` -Les logs de production deviennent ainsi navigables : le support peut passer directement d’une entrĂ©e de log Ă  la documentation correspondante de l’erreur. +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 +``` -## đŸ§© 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 diff --git a/doc/Testing.en.md b/doc/Testing.en.md index 3ce29ba..f6b4b18 100644 --- a/doc/Testing.en.md +++ b/doc/Testing.en.md @@ -1,128 +1,106 @@ -# 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 - ``` ```csharp using FirstClassErrors; using FirstClassErrors.Testing; +using NFluent; ``` -> 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 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. -## ✅ 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()); +Check.That(outcome.IsSuccess).IsTrue(); +Receipt receipt = outcome.GetResultOrThrow(); +Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ``` -With the testing package, the intent comes first and the boilerplate disappears: - -```csharp -// 🙂 After -checkout.Pay(order) - .ShouldFail() - .WithCode("PAYMENT_DECLINED"); -``` - -### 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); +public void Paying_a_valid_order_produces_a_receipt() { + Receipt receipt = checkout.Pay(order).ShouldSucceed(); - Receipt receipt = outcome.ShouldSucceed(); - - Assert.Equal(order.Total, receipt.AmountCharged); + Check.That(receipt.AmountCharged).IsEqualTo(order.Total); } ``` -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"); +public void Declining_a_payment_reports_a_diagnosable_error() { + 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. -Need something the fluent surface doesn't cover? `Subject` gives you the raw -`Error` back: +## Access the underlying error + +`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); +Check.That(error.InnerErrors).IsEmpty(); +Check.That(((InfrastructureError)error).Transience).IsEqualTo(Transience.NonTransient); ``` -### Failure messages read well +Use `Subject` for targeted assertions, not to immediately rebuild all the manual plumbing that the fluent API removed. + +## Failure messages -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: +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 +110,63 @@ 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". ``` ---- +`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. -## 🕒 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() -{ - 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("This order does not exist."); - - Assert.Equal(instant, error.OccurredAt); - } -} -``` - -Need a clock you control across several reads (for example a clock that -advances)? Implement `IClock` and pass it to `Clock.Use(...)`: +public void Looking_up_a_missing_order_returns_the_expected_error() { + Outcome outcome = orders.Find(missingOrderId); -```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; - } - } -} + Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithShortMessage("The order does not exist.") + .WithContextEntry("OrderId", missingOrderId) + .Subject; -using (Clock.Use(new StepClock(start))) -{ - // errors created here get start, start + 1s, start + 2s, ... + Check.That(error).IsInstanceOf(); + Check.That(error.InnerErrors).IsEmpty(); } ``` -**How the scope behaves** - -* 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. +The test reads as a description of the failure contract rather than a sequence of nullable checks and casts. ---- +## Deterministic timestamps and instance ids -## 🔱 Freezing instance ids +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. -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. +See [Deterministic Error Tests](DeterministicTesting.en.md) for `Clock.UseFixed(...)`, `InstanceIds.UseFixed(...)`, custom sources, parallel-test behavior, and full-error snapshots. -Pin a single id: +## Review checklist -```csharp -[Fact] -public void A_not_found_error_snapshots_cleanly() -{ - var id = new Guid("11111111-1111-1111-1111-111111111111"); +Before approving an error test, verify that: - 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 diff --git a/doc/Testing.fr.md b/doc/Testing.fr.md index 505b606..5e7a0f5 100644 --- a/doc/Testing.fr.md +++ b/doc/Testing.fr.md @@ -1,302 +1,172 @@ -# 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 - ``` ```csharp using FirstClassErrors; using FirstClassErrors.Testing; +using NFluent; ``` -> 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 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. -## ✅ 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()); +Check.That(outcome.IsSuccess).IsTrue(); +Receipt receipt = outcome.GetResultOrThrow(); +Check.That(receipt.AmountCharged).IsEqualTo(order.Total); ``` -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"); -``` - -### 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); +public void Payer_une_commande_valide_produit_un_recu() { + Receipt receipt = checkout.Pay(order).ShouldSucceed(); - Receipt receipt = outcome.ShouldSucceed(); - - Assert.Equal(order.Total, receipt.AmountCharged); + Check.That(receipt.AmountCharged).IsEqualTo(order.Total); } ``` -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"); +public void Un_paiement_refuse_remonte_une_erreur_diagnosable() { + 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. -Besoin de quelque chose que la surface fluente ne couvre pas ? `Subject` vous -rend l'`Error` brute : +## AccĂ©der Ă  l’erreur sous-jacente + +`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); +Check.That(error.InnerErrors).IsEmpty(); +Check.That(((InfrastructureError)error).Transience).IsEqualTo(Transience.NonTransient); ``` -### 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. + +## Messages d’échec -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 : +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". ``` ---- +`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. -## 🕒 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() -{ - 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("Cette commande n'existe pas."); - - Assert.Equal(instant, error.OccurredAt); - } -} -``` - -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(...)` : +public void Chercher_une_commande_absente_retourne_l_erreur_attendue() { + Outcome outcome = orders.Find(missingOrderId); -```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; - } - } -} + Error error = outcome.ShouldFail() + .WithCode("ORDER_NOT_FOUND") + .WithShortMessage("La commande n’existe pas.") + .WithContextEntry("OrderId", missingOrderId) + .Subject; -using (Clock.Use(new StepClock(start))) -{ - // les erreurs créées ici obtiennent start, start + 1s, start + 2s, ... + Check.That(error).IsInstanceOf(); + Check.That(error.InnerErrors).IsEmpty(); } ``` -**Comportement de la portĂ©e** - -* 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. +Le test se lit comme une description du contrat d’échec plutĂŽt que comme une suite de vĂ©rifications nullable et de casts. ---- +## Horodatages et identifiants dĂ©terministes -## 🔱 Figer les identifiants d'instance +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. -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. +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. -Épingler un identifiant unique : +## Checklist de revue -```csharp -[Fact] -public void Une_erreur_not_found_se_snapshote_proprement() -{ - var id = new Guid("11111111-1111-1111-1111-111111111111"); +Avant d’approuver un test d’erreur, vĂ©rifiez que : - 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