@drunkcoding/dknet-implementation-skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/agents/dknet-architect.md +49 -0
- package/agents/dknet-bdd-engineer.md +62 -0
- package/agents/dknet-implementer.md +75 -0
- package/package.json +52 -0
- package/plugin.json +26 -0
- package/skills/README.md +47 -0
- package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
- package/skills/dknet-bdd-tests/SKILL.md +355 -0
- package/skills/dknet-bdd-tests/checklist.md +39 -0
- package/skills/dknet-crud/SKILL.md +483 -0
- package/skills/dknet-ddd-principles/SKILL.md +87 -0
- package/skills/dknet-docs/SKILL.md +296 -0
- package/skills/dknet-docs/checklist.md +58 -0
- package/skills/dknet-docs/templates/README-template.md +68 -0
- package/skills/dknet-docs/templates/api-reference-template.md +275 -0
- package/skills/dknet-docs/templates/architecture-template.md +166 -0
- package/skills/dknet-docs/templates/data-model-template.md +99 -0
- package/skills/dknet-docs/templates/events-template.md +155 -0
- package/skills/dknet-dto-mapping/SKILL.md +278 -0
- package/skills/dknet-efcore-config/SKILL.md +379 -0
- package/skills/dknet-endpoint/SKILL.md +458 -0
- package/skills/dknet-entity/SKILL.md +483 -0
- package/skills/dknet-feature/SKILL.md +139 -0
- package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
- package/skills/dknet-feature-remove/SKILL.md +131 -0
- package/skills/dknet-messaging-events/SKILL.md +395 -0
- package/skills/dknet-package-adoption/SKILL.md +252 -0
- package/skills/dknet-platform-config/SKILL.md +342 -0
- package/skills/dknet-project-structure/SKILL.md +148 -0
- package/skills/dknet-queries-specs/SKILL.md +330 -0
- package/skills/dknet-scaffold/SKILL.md +209 -0
- package/skills/dknet-unit-tests/SKILL.md +382 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-unit-tests
|
|
3
|
+
description: Write xUnit + Shouldly tests for a DKNet.Templates feature in Minimal.App.Tests — architecture/convention rules, pure functional tests (entity methods, validators, specs, mapping), and result-level integration tests against ApiFixture + IMessageBus (handler Result failures, EF persistence, domain events). Use after AppServices actions and endpoint config are ready. Covers only what xUnit owns; HTTP request/response and event-log scenarios belong in the `dknet-bdd-tests` skill. Invoke as `/dknet-unit-tests <Feature> <Entity> [mode=manual|auto]` to scaffold it for a feature.
|
|
4
|
+
metadata:
|
|
5
|
+
kind: workflow
|
|
6
|
+
arguments: "<Feature> <Entity> [mode=manual|auto]"
|
|
7
|
+
allowed-tools: Read, Grep, Glob, Edit, Write, Bash, Agent
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Usage: `/dknet-unit-tests <Feature> <Entity> [mode=manual|auto]`
|
|
11
|
+
|
|
12
|
+
# xUnit tests (Minimal.App.Tests)
|
|
13
|
+
|
|
14
|
+
## Both shipped suites are teaching material — business tests only
|
|
15
|
+
|
|
16
|
+
`Minimal.App.Tests` and `Minimal.App.BDDTests` ship inside every generated solution. A team reads them
|
|
17
|
+
to learn how tests are written here, so every test must be about the business domain: the `PurchaseOrder`
|
|
18
|
+
(manual) and `Product` (automated) samples — entity invariants, validators, specs, handler results, CRUD
|
|
19
|
+
over HTTP, domain events.
|
|
20
|
+
|
|
21
|
+
Never add a test for platform plumbing to either suite: logging/telemetry, host/startup wiring, security
|
|
22
|
+
middleware (CORS, HSTS, rate limits, JWT config), health/Swagger endpoints, `FeatureOptions`↔appsettings
|
|
23
|
+
key binding, or template/scaffolding shape. Those belong in this repository's own hygiene tests, not in a
|
|
24
|
+
consumer's shipped example — if you find yourself testing framework or ASP.NET behavior instead of the
|
|
25
|
+
sample's business rules, stop and delete the test.
|
|
26
|
+
|
|
27
|
+
## Test layering — where a test belongs
|
|
28
|
+
|
|
29
|
+
xUnit owns three things; BDD must not re-cover them:
|
|
30
|
+
|
|
31
|
+
1. **Architecture/convention** — `Architecture/*`: NetArchTest + reflection over the compiled assembly
|
|
32
|
+
(handler/validator classes are `internal sealed`, repo interfaces inherit `IRepository<T>`, DTOs never
|
|
33
|
+
expose a domain entity type). Cannot be expressed as an HTTP scenario; never port to BDD.
|
|
34
|
+
2. **Pure functional** — `Unit/*`: entity methods, validators, spec predicate filters, static data. No
|
|
35
|
+
host, no DB, no HTTP.
|
|
36
|
+
3. **Result-level integration** — `Integration/<Feature>/V1/*`: handler failures asserted on the
|
|
37
|
+
`IResult`/`IResultBase` object (not-found, missing acting user, "already cancelled", 409 precondition
|
|
38
|
+
codes) and EF model/schema shape. BDD's HTTP-status/response-body assertions are coarser and would lose
|
|
39
|
+
this intent — keep the `Result`-level assertion here even though the same rule is also proven over HTTP
|
|
40
|
+
in BDD.
|
|
41
|
+
|
|
42
|
+
BDD owns user-facing HTTP behavior (request → status → response body) and domain-event side effects
|
|
43
|
+
observed via log capture. Do not duplicate the same behavior in both suites — an HTTP-status check for a
|
|
44
|
+
rule already covered here belongs in BDD instead, not copied into both.
|
|
45
|
+
|
|
46
|
+
## Fixtures (`Minimal.App.TestSupport` + `Integration/Support`)
|
|
47
|
+
|
|
48
|
+
`TestApiFactoryBase(string? dbName) : WebApplicationFactory<Minimal.Api.Program>` (in
|
|
49
|
+
`Minimal.App.TestSupport`) is the shared host substitution both xUnit and BDD build on. It:
|
|
50
|
+
|
|
51
|
+
- Sets `FeatureManagement:RunDbMigrationWhenAppStart/EnableSwagger/EnableAzureAppConfig = false` and
|
|
52
|
+
`ConnectionStrings:AppDb = UseInMemory`.
|
|
53
|
+
- Swaps `CoreDbContext` for EF Core InMemory via `AddDbContextWithHook` (plain `AddDbContext` would drop
|
|
54
|
+
the DKNet events hook — `AddEvent`/`[RaisesEvent]` domain events would never publish).
|
|
55
|
+
- Replaces `IMembershipService` with `TestMembershipService`.
|
|
56
|
+
- Exposes `LogCapture` (a `TestLogCapture : ILoggerProvider` — captures every log line for assertions),
|
|
57
|
+
`CreateScope()`, and `ResetDatabaseAsync()` (drops + recreates the InMemory DB and clears `LogCapture`).
|
|
58
|
+
|
|
59
|
+
`Integration/Support/ApiFixture : TestApiFactoryBase, IAsyncLifetime` is the plain fixture — no auth, no
|
|
60
|
+
extra overrides:
|
|
61
|
+
|
|
62
|
+
```csharp
|
|
63
|
+
public sealed class ApiFixture : TestApiFactoryBase, IAsyncLifetime
|
|
64
|
+
{
|
|
65
|
+
public async Task InitializeAsync()
|
|
66
|
+
{
|
|
67
|
+
_ = CreateClient();
|
|
68
|
+
await ResetDatabaseAsync();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `IClassFixture<ApiFixture>` and call `fixture.ResetDatabaseAsync()` at the top of every `[Fact]` —
|
|
76
|
+
xUnit shares one fixture instance across all tests in the class, so each test resets state itself rather
|
|
77
|
+
than relying on fixture disposal.
|
|
78
|
+
|
|
79
|
+
**Two ways to exercise a feature:** through `IMessageBus` from `fixture.CreateScope()` — resolve
|
|
80
|
+
`IMessageBus` and (for seeding/assertion) `IRepositorySpec`, call `bus.Send(request)`, assert on the
|
|
81
|
+
returned `IResult<TDto>`/`IResultBase` — this is the default for result-level integration tests; or
|
|
82
|
+
through `HttpClient` (`fixture.CreateClient()`), only for a precondition branch with no BDD scenario of
|
|
83
|
+
its own (see `ProductPreconditionTests` below). Prefer BDD for anything HTTP-shaped.
|
|
84
|
+
|
|
85
|
+
**Auth fixture variants** (`Integration/Support/`), used only when a test needs the authenticated/authorized
|
|
86
|
+
path: `AuthOnApiFixture` flips `FeatureManagement:RequireAuthorization` on and swaps the JWT bearer scheme
|
|
87
|
+
for `TestAuthHandler` (fixed caller identity, no live token needed); `AuthOnFixedTenantApiFixture`,
|
|
88
|
+
`AuthOnMultiSubjectApiFixture`, `AuthOnNoNameClaimApiFixture`, `DemoAuthenticationOffApiFixture`,
|
|
89
|
+
`RequireAuthorizationPlusDemoApiFixture`, `VersioningOffApiFixture` cover narrower combinations — read the
|
|
90
|
+
closest one before writing a new fixture. `AuthOnApiFixture` sets its flags via
|
|
91
|
+
`Environment.SetEnvironmentVariable` in the constructor, not `AddFeatureOverrides` — `Program.cs` binds
|
|
92
|
+
`FeatureOptions` before the fixture's `ConfigureAppConfiguration` override is merged in, so only an
|
|
93
|
+
environment variable set before `WebApplication.CreateBuilder(args)` runs takes effect. Safe only because
|
|
94
|
+
`AssemblyInfo.cs` disables assembly-wide test parallelization.
|
|
95
|
+
|
|
96
|
+
Use `TestLogCapture` (via `fixture.LogCapture.Messages`) together with `Eventually.IsTrueAsync(...)` when
|
|
97
|
+
asserting a domain-event handler's side effect — the in-memory bus publishes with
|
|
98
|
+
`EnableBlockingPublish = false`, so a consumer's log line lands on a background task, not before the
|
|
99
|
+
command's response returns. Polling avoids a flaky race.
|
|
100
|
+
|
|
101
|
+
## Worked examples
|
|
102
|
+
|
|
103
|
+
**Result-level integration — happy path + not-found**
|
|
104
|
+
(`Integration/ManualSample/V1/PurchaseOrderActionsIntegrationTests.cs`):
|
|
105
|
+
|
|
106
|
+
```csharp
|
|
107
|
+
public sealed class PurchaseOrderActionsIntegrationTests(ApiFixture fixture) : IClassFixture<ApiFixture>
|
|
108
|
+
{
|
|
109
|
+
[Fact]
|
|
110
|
+
public async Task Create_ShouldPersistOrder_AndReturnMatchingDto()
|
|
111
|
+
{
|
|
112
|
+
await fixture.ResetDatabaseAsync();
|
|
113
|
+
using var scope = fixture.CreateScope();
|
|
114
|
+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
|
|
115
|
+
var repository = scope.ServiceProvider.GetRequiredService<IRepositorySpec>();
|
|
116
|
+
|
|
117
|
+
var result = await bus.Send(new CreatePurchaseOrderRequest
|
|
118
|
+
{
|
|
119
|
+
CustomerName = "Acme Pte Ltd", Amount = 250.00m, ByUser = "integration-test"
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
result.IsSuccess.ShouldBeTrue();
|
|
123
|
+
result.Value!.CustomerName.ShouldBe("Acme Pte Ltd");
|
|
124
|
+
|
|
125
|
+
var created = await repository.FirstOrDefaultAsync(
|
|
126
|
+
new SpecGetPurchaseOrder(byCustomerName: "Acme Pte Ltd"), CancellationToken.None);
|
|
127
|
+
created!.CreatedBy.ShouldBe("integration-test");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[Fact]
|
|
131
|
+
public async Task Update_ShouldFail_WhenOrderNotFound()
|
|
132
|
+
{
|
|
133
|
+
await fixture.ResetDatabaseAsync();
|
|
134
|
+
using var scope = fixture.CreateScope();
|
|
135
|
+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
|
|
136
|
+
|
|
137
|
+
var result = await bus.Send(new UpdatePurchaseOrderRequest { Id = Guid.NewGuid(), Amount = 50m, ByUser = "integration-test" });
|
|
138
|
+
|
|
139
|
+
result.IsFailed.ShouldBeTrue();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The same class also proves a guarded state transition by calling the handler twice — `Cancel` once
|
|
145
|
+
succeeds, a second `Cancel` on the same order fails with the `precondition.purchase-order-already-cancelled`
|
|
146
|
+
message (`PreconditionCodes`, `Minimal.AppServices/Share/PreconditionCodes.cs`), and every mutating action
|
|
147
|
+
fails when `ByUser` is left empty (the acting-user rule the manual mode enforces in the handler itself).
|
|
148
|
+
|
|
149
|
+
**Precondition branch reachable only over HTTP from xUnit**
|
|
150
|
+
(`Integration/AutomatedSample/V1/ProductPreconditionTests.cs`):
|
|
151
|
+
|
|
152
|
+
```csharp
|
|
153
|
+
public sealed class ProductPreconditionTests(AuthOnApiFixture fixture) : IClassFixture<AuthOnApiFixture>
|
|
154
|
+
{
|
|
155
|
+
[Fact]
|
|
156
|
+
public async Task DeletingAnUnknownProduct_PassesThePreconditionAndStillAnswers404()
|
|
157
|
+
{
|
|
158
|
+
await fixture.ResetDatabaseAsync();
|
|
159
|
+
var client = fixture.CreateClient();
|
|
160
|
+
|
|
161
|
+
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/v1/products/{Guid.NewGuid()}");
|
|
162
|
+
request.Headers.Add("X-Test-Scopes", "products.write");
|
|
163
|
+
using var response = await client.SendAsync(request);
|
|
164
|
+
|
|
165
|
+
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The comment on that class explains why: `DeleteProductRequestValidator` must let an unknown id pass its
|
|
171
|
+
precondition check, or the route's own 404 would be hidden behind a 409. BDD covers the "still for sale"
|
|
172
|
+
(409) and "discontinued" (204) branches of the same rule end to end; this is the one branch — the target
|
|
173
|
+
not existing at all — with no user-facing scenario of its own. Reach for `HttpClient` in xUnit only for a
|
|
174
|
+
gap shaped like this one, not as a default way to test a route.
|
|
175
|
+
|
|
176
|
+
**Pure functional — validators** (`Unit/ManualSample/PurchaseOrderValidatorsTests.cs`): construct the
|
|
177
|
+
`AbstractValidator<TRequest>` directly and call `.Validate(request)`, no host:
|
|
178
|
+
|
|
179
|
+
```csharp
|
|
180
|
+
private readonly CreatePurchaseOrderCommandValidator _createValidator = new();
|
|
181
|
+
|
|
182
|
+
[Fact]
|
|
183
|
+
public void CreateValidator_ShouldFail_WhenCustomerNameIsBlank()
|
|
184
|
+
{
|
|
185
|
+
var result = _createValidator.Validate(new CreatePurchaseOrderRequest { CustomerName = "", Amount = 10m });
|
|
186
|
+
result.IsValid.ShouldBeFalse();
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
That file also guards a real regression (DRK-714): `UpdateValidator_ShouldNotRejectEmptyId` — `Id` comes
|
|
191
|
+
from the route, not the body, so the validator must not fail on `Guid.Empty`; an unknown/empty id 404s
|
|
192
|
+
from the handler's spec lookup instead. Write the equivalent guard whenever a validator could accidentally
|
|
193
|
+
reject a route-bound id.
|
|
194
|
+
|
|
195
|
+
**Pure functional — specs** (`Unit/ManualSample/SpecGetPurchaseOrderTests.cs`): compile the spec's
|
|
196
|
+
`FilterQuery` and run it against in-memory instances, no DB:
|
|
197
|
+
|
|
198
|
+
```csharp
|
|
199
|
+
[Fact]
|
|
200
|
+
public void NoFilter_ShouldMatchEveryOrder()
|
|
201
|
+
{
|
|
202
|
+
// Regression guard: an unstarted predicate builder compiles to WHERE FALSE (empty list page).
|
|
203
|
+
var predicate = new SpecGetPurchaseOrder().FilterQuery!.Compile();
|
|
204
|
+
predicate(MakeOrder("Acme")).ShouldBeTrue();
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Pure functional — entity methods, mode=auto** (`Unit/AutomatedSample/ProductTests.cs`):
|
|
209
|
+
|
|
210
|
+
```csharp
|
|
211
|
+
[Fact]
|
|
212
|
+
public void ChangePrice_ShouldUpdatePrice()
|
|
213
|
+
{
|
|
214
|
+
var product = new Product("Widget", 9.99m);
|
|
215
|
+
product.ChangePrice(12.50m);
|
|
216
|
+
product.Price.ShouldBe(12.50m);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
[Fact]
|
|
220
|
+
public void Discontinue_CalledTwice_ShouldStayDiscontinued_NotThrow()
|
|
221
|
+
{
|
|
222
|
+
// The entity method itself stays idempotent — the refusal on a second discontinue is a business
|
|
223
|
+
// rule enforced by DiscontinueProductCommandHandler, one layer up, not by this method.
|
|
224
|
+
var product = new Product("Widget", 9.99m);
|
|
225
|
+
product.Discontinue();
|
|
226
|
+
product.Discontinue();
|
|
227
|
+
product.IsDiscontinued.ShouldBeTrue();
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The same file reflects over the `[CrudCreate]` constructor's parameters to prove the `DataAnnotations`
|
|
232
|
+
(`[Required]`, `[StringLength]`, `[Range]`) that the generator forwards onto the generated request are
|
|
233
|
+
actually present on the source the generator reads — grounding the claim without asserting on generated
|
|
234
|
+
code that has no committed source file.
|
|
235
|
+
|
|
236
|
+
**Static data seeder** (`Unit/ManualSample/PurchaseOrderStaticDataTests.cs`): a `DataSeedingConfiguration<T>`
|
|
237
|
+
seeder's `GetDataAsync` is `protected`, invoked only by DKNet's `UseAutoDataSeeding` pipeline — neither test
|
|
238
|
+
fixture wires that pipeline into its InMemory `DbContextOptions`, so it's otherwise never exercised. Invoke
|
|
239
|
+
it via reflection to prove the actual seed data instead of leaving the seeder at 0% coverage:
|
|
240
|
+
|
|
241
|
+
```csharp
|
|
242
|
+
var method = typeof(PurchaseOrderStaticData).GetMethod("GetDataAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
243
|
+
var orders = await (ValueTask<ICollection<PurchaseOrder>>)method.Invoke(new PurchaseOrderStaticData(), [CancellationToken.None])!;
|
|
244
|
+
orders.ShouldAllBe(o => o.CreatedBy == SharedConsts.SystemAccount);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Architecture rule** (`Architecture/AppServiceTests.cs`), using NetArchTest against the compiled
|
|
248
|
+
`Minimal.AppServices` assembly:
|
|
249
|
+
|
|
250
|
+
```csharp
|
|
251
|
+
var result = Types.InAssembly(typeof(AppSetup).Assembly)
|
|
252
|
+
.That().AreClasses().And().AreNotAbstract().And().ImplementInterface(typeof(IRequestHandler<,>))
|
|
253
|
+
.Should().NotBePublic().And().BeSealed()
|
|
254
|
+
.GetResult();
|
|
255
|
+
result.IsSuccessful.ShouldBeTrue();
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Mode differences
|
|
259
|
+
|
|
260
|
+
- **manual** (`ManualSample/PurchaseOrder`): test the hand-written `CreatePurchaseOrderCommandValidator`
|
|
261
|
+
etc. directly; cover duplicate checks and rejected state transitions (`Cancel` twice) through the
|
|
262
|
+
handler's `Result`; the acting-user rule (`ByUser` empty → fail) is enforced by the handler itself, so
|
|
263
|
+
assert it there.
|
|
264
|
+
- **auto** (`AutomatedSample/Product`): no hand-written validator class exists for `[CrudCreate]`/
|
|
265
|
+
`[CrudUpdate]` members — test the entity method directly (`ChangePrice`, `Discontinue`, `Approve`)
|
|
266
|
+
instead. `DataAnnotations` on a `[CrudCreate]` parameter are forwarded onto the generated request but
|
|
267
|
+
enforced only on a literal `Map*(string, Delegate)` route — **never assert 400 from a DataAnnotations
|
|
268
|
+
violation on a generated CRUD route**; it returns 201/200 (`DKNet.AspCore.Extensions`'s generic
|
|
269
|
+
`Map*<TRequest,TDto>` wrapper isn't visible to the validation source generator). A FluentValidation
|
|
270
|
+
validator still runs on every route. Verify a declared event's composed name against the compiled
|
|
271
|
+
assembly before asserting on it — `strings bin/**/Minimal.Domains.dll | grep <Entity>`.
|
|
272
|
+
|
|
273
|
+
## Conventions
|
|
274
|
+
|
|
275
|
+
- xUnit + Shouldly (`result.IsSuccess.ShouldBeTrue()`, not `Assert.True`). `Minimal.App.Tests.csproj`
|
|
276
|
+
disables analyzers and warnings-as-errors — production code style rules do not apply here.
|
|
277
|
+
- Implicit usings from `GlobalUsings.cs`: `AutoBogus`, `Shouldly`, `System.Text.Json`, `MapsterMapper`,
|
|
278
|
+
plus csproj-level `System.Net`, `Microsoft.Extensions.DependencyInjection`, `Xunit`. Still add explicit
|
|
279
|
+
`using`s for `SlimMessageBus`, `DKNet.EfCore.Specifications*`, and your feature's `AppServices`/`Domains`
|
|
280
|
+
namespaces.
|
|
281
|
+
- Reset the database at the top of every test (`await fixture.ResetDatabaseAsync()`), never in a shared
|
|
282
|
+
constructor — the fixture instance is shared across the whole test class.
|
|
283
|
+
- Don't hand-roll a mock of `IRepositorySpec` or `IMapper`; resolve the real ones from
|
|
284
|
+
`fixture.CreateScope()` against the InMemory provider — that is what proves DI wiring, not a substitute.
|
|
285
|
+
- Test classes/methods: `{Entity}{Concern}Tests`, `[Fact]` methods named `Method_ShouldOutcome_WhenCondition`.
|
|
286
|
+
|
|
287
|
+
## Commands
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
dotnet test ApiEndpoints/Minimal.App.Tests/Minimal.App.Tests.csproj --filter "FullyQualifiedName~PurchaseOrder"
|
|
291
|
+
dotnet test --settings coverage.runsettings --collect:"XPlat Code Coverage"
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`coverage.runsettings` includes `[DKNet*]*` and `[Minimal*]*`, excludes `*.Tests`/`*Tests` assemblies and
|
|
295
|
+
`**/bin/**, **/obj/**, **/*Tests.cs, **/GlobalUsings.cs, **/*.g.cs` by file — don't put real logic in an
|
|
296
|
+
excluded path expecting it to be measured.
|
|
297
|
+
|
|
298
|
+
## Step-by-step
|
|
299
|
+
|
|
300
|
+
1. Decide the layer: architecture rule, pure functional (entity/validator/spec), or result-level
|
|
301
|
+
integration. Don't write an HTTP test here unless it fills a genuine result-only/precondition gap BDD
|
|
302
|
+
can't reach.
|
|
303
|
+
2. Pure functional: construct the validator/entity/spec directly, no fixture.
|
|
304
|
+
3. Result-level integration: pick `ApiFixture` (or an auth variant only if the scenario needs
|
|
305
|
+
authentication/authorization), reset the DB, resolve `IMessageBus`/`IRepositorySpec` from
|
|
306
|
+
`CreateScope()`, send the request, assert on `IResult`/`IResultBase`.
|
|
307
|
+
4. Cover: happy path, not-found, missing/invalid acting user (manual mode), a guarded state transition
|
|
308
|
+
failing on retry, and (auto mode) the entity method's own behavior plus its forwarded
|
|
309
|
+
`DataAnnotations`.
|
|
310
|
+
5. Run the filtered test, then the full suite with coverage before moving on.
|
|
311
|
+
|
|
312
|
+
## Common mistakes
|
|
313
|
+
|
|
314
|
+
- **What you might expect**: asserting `400` when a `[Range]` attribute on a `[CrudCreate]` parameter is
|
|
315
|
+
violated on a generated route. **What actually happens**: it succeeds (201). **Why**: DataAnnotations are
|
|
316
|
+
forwarded onto the generated request but only enforced when the route is a literal `Map*` call the .NET
|
|
317
|
+
validation source generator can see in the compiling project; generated CRUD routes go through
|
|
318
|
+
`DKNet.AspCore.Extensions`'s generic wrapper instead.
|
|
319
|
+
- **What you might expect**: a domain event handler's log line is present immediately after
|
|
320
|
+
`bus.Send(...)` returns. **What actually happens**: it's sometimes missing. **Why**: the in-memory bus
|
|
321
|
+
publishes with `EnableBlockingPublish = false`; poll with `Eventually.IsTrueAsync(...)` against
|
|
322
|
+
`fixture.LogCapture.Messages` instead.
|
|
323
|
+
- **What you might expect**: an HTTP-status assertion in xUnit for a rule already covered by a BDD
|
|
324
|
+
scenario is harmless extra coverage. **What actually happens**: duplicated, drifting coverage. **Why**:
|
|
325
|
+
xUnit owns architecture/pure-functional/result-level assertions; BDD owns request→status→body.
|
|
326
|
+
- **What you might expect**: a new fixture mutating `Environment.SetEnvironmentVariable` for its own flag
|
|
327
|
+
works standalone. **What actually happens**: it races other tests intermittently. **Why**: only
|
|
328
|
+
`AssemblyInfo.cs`'s `DisableTestParallelization = true` makes that trick safe assembly-wide.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
# Workflow: `/dknet-unit-tests`
|
|
333
|
+
|
|
334
|
+
The procedure an agent follows when invoked with arguments. The reference sections above are the rules it applies.
|
|
335
|
+
|
|
336
|
+
You are adding integration tests in `Minimal.App.Tests` that exercise the AppServices and Domains layers through the real DI container.
|
|
337
|
+
|
|
338
|
+
### Inputs
|
|
339
|
+
|
|
340
|
+
`$ARGUMENTS` — feature folder, entity, optional `mode=manual|auto`. If not supplied, detect it: a
|
|
341
|
+
`[CrudCreate]` on the entity means `auto`. The mode decides which of the cases below apply.
|
|
342
|
+
|
|
343
|
+
### Required reading
|
|
344
|
+
|
|
345
|
+
1. The reference sections above
|
|
346
|
+
2. `ApiEndpoints/Minimal.App.Tests/` — existing fixtures and test patterns (`Architecture/`, `Integration/`, `Unit/`).
|
|
347
|
+
|
|
348
|
+
### Steps
|
|
349
|
+
|
|
350
|
+
1. Use the `dknet-implementer` subagent (or follow the skill directly) to write tests covering.
|
|
351
|
+
|
|
352
|
+
Both modes:
|
|
353
|
+
- Happy-path Create / Update / Delete via `IMessageBus.Send(...)`.
|
|
354
|
+
- Not-found in Update + Delete.
|
|
355
|
+
- Domain event firing — assert the in-memory consumer ran. In `auto`, send to the **composed**
|
|
356
|
+
event name (`<Entity><NarrowingProps><Operation>Event`, e.g. `ProductPriceUpdatedEvent`), which
|
|
357
|
+
you verify against the compiled assembly, not by guessing.
|
|
358
|
+
- Mapster smoke test (entity → DTO field-for-field).
|
|
359
|
+
|
|
360
|
+
`mode=manual` only:
|
|
361
|
+
- FluentValidation failures (empty / too-long / invalid format) on Create + Update.
|
|
362
|
+
- Duplicate detection in Create handler.
|
|
363
|
+
- Rejected state transitions (`Result.Fail`) on any business action.
|
|
364
|
+
|
|
365
|
+
`mode=auto` only:
|
|
366
|
+
- Entity mutation methods tested directly — that is where the behavior lives.
|
|
367
|
+
- **Do NOT** write a test asserting a `400`/validation failure from a forwarded DataAnnotations
|
|
368
|
+
attribute on a generated request. It is never enforced under this template's endpoint
|
|
369
|
+
convention, so such a test either fails or, worse, gets "fixed" by relaxing it into asserting
|
|
370
|
+
the gap is correct. Note the gap in the report instead.
|
|
371
|
+
2. Run only the affected tests:
|
|
372
|
+
```
|
|
373
|
+
dotnet test ApiEndpoints/Minimal.App.Tests/Minimal.App.Tests.csproj --filter "FullyQualifiedName~<Entity>"
|
|
374
|
+
```
|
|
375
|
+
3. If any test fails, fix the test or product code (per skill guidance) — do not relax assertions.
|
|
376
|
+
4. Report: test file path, count, pass/fail, coverage areas hit.
|
|
377
|
+
|
|
378
|
+
### Constraints
|
|
379
|
+
|
|
380
|
+
- Tests use the real `ApiFixture` + DI container — no hand-rolled mocks for `IRepositorySpec` or `IMapper`.
|
|
381
|
+
- xUnit + Shouldly. Assertions: `result.IsSuccess.ShouldBeTrue()`, `result.Value.X.ShouldBe(...)`, `result.Errors.ShouldContain(e => ...)`.
|
|
382
|
+
- Reset DB state between tests (per the skill's fixture pattern). Don't leak state between cases.
|