Skip to content

Commit b169260

Browse files
committed
Refactor unit-testing skill to reduce the size of the SKILL.md and to tranfer details to CHEATSHEET.md
1 parent f971f24 commit b169260

2 files changed

Lines changed: 517 additions & 777 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# Unit Testing Cheatsheet
2+
3+
## FluentAssertions
4+
5+
### Boolean / Null
6+
7+
```csharp
8+
result.Should().BeTrue();
9+
result.Should().BeFalse();
10+
result.Should().BeNull();
11+
result.Should().NotBeNull();
12+
```
13+
14+
### Equality
15+
16+
```csharp
17+
result.Should().Be(expected);
18+
result.Should().NotBe(unexpected);
19+
20+
// Deep equality — use for objects and DTOs
21+
result.Should().BeEquivalentTo(expected);
22+
```
23+
24+
### Collections ⭐ Primary assertion pattern for this codebase
25+
26+
#### Existence checks
27+
28+
```csharp
29+
list.Should().BeEmpty();
30+
list.Should().NotBeEmpty();
31+
list.Should().HaveCount(3);
32+
```
33+
34+
#### Contains — use when asserting a single item exists (never use [index])
35+
36+
```csharp
37+
// ✅ Preferred — predicate, order-insensitive
38+
list.Should().Contain(c => c.FirstName == "John" && c.LastName == "Doe");
39+
40+
// ✅ xUnit equivalent
41+
Assert.Contains(list, c => c.FirstName == "John" && c.LastName == "Doe");
42+
43+
// ❌ Never do this — brittle, order-dependent
44+
Assert.Equal("John", list[0].FirstName);
45+
```
46+
47+
#### ContainSingle — use when exactly one item is expected
48+
49+
```csharp
50+
// Existence only
51+
list.Should().ContainSingle();
52+
53+
// Existence with predicate
54+
list.Should().ContainSingle(c => c.FirstName == "John");
55+
56+
// Existence + further assertions on the matched item
57+
list.Should().ContainSingle(c => c.FirstName == "John")
58+
.Which.LastName.Should().Be("Doe");
59+
```
60+
61+
#### BeEquivalentTo — use when asserting the full collection matches
62+
63+
```csharp
64+
// ✅ Full match, order-insensitive by default
65+
list.Should().BeEquivalentTo(new[]
66+
{
67+
new Customer { FirstName = "John", LastName = "Doe" },
68+
new Customer { FirstName = "Jane", LastName = "Smith" }
69+
});
70+
71+
// ✅ Scope to relevant properties only — preferred to avoid over-specification
72+
list.Should().BeEquivalentTo(
73+
new[]
74+
{
75+
new Customer { FirstName = "John", LastName = "Doe" },
76+
new Customer { FirstName = "Jane", LastName = "Smith" }
77+
},
78+
options => options
79+
.Including(c => c.FirstName)
80+
.Including(c => c.LastName)
81+
);
82+
83+
// ✅ Exclude properties you don't care about (e.g. generated IDs, timestamps)
84+
list.Should().BeEquivalentTo(expected,
85+
options => options
86+
.Excluding(c => c.Id)
87+
.Excluding(c => c.CreatedDate)
88+
);
89+
90+
// ❌ Over-specified — any unrelated property change breaks the test
91+
list.Should().BeEquivalentTo(new Customer
92+
{
93+
Id = 1, FirstName = "John", LastName = "Doe",
94+
CreatedDate = DateTime.Parse("2026-01-30"), Version = 1
95+
});
96+
```
97+
98+
### Decision guide
99+
100+
| Scenario | Use |
101+
|---|---|
102+
| One item exists matching conditions | `ContainSingle(predicate)` |
103+
| At least one item matches | `Contain(predicate)` |
104+
| Exact collection match, all properties | `BeEquivalentTo(expected)` |
105+
| Exact collection match, selected properties | `BeEquivalentTo(expected, options => options.Including(...))` |
106+
| Ignore generated/audit properties | `BeEquivalentTo(expected, options => options.Excluding(...))` |
107+
| Never | `list[0].Property` |
108+
109+
---
110+
111+
### Types
112+
113+
```csharp
114+
result.Should().BeOfType<MyType>();
115+
result.Should().BeAssignableTo<IMyInterface>();
116+
```
117+
118+
### Strings
119+
120+
```csharp
121+
name.Should().StartWith("Test");
122+
name.Should().EndWith("Service");
123+
name.Should().Contain("Entity");
124+
name.Should().BeNullOrEmpty();
125+
name.Should().NotBeNullOrWhiteSpace();
126+
```
127+
128+
---
129+
130+
### Exceptions
131+
132+
```csharp
133+
// Verify exception type
134+
Action act = () => target.Execute(invalidInput);
135+
act.Should().Throw<InvalidOperationException>();
136+
137+
// Verify exception message (wildcards supported)
138+
act.Should().Throw<ArgumentException>()
139+
.WithMessage("*cannot be null*");
140+
141+
// Verify no exception thrown
142+
act.Should().NotThrow();
143+
```
144+
145+
---
146+
147+
### Result Pattern
148+
149+
```csharp
150+
result.IsSuccess.Should().BeTrue();
151+
result.IsFailure.Should().BeTrue();
152+
result.Error.Should().Be(ExpectedError);
153+
result.Value.Should().NotBeNull();
154+
```
155+
156+
---
157+
158+
## Test Project File Template
159+
160+
```xml
161+
<Project Sdk="Microsoft.NET.Sdk">
162+
163+
<PropertyGroup>
164+
<TargetFramework>net10.0</TargetFramework>
165+
<ImplicitUsings>enable</ImplicitUsings>
166+
<Nullable>enable</Nullable>
167+
<OutputType>Exe</OutputType>
168+
<IsPackable>false</IsPackable>
169+
</PropertyGroup>
170+
171+
<ItemGroup>
172+
<PackageReference Include="coverlet.collector" Version="6.0.4" />
173+
<PackageReference Include="FluentAssertions" Version="8.8.0" />
174+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
175+
<PackageReference Include="NSubstitute" Version="5.3.0" />
176+
<PackageReference Include="xunit.v3" Version="3.2.0" />
177+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
178+
<PrivateAssets>all</PrivateAssets>
179+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
180+
</PackageReference>
181+
</ItemGroup>
182+
183+
<ItemGroup>
184+
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
185+
</ItemGroup>
186+
187+
<ItemGroup>
188+
<ProjectReference Include="..\{Module}.{AssemblyName}\{Module}.{AssemblyName}.csproj" />
189+
<ProjectReference Include="..\{Module}.DataModel\{Module}.DataModel.csproj" />
190+
<ProjectReference Include="..\..\Contracts\Contracts.csproj" />
191+
<ProjectReference Include="..\..\..\Infra\DataAccess\DataAccess.csproj" />
192+
</ItemGroup>
193+
194+
<ItemGroup>
195+
<Using Include="Xunit" />
196+
</ItemGroup>
197+
198+
</Project>
199+
```
200+
201+
---
202+
203+
## Template: Fake Test Double (Stub + Mock)
204+
205+
```csharp
206+
public class {Class}Tests
207+
{
208+
// ... tests ...
209+
// ... method helpers ...
210+
211+
private class Fake{Dependency} : I{Dependency}
212+
{
213+
private readonly List<{EntityType}> {initialData} = new();
214+
private readonly List<{EntityType}> {trackedEntities} = new();
215+
216+
public Fake{Dependency}({EntityType}[] {data})
217+
{
218+
this.{initialData}.AddRange({data});
219+
}
220+
221+
public {ReturnType} {QueryMethod}()
222+
{
223+
return {initialData}.{TransformToReturnType}();
224+
}
225+
226+
public void {CommandMethod}({EntityType} {entity})
227+
{
228+
{trackedEntities}.Add({entity});
229+
}
230+
231+
public IReadOnlyList<{EntityType}> Get{TrackedEntities}<T>() where T : {EntityType}
232+
{
233+
return {trackedEntities}.OfType<T>().ToList();
234+
}
235+
}
236+
}
237+
```
238+
239+
```csharp
240+
// Example for Fake UnitOfWork
241+
public class {Class}Tests
242+
{
243+
// ... tests ...
244+
// ... method helpers ...
245+
246+
private class FakeUnitOfWork : IUnitOfWork
247+
{
248+
private readonly List<object> data = new();
249+
private readonly List<object> addedEntities = new();
250+
private readonly List<object> savedEntities = new();
251+
private List<object> deletedEntities = new();
252+
253+
public FakeUnitOfWork(params object[] initialData)
254+
{
255+
data.AddRange(initialData);
256+
}
257+
258+
public IQueryable<T> GetEntities<T>() where T : class
259+
{
260+
return data
261+
.Union(addedEntities)
262+
.Union(savedEntities)
263+
.OfType<T>().AsQueryable();
264+
}
265+
266+
public void Add<T>(T entity) where T : class
267+
{
268+
addedEntities.Add(entity);
269+
}
270+
271+
public void Delete<T>(T entity) where T : class
272+
{
273+
deletedEntities.Add(entity);
274+
}
275+
276+
public void SaveChanges()
277+
{
278+
savedEntities.AddRange(addedEntities);
279+
}
280+
281+
public Task SaveChangesAsync()
282+
{
283+
SaveChanges();
284+
return Task.CompletedTask;
285+
}
286+
287+
public IReadOnlyList<T> GetAddedEntities<T>() where T : class => addedEntities.OfType<T>().ToList();
288+
public IReadOnlyList<T> GetSavedEntities<T>() where T : class => savedEntities.OfType<T>().ToList();
289+
public IReadOnlyList<T> GetDeletedEntities<T>() where T : class => deletedEntities.OfType<T>().ToList();
290+
291+
public void BeginTransactionScope(SimplifiedIsolationLevel isolationLevel) { }
292+
public IUnitOfWork CreateUnitOfWork() => this;
293+
public void Dispose() { }
294+
}
295+
}
296+
```
297+
298+
---
299+
300+
## Approved Packages
301+
302+
| Purpose | Package | Version |
303+
|--------------|----------------------|---------|
304+
| Test framework | xUnit | 3.x |
305+
| Mocking | NSubstitute | 5.x |
306+
| Assertions | FluentAssertions | 8.x |
307+
| Code coverage | coverlet.collector | 6.x |

0 commit comments

Comments
 (0)