@grimoire-cc/cli 0.13.1 → 0.13.2
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/package.json +1 -1
- package/packs/dotnet-pack/grimoire.json +6 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/SKILL.md +293 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/reference/anti-patterns.md +329 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/reference/framework-guidelines.md +361 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/reference/parameterized-testing.md +378 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/reference/test-organization.md +476 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/reference/test-performance.md +576 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/templates/tunit-template.md +438 -0
- package/packs/dotnet-pack/skills/grimoire.dotnet-unit-testing/templates/xunit-template.md +303 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# Test Organization
|
|
2
|
+
|
|
3
|
+
Guidelines for organizing large test suites effectively.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Folder and Namespace Structure](#folder-and-namespace-structure)
|
|
8
|
+
- [File Naming Conventions](#file-naming-conventions)
|
|
9
|
+
- [Nested Classes for Organization](#nested-classes-for-organization)
|
|
10
|
+
- [Test Categories and Traits](#test-categories-and-traits)
|
|
11
|
+
- [Test Collections and Shared Fixtures](#test-collections-and-shared-fixtures)
|
|
12
|
+
- [Controlling Parallel Execution](#controlling-parallel-execution)
|
|
13
|
+
- [Project Structure for Large Solutions](#project-structure-for-large-solutions)
|
|
14
|
+
- [When to Split Test Projects](#when-to-split-test-projects)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Folder and Namespace Structure
|
|
19
|
+
|
|
20
|
+
Mirror your source code structure in the test project:
|
|
21
|
+
|
|
22
|
+
```plain
|
|
23
|
+
src/
|
|
24
|
+
├── MyApp.Domain/
|
|
25
|
+
│ ├── Entities/
|
|
26
|
+
│ │ └── Order.cs
|
|
27
|
+
│ └── Services/
|
|
28
|
+
│ ├── OrderService.cs
|
|
29
|
+
│ └── PaymentService.cs
|
|
30
|
+
├── MyApp.Infrastructure/
|
|
31
|
+
│ └── Repositories/
|
|
32
|
+
│ └── OrderRepository.cs
|
|
33
|
+
└── MyApp.Api/
|
|
34
|
+
└── Controllers/
|
|
35
|
+
└── OrderController.cs
|
|
36
|
+
|
|
37
|
+
tests/
|
|
38
|
+
├── MyApp.Domain.Tests/
|
|
39
|
+
│ ├── Entities/
|
|
40
|
+
│ │ └── OrderTests.cs
|
|
41
|
+
│ └── Services/
|
|
42
|
+
│ ├── OrderServiceTests.cs
|
|
43
|
+
│ └── PaymentServiceTests.cs
|
|
44
|
+
├── MyApp.Infrastructure.Tests/
|
|
45
|
+
│ └── Repositories/
|
|
46
|
+
│ └── OrderRepositoryTests.cs
|
|
47
|
+
└── MyApp.Api.Tests/
|
|
48
|
+
└── Controllers/
|
|
49
|
+
└── OrderControllerTests.cs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## File Naming Conventions
|
|
55
|
+
|
|
56
|
+
| Source File | Test File | Pattern |
|
|
57
|
+
| ------------- | ----------- | --------- |
|
|
58
|
+
| `OrderService.cs` | `OrderServiceTests.cs` | `{ClassName}Tests.cs` |
|
|
59
|
+
| `IOrderRepository.cs` | `OrderRepositoryTests.cs` | Test the implementation, not interface |
|
|
60
|
+
| `OrderValidator.cs` | `OrderValidatorTests.cs` | `{ClassName}Tests.cs` |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Nested Classes for Organization
|
|
65
|
+
|
|
66
|
+
Use nested classes to group related tests within a single test file.
|
|
67
|
+
|
|
68
|
+
### xUnit - Nested Classes
|
|
69
|
+
|
|
70
|
+
```csharp
|
|
71
|
+
public class OrderServiceTests : IDisposable
|
|
72
|
+
{
|
|
73
|
+
private readonly Mock<IOrderRepository> _mockRepository;
|
|
74
|
+
private readonly OrderService _sut;
|
|
75
|
+
|
|
76
|
+
public OrderServiceTests()
|
|
77
|
+
{
|
|
78
|
+
_mockRepository = new Mock<IOrderRepository>();
|
|
79
|
+
_sut = new OrderService(_mockRepository.Object);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public void Dispose() { }
|
|
83
|
+
|
|
84
|
+
// Group tests by method being tested
|
|
85
|
+
public class CreateOrder : OrderServiceTests
|
|
86
|
+
{
|
|
87
|
+
[Fact]
|
|
88
|
+
public async Task WithValidOrder_ReturnsSuccessResult()
|
|
89
|
+
{
|
|
90
|
+
// Inherits _sut and _mockRepository from parent
|
|
91
|
+
var order = new Order { /* ... */ };
|
|
92
|
+
var result = await _sut.CreateOrderAsync(order);
|
|
93
|
+
Assert.True(result.IsSuccess);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[Fact]
|
|
97
|
+
public async Task WithNullOrder_ThrowsArgumentNullException()
|
|
98
|
+
{
|
|
99
|
+
await Assert.ThrowsAsync<ArgumentNullException>(
|
|
100
|
+
() => _sut.CreateOrderAsync(null!));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[Fact]
|
|
104
|
+
public async Task WithDuplicateOrderNumber_ThrowsConflictException()
|
|
105
|
+
{
|
|
106
|
+
_mockRepository.Setup(r => r.ExistsAsync(It.IsAny<string>()))
|
|
107
|
+
.ReturnsAsync(true);
|
|
108
|
+
|
|
109
|
+
await Assert.ThrowsAsync<ConflictException>(
|
|
110
|
+
() => _sut.CreateOrderAsync(new Order { OrderNumber = "ORD-001" }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public class UpdateOrder : OrderServiceTests
|
|
115
|
+
{
|
|
116
|
+
[Fact]
|
|
117
|
+
public async Task WithValidChanges_UpdatesSuccessfully() { }
|
|
118
|
+
|
|
119
|
+
[Fact]
|
|
120
|
+
public async Task WithNonExistentOrder_ThrowsNotFoundException() { }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### TUnit - Nested Classes
|
|
126
|
+
|
|
127
|
+
```csharp
|
|
128
|
+
public class OrderServiceTests : IAsyncDisposable
|
|
129
|
+
{
|
|
130
|
+
protected readonly Mock<IOrderRepository> _mockRepository;
|
|
131
|
+
protected readonly OrderService _sut;
|
|
132
|
+
|
|
133
|
+
public OrderServiceTests()
|
|
134
|
+
{
|
|
135
|
+
_mockRepository = new Mock<IOrderRepository>();
|
|
136
|
+
_sut = new OrderService(_mockRepository.Object);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
140
|
+
|
|
141
|
+
public class CreateOrder : OrderServiceTests
|
|
142
|
+
{
|
|
143
|
+
[Test]
|
|
144
|
+
public async Task WithValidOrder_ReturnsSuccessResult()
|
|
145
|
+
{
|
|
146
|
+
var order = new Order { /* ... */ };
|
|
147
|
+
var result = await _sut.CreateOrderAsync(order);
|
|
148
|
+
await Assert.That(result.IsSuccess).IsTrue();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
[Test]
|
|
152
|
+
public async Task WithNullOrder_ThrowsArgumentNullException()
|
|
153
|
+
{
|
|
154
|
+
await Assert.That(() => _sut.CreateOrderAsync(null!))
|
|
155
|
+
.ThrowsException()
|
|
156
|
+
.OfType<ArgumentNullException>();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Test Output with Nested Classes:**
|
|
163
|
+
|
|
164
|
+
```plain
|
|
165
|
+
OrderServiceTests+CreateOrder.WithValidOrder_ReturnsSuccessResult ✓
|
|
166
|
+
OrderServiceTests+CreateOrder.WithNullOrder_ThrowsArgumentNullException ✓
|
|
167
|
+
OrderServiceTests+UpdateOrder.WithValidChanges_UpdatesSuccessfully ✓
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Alternative: Region-Based Organization
|
|
171
|
+
|
|
172
|
+
For simpler cases, use `#region` to organize tests:
|
|
173
|
+
|
|
174
|
+
```csharp
|
|
175
|
+
public class OrderServiceTests : IDisposable
|
|
176
|
+
{
|
|
177
|
+
private readonly OrderService _sut;
|
|
178
|
+
|
|
179
|
+
public OrderServiceTests()
|
|
180
|
+
{
|
|
181
|
+
_sut = new OrderService(/* ... */);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public void Dispose() { }
|
|
185
|
+
|
|
186
|
+
#region CreateOrder Tests
|
|
187
|
+
|
|
188
|
+
[Fact]
|
|
189
|
+
public async Task CreateOrder_WithValidData_ReturnsSuccess() { }
|
|
190
|
+
|
|
191
|
+
[Fact]
|
|
192
|
+
public async Task CreateOrder_WithNullOrder_ThrowsArgumentNullException() { }
|
|
193
|
+
|
|
194
|
+
#endregion
|
|
195
|
+
|
|
196
|
+
#region UpdateOrder Tests
|
|
197
|
+
|
|
198
|
+
[Fact]
|
|
199
|
+
public async Task UpdateOrder_WithValidChanges_UpdatesSuccessfully() { }
|
|
200
|
+
|
|
201
|
+
#endregion
|
|
202
|
+
|
|
203
|
+
#region Test Helpers
|
|
204
|
+
|
|
205
|
+
private static Order CreateValidOrder() => new Order { /* ... */ };
|
|
206
|
+
|
|
207
|
+
#endregion
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**When to use each:**
|
|
212
|
+
|
|
213
|
+
- **Nested Classes**: Different setup/teardown needs, shared state within group, better test explorer grouping
|
|
214
|
+
- **Regions**: Same setup for all tests, simpler structure, IDE collapsibility
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Test Categories and Traits
|
|
219
|
+
|
|
220
|
+
### xUnit - Traits
|
|
221
|
+
|
|
222
|
+
```csharp
|
|
223
|
+
public class OrderServiceTests
|
|
224
|
+
{
|
|
225
|
+
[Fact]
|
|
226
|
+
[Trait("Category", "Unit")]
|
|
227
|
+
public async Task CreateOrder_WithValidData_Succeeds() { }
|
|
228
|
+
|
|
229
|
+
[Fact]
|
|
230
|
+
[Trait("Category", "Integration")]
|
|
231
|
+
[Trait("Database", "Required")]
|
|
232
|
+
public async Task CreateOrder_PersistsToDatabase() { }
|
|
233
|
+
|
|
234
|
+
[Fact]
|
|
235
|
+
[Trait("Category", "Slow")]
|
|
236
|
+
public async Task ProcessBulkOrders_HandlesLargeDataset() { }
|
|
237
|
+
|
|
238
|
+
[Fact]
|
|
239
|
+
[Trait("Bug", "JIRA-1234")]
|
|
240
|
+
public async Task CreateOrder_WithSpecialCharacters_DoesNotFail() { }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Run specific categories:
|
|
244
|
+
// dotnet test --filter "Category=Unit"
|
|
245
|
+
// dotnet test --filter "Category!=Slow"
|
|
246
|
+
// dotnet test --filter "Bug=JIRA-1234"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### TUnit - Categories
|
|
250
|
+
|
|
251
|
+
```csharp
|
|
252
|
+
public class OrderServiceTests
|
|
253
|
+
{
|
|
254
|
+
[Test]
|
|
255
|
+
[Category("Unit")]
|
|
256
|
+
public async Task CreateOrder_WithValidData_Succeeds() { }
|
|
257
|
+
|
|
258
|
+
[Test]
|
|
259
|
+
[Category("Integration")]
|
|
260
|
+
[Category("Database")]
|
|
261
|
+
public async Task CreateOrder_PersistsToDatabase() { }
|
|
262
|
+
|
|
263
|
+
[Test]
|
|
264
|
+
[Property("Bug", "JIRA-1234")]
|
|
265
|
+
public async Task CreateOrder_WithSpecialCharacters_DoesNotFail() { }
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Common Category Conventions
|
|
270
|
+
|
|
271
|
+
```csharp
|
|
272
|
+
[Trait("Category", "Unit")] // Fast, isolated, no external dependencies
|
|
273
|
+
[Trait("Category", "Integration")] // Requires external resources (DB, API)
|
|
274
|
+
[Trait("Category", "E2E")] // End-to-end tests
|
|
275
|
+
[Trait("Category", "Smoke")] // Quick sanity checks for deployments
|
|
276
|
+
[Trait("Category", "Slow")] // Tests that take >1 second
|
|
277
|
+
[Trait("Category", "Flaky")] // Known intermittent failures
|
|
278
|
+
|
|
279
|
+
// CI/CD pipeline examples:
|
|
280
|
+
// PR builds: dotnet test --filter "Category=Unit"
|
|
281
|
+
// Nightly builds: dotnet test --filter "Category=Unit|Category=Integration"
|
|
282
|
+
// Release builds: dotnet test (all tests)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Test Collections and Shared Fixtures
|
|
288
|
+
|
|
289
|
+
### xUnit - Collections for Shared Context
|
|
290
|
+
|
|
291
|
+
Use collections when multiple test classes share expensive setup:
|
|
292
|
+
|
|
293
|
+
```csharp
|
|
294
|
+
// 1. Define the shared fixture
|
|
295
|
+
public class DatabaseFixture : IAsyncLifetime
|
|
296
|
+
{
|
|
297
|
+
public TestDatabase Database { get; private set; } = null!;
|
|
298
|
+
public string ConnectionString => Database.ConnectionString;
|
|
299
|
+
|
|
300
|
+
public async Task InitializeAsync()
|
|
301
|
+
{
|
|
302
|
+
Database = await TestDatabase.CreateAsync();
|
|
303
|
+
await Database.MigrateAsync();
|
|
304
|
+
await Database.SeedTestDataAsync();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
public async Task DisposeAsync()
|
|
308
|
+
{
|
|
309
|
+
await Database.DisposeAsync();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 2. Define the collection
|
|
314
|
+
[CollectionDefinition("Database")]
|
|
315
|
+
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
|
|
316
|
+
{
|
|
317
|
+
// Marker class, no code needed
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 3. Use in test classes
|
|
321
|
+
[Collection("Database")]
|
|
322
|
+
public class OrderRepositoryTests
|
|
323
|
+
{
|
|
324
|
+
private readonly DatabaseFixture _fixture;
|
|
325
|
+
|
|
326
|
+
public OrderRepositoryTests(DatabaseFixture fixture)
|
|
327
|
+
{
|
|
328
|
+
_fixture = fixture;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
[Fact]
|
|
332
|
+
public async Task GetById_WithExistingOrder_ReturnsOrder()
|
|
333
|
+
{
|
|
334
|
+
using var context = new AppDbContext(_fixture.ConnectionString);
|
|
335
|
+
var repo = new OrderRepository(context);
|
|
336
|
+
|
|
337
|
+
var result = await repo.GetByIdAsync(TestData.ExistingOrderId);
|
|
338
|
+
|
|
339
|
+
Assert.NotNull(result);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
[Collection("Database")]
|
|
344
|
+
public class CustomerRepositoryTests
|
|
345
|
+
{
|
|
346
|
+
private readonly DatabaseFixture _fixture;
|
|
347
|
+
|
|
348
|
+
public CustomerRepositoryTests(DatabaseFixture fixture)
|
|
349
|
+
{
|
|
350
|
+
_fixture = fixture;
|
|
351
|
+
// Same database instance shared across both test classes
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### TUnit - ClassDataSource for Shared Fixtures
|
|
357
|
+
|
|
358
|
+
```csharp
|
|
359
|
+
public class DatabaseFixture : IAsyncInitializable, IAsyncDisposable
|
|
360
|
+
{
|
|
361
|
+
public TestDatabase Database { get; private set; } = null!;
|
|
362
|
+
|
|
363
|
+
public async Task InitializeAsync()
|
|
364
|
+
{
|
|
365
|
+
Database = await TestDatabase.CreateAsync();
|
|
366
|
+
await Database.MigrateAsync();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
public async ValueTask DisposeAsync()
|
|
370
|
+
{
|
|
371
|
+
await Database.DisposeAsync();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
public class OrderRepositoryTests
|
|
376
|
+
{
|
|
377
|
+
[Test]
|
|
378
|
+
[ClassDataSource<DatabaseFixture>(Shared = SharedType.Globally)]
|
|
379
|
+
public async Task GetById_WithExistingOrder_ReturnsOrder(DatabaseFixture fixture)
|
|
380
|
+
{
|
|
381
|
+
using var context = new AppDbContext(fixture.Database.ConnectionString);
|
|
382
|
+
var repo = new OrderRepository(context);
|
|
383
|
+
|
|
384
|
+
var result = await repo.GetByIdAsync(TestData.ExistingOrderId);
|
|
385
|
+
|
|
386
|
+
await Assert.That(result).IsNotNull();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Controlling Parallel Execution
|
|
394
|
+
|
|
395
|
+
### xUnit
|
|
396
|
+
|
|
397
|
+
```csharp
|
|
398
|
+
// Disable for entire assembly (AssemblyInfo.cs)
|
|
399
|
+
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
|
400
|
+
|
|
401
|
+
// Limit parallel threads
|
|
402
|
+
[assembly: CollectionBehavior(MaxParallelThreads = 4)]
|
|
403
|
+
|
|
404
|
+
// Disable for specific collection
|
|
405
|
+
[CollectionDefinition("Sequential", DisableParallelization = true)]
|
|
406
|
+
public class SequentialCollection { }
|
|
407
|
+
|
|
408
|
+
[Collection("Sequential")]
|
|
409
|
+
public class TestsThatCannotRunInParallel { }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### TUnit
|
|
413
|
+
|
|
414
|
+
```csharp
|
|
415
|
+
// Sequential within a class
|
|
416
|
+
[NotInParallel]
|
|
417
|
+
public class SequentialTests { }
|
|
418
|
+
|
|
419
|
+
// Named parallel group (tests in same group run sequentially)
|
|
420
|
+
[ParallelGroup("DatabaseTests")]
|
|
421
|
+
public class OrderRepositoryTests { }
|
|
422
|
+
|
|
423
|
+
[ParallelGroup("DatabaseTests")]
|
|
424
|
+
public class CustomerRepositoryTests { }
|
|
425
|
+
|
|
426
|
+
// Custom parallelism limit
|
|
427
|
+
[ParallelLimiter<MaxParallel3>]
|
|
428
|
+
public class ResourceIntensiveTests { }
|
|
429
|
+
|
|
430
|
+
public class MaxParallel3 : IParallelLimit
|
|
431
|
+
{
|
|
432
|
+
public int Limit => 3;
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Project Structure for Large Solutions
|
|
439
|
+
|
|
440
|
+
```plain
|
|
441
|
+
tests/
|
|
442
|
+
├── Unit/
|
|
443
|
+
│ ├── MyApp.Domain.Tests/ # Domain logic unit tests
|
|
444
|
+
│ ├── MyApp.Application.Tests/ # Application/use case tests
|
|
445
|
+
│ └── MyApp.Api.Tests/ # Controller unit tests
|
|
446
|
+
│
|
|
447
|
+
├── Integration/
|
|
448
|
+
│ ├── MyApp.Infrastructure.Tests/ # Repository, external service tests
|
|
449
|
+
│ └── MyApp.Api.Integration.Tests/ # API tests with real database
|
|
450
|
+
│
|
|
451
|
+
├── E2E/
|
|
452
|
+
│ └── MyApp.E2E.Tests/ # Full end-to-end tests
|
|
453
|
+
│
|
|
454
|
+
└── Shared/
|
|
455
|
+
└── MyApp.Tests.Common/ # Shared fixtures, builders, utilities
|
|
456
|
+
├── Fixtures/
|
|
457
|
+
│ ├── DatabaseFixture.cs
|
|
458
|
+
│ └── ApiFixture.cs
|
|
459
|
+
├── Builders/
|
|
460
|
+
│ ├── OrderBuilder.cs
|
|
461
|
+
│ └── CustomerBuilder.cs
|
|
462
|
+
└── Extensions/
|
|
463
|
+
└── AssertionExtensions.cs
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## When to Split Test Projects
|
|
469
|
+
|
|
470
|
+
| Scenario | Recommendation |
|
|
471
|
+
| ---------- | ---------------- |
|
|
472
|
+
| <500 tests total | Single test project per source project |
|
|
473
|
+
| 500-2000 tests | Split Unit vs Integration |
|
|
474
|
+
| >2000 tests | Split by test type AND by domain area |
|
|
475
|
+
| Different test runners needed | Separate projects |
|
|
476
|
+
| Different framework versions | Separate projects |
|