Skip to content

Commit 3e6bf91

Browse files
authored
Add unit testing framework and guidelines for Cloudflare Bicep extension (#30)
1 parent 2ee2300 commit 3e6bf91

17 files changed

+880
-7
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
---
2+
applyTo: "**/*Tests.cs,**/*Test.cs,**/*.Tests/**/*.cs"
3+
description: "Unit testing guidelines for Bicep local-deploy extension handlers"
4+
---
5+
6+
# Unit Testing Guidelines for Bicep Extensions
7+
8+
## Project Setup
9+
- Use MSTest as the test framework
10+
- Use Moq for mocking dependencies
11+
- Use FluentAssertions for readable assertions
12+
- Target .NET 9
13+
14+
## Test Project Structure
15+
```xml
16+
<ItemGroup>
17+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
18+
<PackageReference Include="MSTest.TestAdapter" />
19+
<PackageReference Include="MSTest.TestFramework" />
20+
<PackageReference Include="Moq" />
21+
<PackageReference Include="FluentAssertions" />
22+
</ItemGroup>
23+
```
24+
25+
## Handler testability
26+
27+
### Dependency Injection Pattern
28+
- **Always** abstract external dependencies (HTTP clients, file systems, APIs) behind interfaces
29+
- Inject dependencies via constructor
30+
- Avoid direct access to external resources in handlers
31+
32+
**Avoid** - Hard to test:
33+
```csharp
34+
public class MyHandler : TypedResourceHandler<MyProperties, MyIdentifiers>
35+
{
36+
protected override Task<ExtensibilityOperationSuccessResponse> CreateOrUpdate(...)
37+
{
38+
// Direct dependency - can't test without real external system
39+
var client = new HttpClient();
40+
// ...
41+
}
42+
}
43+
```
44+
45+
**Prefer** - Testable with mocks:
46+
```csharp
47+
public class MyHandler : TypedResourceHandler<MyProperties, MyIdentifiers>
48+
{
49+
private readonly IMyService _service;
50+
51+
public MyHandler(IMyService service)
52+
{
53+
_service = service;
54+
}
55+
56+
protected override async Task<ExtensibilityOperationSuccessResponse> CreateOrUpdate(...)
57+
{
58+
await _service.DoWorkAsync(...);
59+
return CreateSuccessResponse(properties, identifiers);
60+
}
61+
}
62+
```
63+
64+
## Test Class structure
65+
66+
```csharp
67+
[TestClass]
68+
public class MyHandlerTests
69+
{
70+
private Mock<IMyService> _mockService = null!;
71+
private MyHandler _handler = null!;
72+
73+
[TestInitialize]
74+
public void Setup()
75+
{
76+
// Use strict behavior to fail on unexpected calls
77+
_mockService = new Mock<IMyService>(MockBehavior.Strict);
78+
_handler = new MyHandler(_mockService.Object);
79+
}
80+
81+
[TestCleanup]
82+
public void Cleanup()
83+
{
84+
// Verify all setups were invoked
85+
_mockService.VerifyAll();
86+
}
87+
}
88+
```
89+
90+
## Test naming convention
91+
- Use descriptive names: `MethodName_Scenario_ExpectedResult`
92+
- Examples:
93+
- `CreateOrUpdate_WhenResourceExists_UpdatesResource`
94+
- `Get_WhenResourceNotFound_ThrowsNotFoundException`
95+
- `Delete_RemovesResourceSuccessfully`
96+
97+
## Arrange-Act-Assert Pattern
98+
- **Arrange**: Set up mocks and test data
99+
- **Act**: Call the method under test
100+
- **Assert**: Verify the result
101+
102+
```csharp
103+
[TestMethod]
104+
public async Task CreateOrUpdate_WritesResourceAndReturnsSuccess()
105+
{
106+
// Arrange
107+
var properties = new MyProperties { Name = "Test" };
108+
var identifiers = new MyIdentifiers { Id = "123" };
109+
110+
_mockService
111+
.Setup(s => s.CreateAsync(identifiers.Id, properties.Name, It.IsAny<CancellationToken>()))
112+
.Returns(Task.CompletedTask);
113+
114+
// Act
115+
var response = await _handler.CreateOrUpdate(properties, identifiers, CancellationToken.None);
116+
117+
// Assert
118+
response.Should().NotBeNull();
119+
response.Resource.Should().NotBeNull();
120+
}
121+
```
122+
123+
## Testing exceptions
124+
125+
```csharp
126+
[TestMethod]
127+
public async Task CreateOrUpdate_WhenServiceFails_ThrowsException()
128+
{
129+
// Arrange
130+
_mockService
131+
.Setup(s => s.CreateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
132+
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
133+
134+
// Act & Assert
135+
await FluentActions
136+
.Invoking(() => _handler.CreateOrUpdate(properties, identifiers, CancellationToken.None))
137+
.Should()
138+
.ThrowAsync<InvalidOperationException>()
139+
.WithMessage("Service unavailable");
140+
}
141+
```
142+
143+
## Data-Driven tests
144+
Use `[DataRow]` for testing multiple scenarios:
145+
146+
```csharp
147+
[TestMethod]
148+
[DataRow("project1", "Description 1")]
149+
[DataRow("project-with-dashes", "Another description")]
150+
[DataRow("ProjectWithCaps", "")]
151+
public async Task CreateOrUpdate_HandlesVariousInputs(string name, string description)
152+
{
153+
// Test implementation
154+
}
155+
```
156+
157+
## Testing Strategy by component
158+
159+
| Component | What to Test | Approach |
160+
|-----------|--------------|----------|
161+
| **Handlers** | Business logic, validation, error handling | Mock all dependencies |
162+
| **Services** | External integrations, API calls | Isolated test environments |
163+
| **Validators** | Input validation rules | Direct instantiation |
164+
165+
## Service integration tests
166+
For services that interact with external systems, use isolated test environments:
167+
168+
```csharp
169+
[TestClass]
170+
public class MyServiceTests
171+
{
172+
private string _testDirectory = null!;
173+
174+
[TestInitialize]
175+
public void Setup()
176+
{
177+
_testDirectory = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}");
178+
Directory.CreateDirectory(_testDirectory);
179+
}
180+
181+
[TestCleanup]
182+
public void Cleanup()
183+
{
184+
if (Directory.Exists(_testDirectory))
185+
{
186+
Directory.Delete(_testDirectory, recursive: true);
187+
}
188+
}
189+
}
190+
```
191+
192+
## Running tests
193+
194+
```powershell
195+
# Run all tests
196+
dotnet test
197+
198+
# Run with detailed output
199+
dotnet test --logger "console;verbosity=detailed"
200+
201+
# Run specific test class
202+
dotnet test --filter "FullyQualifiedName~MyHandlerTests"
203+
204+
# Run with code coverage
205+
dotnet test --collect:"XPlat Code Coverage"
206+
```
207+
208+
## Common FluentAssertions Patterns
209+
210+
```csharp
211+
// Null checks
212+
result.Should().NotBeNull();
213+
result.Should().BeNull();
214+
215+
// Object comparison
216+
result.Should().BeEquivalentTo(expected);
217+
218+
// Collection assertions
219+
results.Should().HaveCount(3);
220+
results.Should().Contain(item);
221+
222+
// String assertions
223+
message.Should().StartWith("Error:");
224+
message.Should().Contain("not found");
225+
```
226+
227+
## Key principles
228+
229+
1. **Use Dependency Injection** — Abstract external dependencies behind interfaces
230+
2. **Apply Loose Coupling** — Handlers depend on abstractions, not implementations
231+
3. **Use Strict Mocking** — Fail on unexpected calls to catch bugs early
232+
4. **Test One Scenario Per Method** — Keep tests focused and readable
233+
5. **Verify All Mocks** — Ensure all expected calls were made

.github/workflows/build.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ jobs:
4343
run: |
4444
dotnet build
4545
46+
- name: Test
47+
run: |
48+
dotnet test --logger "trx" --results-directory "TestResults"
49+
50+
- name: Upload Test Results
51+
uses: actions/upload-artifact@v4
52+
if: always()
53+
with:
54+
name: test-results
55+
path: TestResults/*.trx
56+
4657
publish-local:
4758
name: Publish Locally
4859
runs-on: ubuntu-22.04

Cloudflare-Bicep-deploy.sln

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,57 @@
1+
12
Microsoft Visual Studio Solution File, Format Version 12.00
23
# Visual Studio Version 17
34
VisualStudioVersion = 17.5.2.0
45
MinimumVisualStudioVersion = 10.0.40219.1
56
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudflareExtension", "src\CloudflareExtension.csproj", "{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}"
67
EndProject
8+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
9+
EndProject
10+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudflareExtension.Tests", "tests\CloudflareExtension.Tests.csproj", "{EEDCCB04-C737-486C-B540-4CF29B79E822}"
11+
EndProject
12+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
13+
EndProject
714
Global
815
GlobalSection(SolutionConfigurationPlatforms) = preSolution
916
Debug|Any CPU = Debug|Any CPU
17+
Debug|x64 = Debug|x64
18+
Debug|x86 = Debug|x86
1019
Release|Any CPU = Release|Any CPU
20+
Release|x64 = Release|x64
21+
Release|x86 = Release|x86
1122
EndGlobalSection
1223
GlobalSection(ProjectConfigurationPlatforms) = postSolution
1324
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1425
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
26+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|x64.ActiveCfg = Debug|Any CPU
27+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|x64.Build.0 = Debug|Any CPU
28+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|x86.ActiveCfg = Debug|Any CPU
29+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Debug|x86.Build.0 = Debug|Any CPU
1530
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
1631
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|Any CPU.Build.0 = Release|Any CPU
32+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|x64.ActiveCfg = Release|Any CPU
33+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|x64.Build.0 = Release|Any CPU
34+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|x86.ActiveCfg = Release|Any CPU
35+
{78223E13-9B2C-1DC2-1E1A-7301900DB4FB}.Release|x86.Build.0 = Release|Any CPU
36+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
37+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|Any CPU.Build.0 = Debug|Any CPU
38+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|x64.ActiveCfg = Debug|Any CPU
39+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|x64.Build.0 = Debug|Any CPU
40+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|x86.ActiveCfg = Debug|Any CPU
41+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Debug|x86.Build.0 = Debug|Any CPU
42+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|Any CPU.ActiveCfg = Release|Any CPU
43+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|Any CPU.Build.0 = Release|Any CPU
44+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|x64.ActiveCfg = Release|Any CPU
45+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|x64.Build.0 = Release|Any CPU
46+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|x86.ActiveCfg = Release|Any CPU
47+
{EEDCCB04-C737-486C-B540-4CF29B79E822}.Release|x86.Build.0 = Release|Any CPU
1748
EndGlobalSection
1849
GlobalSection(SolutionProperties) = preSolution
1950
HideSolutionNode = FALSE
2051
EndGlobalSection
52+
GlobalSection(NestedProjects) = preSolution
53+
{EEDCCB04-C737-486C-B540-4CF29B79E822} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
54+
EndGlobalSection
2155
GlobalSection(ExtensibilityGlobals) = postSolution
2256
SolutionGuid = {8BADE5A6-CA6E-4D28-BAA1-47579F726B57}
2357
EndGlobalSection

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,20 @@ In the `bicepconfig.json` you refer to the ACR:
141141
}
142142
```
143143

144+
### Testing
145+
146+
Run the unit tests with:
147+
148+
```powershell
149+
dotnet test
150+
```
151+
152+
For detailed output:
153+
154+
```powershell
155+
dotnet test --logger "console;verbosity=detailed"
156+
```
157+
144158
### Public ACR
145159

146160
If you want to try it out without effort, then you can use `br:cloudflarebicep.azurecr.io/extensions/cloudflare:0.1.25` as the ACR reference which I have published.

src/Handlers/CloudflareDnsRecordHandler.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ namespace CloudflareExtension.Handlers;
66

77
public class CloudflareDnsRecordHandler : TypedResourceHandler<CloudflareDnsRecord, CloudflareDnsRecordIdentifiers>
88
{
9+
private readonly ICloudflareApiServiceFactory _apiServiceFactory;
10+
11+
public CloudflareDnsRecordHandler() : this(new CloudflareApiServiceFactory())
12+
{
13+
}
14+
15+
public CloudflareDnsRecordHandler(ICloudflareApiServiceFactory apiServiceFactory)
16+
{
17+
_apiServiceFactory = apiServiceFactory;
18+
}
19+
920
protected override Task<ResourceResponse> Preview(ResourceRequest request, CancellationToken cancellationToken)
1021
{
1122
// For preview, just return the requested configuration without making API calls
@@ -16,8 +27,7 @@ protected override async Task<ResourceResponse> CreateOrUpdate(ResourceRequest r
1627
{
1728
try
1829
{
19-
var config = Configuration.GetConfiguration();
20-
using var apiService = new CloudflareApiService(config);
30+
using var apiService = _apiServiceFactory.Create();
2131

2232
// Use the zone ID provided in the Bicep template
2333
if (string.IsNullOrEmpty(request.Properties.ZoneId))

src/Handlers/CloudflareSecurityRuleHandler.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ namespace CloudflareExtension.Handlers;
66

77
public class CloudflareSecurityRuleHandler : TypedResourceHandler<CloudflareSecurityRule, CloudflareSecurityRuleIdentifiers>
88
{
9+
private readonly ICloudflareApiServiceFactory _apiServiceFactory;
10+
11+
public CloudflareSecurityRuleHandler() : this(new CloudflareApiServiceFactory())
12+
{
13+
}
14+
15+
public CloudflareSecurityRuleHandler(ICloudflareApiServiceFactory apiServiceFactory)
16+
{
17+
_apiServiceFactory = apiServiceFactory;
18+
}
19+
920
protected override Task<ResourceResponse> Preview(ResourceRequest request, CancellationToken cancellationToken)
1021
=> Task.FromResult(GetResponse(request));
1122

@@ -32,8 +43,7 @@ protected override async Task<ResourceResponse> CreateOrUpdate(ResourceRequest r
3243
request.Properties.Reference = request.Properties.Reference.Trim();
3344
}
3445

35-
var config = Configuration.GetConfiguration();
36-
using var apiService = new CloudflareApiService(config);
46+
using var apiService = _apiServiceFactory.Create();
3747

3848
if (string.IsNullOrWhiteSpace(request.Properties.RuleId))
3949
{

0 commit comments

Comments
 (0)