Skip to content

Commit 1c937cd

Browse files
authored
test: add first-party unit tests for Vulthil.xUnit and Vulthil.Extensions.Testing (#345)
1 parent ccf91ae commit 1c937cd

10 files changed

Lines changed: 1059 additions & 3 deletions

Vulthil.SharedKernel.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@
123123
<Project Path="tests/Vulthil.IntegrationTests/Vulthil.IntegrationTests.csproj" />
124124
<Project Path="tests/Vulthil.TestHost/Vulthil.TestHost.csproj" />
125125
</Folder>
126+
<Folder Name="/tests/Testing/">
127+
<Project Path="tests/Vulthil.Extensions.Testing.Tests/Vulthil.Extensions.Testing.Tests.csproj" />
128+
<Project Path="tests/Vulthil.xUnit.Tests/Vulthil.xUnit.Tests.csproj" />
129+
</Folder>
126130
<Folder Name="/tests/MessagingIntegrationTests/">
127131
<File Path="tests/MessagingIntegrationTests/aspire.config.json" />
128132
<File Path="tests/MessagingIntegrationTests/Directory.Build.props" />
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using Vulthil.xUnit;
4+
5+
namespace Vulthil.Extensions.Testing.Tests;
6+
7+
public sealed class HttpResponseMessageExtensionsTests : BaseUnitTestCase
8+
{
9+
[Fact]
10+
public async Task DeserializesTheResponseBodyOnSuccess()
11+
{
12+
// Arrange
13+
using var response = new HttpResponseMessage(HttpStatusCode.OK)
14+
{
15+
Content = JsonContent.Create(new TestPayload("Ada")),
16+
};
17+
18+
// Act
19+
var payload = await response.GetResponseAsync<TestPayload>(CancellationToken);
20+
21+
// Assert
22+
payload.Name.ShouldBe("Ada");
23+
}
24+
25+
[Fact]
26+
public async Task NullResponseThrowsArgumentNullException()
27+
{
28+
// Arrange
29+
HttpResponseMessage? response = null;
30+
31+
// Act
32+
var exception = await Should.ThrowAsync<ArgumentNullException>(
33+
() => response.GetResponseAsync<TestPayload>(CancellationToken));
34+
35+
// Assert
36+
exception.ParamName.ShouldBe("response");
37+
}
38+
39+
[Fact]
40+
public async Task NonSuccessStatusCodeThrows()
41+
{
42+
// Arrange
43+
using var response = new HttpResponseMessage(HttpStatusCode.NotFound)
44+
{
45+
Content = JsonContent.Create(new TestPayload("Ada")),
46+
};
47+
48+
// Act
49+
var exception = await Should.ThrowAsync<HttpRequestException>(
50+
() => response.GetResponseAsync<TestPayload>(CancellationToken));
51+
52+
// Assert
53+
exception.ShouldNotBeNull();
54+
}
55+
56+
[Fact]
57+
public async Task NullJsonBodyThrowsInvalidOperationException()
58+
{
59+
// Arrange
60+
using var response = new HttpResponseMessage(HttpStatusCode.OK)
61+
{
62+
Content = JsonContent.Create<TestPayload?>(null),
63+
};
64+
65+
// Act
66+
var exception = await Should.ThrowAsync<InvalidOperationException>(
67+
() => response.GetResponseAsync<TestPayload>(CancellationToken));
68+
69+
// Assert
70+
exception.Message.ShouldBe("Response content is empty or could not be deserialized.");
71+
}
72+
73+
public sealed record TestPayload(string Name);
74+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
using System.Diagnostics;
2+
using Vulthil.Results;
3+
using Vulthil.xUnit;
4+
5+
namespace Vulthil.Extensions.Testing.Tests;
6+
7+
public sealed class PollingWaitAsyncOfTTests : BaseUnitTestCase
8+
{
9+
private static readonly TimeSpan ShortTick = TimeSpan.FromMilliseconds(15);
10+
11+
[Fact]
12+
public async Task SucceedsOnTheFirstAttemptWithoutWaitingForATick()
13+
{
14+
// Arrange
15+
var callCount = 0;
16+
Task<Result<int>> Poll(CancellationToken ct)
17+
{
18+
callCount++;
19+
return Task.FromResult(Result.Success(42));
20+
}
21+
var stopwatch = Stopwatch.StartNew();
22+
23+
// Act
24+
var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, TimeSpan.FromSeconds(5), CancellationToken);
25+
stopwatch.Stop();
26+
27+
// Assert
28+
result.IsSuccess.ShouldBeTrue();
29+
result.Value.ShouldBe(42);
30+
callCount.ShouldBe(1);
31+
stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
32+
}
33+
34+
[Fact]
35+
public async Task SucceedsOnALaterAttemptAfterInitialFailures()
36+
{
37+
// Arrange
38+
var attempt = 0;
39+
Task<Result<string>> Poll(CancellationToken ct)
40+
{
41+
attempt++;
42+
return Task.FromResult(attempt < 3
43+
? Result.Failure<string>(Error.Failure($"attempt-{attempt}", "not yet"))
44+
: Result.Success("done"));
45+
}
46+
47+
// Act
48+
var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, ShortTick, CancellationToken);
49+
50+
// Assert
51+
result.IsSuccess.ShouldBeTrue();
52+
result.Value.ShouldBe("done");
53+
attempt.ShouldBe(3);
54+
}
55+
56+
[Fact]
57+
public async Task TimingOutAggregatesEveryAttemptsErrorsInOrder()
58+
{
59+
// Arrange
60+
var attempt = 0;
61+
Task<Result<int>> Poll(CancellationToken ct)
62+
{
63+
attempt++;
64+
return Task.FromResult(Result.Failure<int>(Error.Failure($"error-{attempt}", "still failing")));
65+
}
66+
67+
// Act
68+
var result = await Polling.WaitAsync(TimeSpan.FromMilliseconds(70), Poll, ShortTick, CancellationToken);
69+
70+
// Assert
71+
result.IsSuccess.ShouldBeFalse();
72+
result.PollingError.ShouldNotBeNull();
73+
result.PollingError.Code.ShouldBe(Polling.Timeout.Code);
74+
result.PollingError.Description.ShouldBe(Polling.Timeout.Description);
75+
result.PollingError.Errors.Count.ShouldBe(attempt);
76+
result.PollingError.Errors.Select(error => error.Code)
77+
.ShouldBe(Enumerable.Range(1, attempt).Select(index => $"error-{index}"));
78+
}
79+
80+
[Fact]
81+
public async Task TimingOutCompletesNormallyWithoutThrowing()
82+
{
83+
// Act
84+
var result = await Polling.WaitAsync<int>(
85+
TimeSpan.FromMilliseconds(40),
86+
_ => Task.FromResult(Result.Failure<int>(Error.Failure("nope", "nope"))),
87+
ShortTick,
88+
CancellationToken.None);
89+
90+
// Assert
91+
result.IsSuccess.ShouldBeFalse();
92+
result.PollingError.ShouldNotBeNull();
93+
}
94+
95+
[Fact]
96+
public async Task ExternalCancellationThrowsInsteadOfReturningATimeoutResult()
97+
{
98+
// Arrange
99+
using var externalCts = new CancellationTokenSource();
100+
var observedToken = default(CancellationToken);
101+
Task<Result<int>> Poll(CancellationToken ct)
102+
{
103+
observedToken = ct;
104+
return Task.FromResult(Result.Failure<int>(Error.Failure("still-going", "not yet")));
105+
}
106+
externalCts.CancelAfter(TimeSpan.FromMilliseconds(30));
107+
108+
// Act
109+
var exception = await Should.ThrowAsync<OperationCanceledException>(
110+
() => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));
111+
112+
// Assert
113+
exception.ShouldNotBeNull();
114+
observedToken.IsCancellationRequested.ShouldBeTrue();
115+
}
116+
117+
[Fact]
118+
public async Task NullFuncThrowsArgumentNullException()
119+
{
120+
// Arrange
121+
Func<CancellationToken, Task<Result<int>>>? func = null;
122+
123+
// Act
124+
var exception = await Should.ThrowAsync<ArgumentNullException>(
125+
() => Polling.WaitAsync(TimeSpan.FromSeconds(1), func!, CancellationToken));
126+
127+
// Assert
128+
exception.ParamName.ShouldBe("func");
129+
}
130+
}
131+
132+
public sealed class PollingWaitAsyncTests : BaseUnitTestCase
133+
{
134+
private static readonly TimeSpan ShortTick = TimeSpan.FromMilliseconds(15);
135+
136+
[Fact]
137+
public async Task SucceedsOnTheFirstAttemptWithoutWaitingForATick()
138+
{
139+
// Arrange
140+
var callCount = 0;
141+
Task<Result> Poll(CancellationToken ct)
142+
{
143+
callCount++;
144+
return Task.FromResult(Result.Success());
145+
}
146+
var stopwatch = Stopwatch.StartNew();
147+
148+
// Act
149+
var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, TimeSpan.FromSeconds(5), CancellationToken);
150+
stopwatch.Stop();
151+
152+
// Assert
153+
result.IsSuccess.ShouldBeTrue();
154+
callCount.ShouldBe(1);
155+
stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
156+
}
157+
158+
[Fact]
159+
public async Task SucceedsOnALaterAttemptAfterInitialFailures()
160+
{
161+
// Arrange
162+
var attempt = 0;
163+
Task<Result> Poll(CancellationToken ct)
164+
{
165+
attempt++;
166+
return Task.FromResult(attempt < 3
167+
? Result.Failure(Error.Failure($"attempt-{attempt}", "not yet"))
168+
: Result.Success());
169+
}
170+
171+
// Act
172+
var result = await Polling.WaitAsync(TimeSpan.FromSeconds(5), Poll, ShortTick, CancellationToken);
173+
174+
// Assert
175+
result.IsSuccess.ShouldBeTrue();
176+
attempt.ShouldBe(3);
177+
}
178+
179+
[Fact]
180+
public async Task TimingOutAggregatesEveryAttemptsErrorsInOrder()
181+
{
182+
// Arrange
183+
var attempt = 0;
184+
Task<Result> Poll(CancellationToken ct)
185+
{
186+
attempt++;
187+
return Task.FromResult(Result.Failure(Error.Failure($"error-{attempt}", "still failing")));
188+
}
189+
190+
// Act
191+
var result = await Polling.WaitAsync(TimeSpan.FromMilliseconds(70), Poll, ShortTick, CancellationToken);
192+
193+
// Assert
194+
result.IsSuccess.ShouldBeFalse();
195+
result.PollingError.ShouldNotBeNull();
196+
result.PollingError.Code.ShouldBe(Polling.Timeout.Code);
197+
result.PollingError.Description.ShouldBe(Polling.Timeout.Description);
198+
result.PollingError.Errors.Count.ShouldBe(attempt);
199+
result.PollingError.Errors.Select(error => error.Code)
200+
.ShouldBe(Enumerable.Range(1, attempt).Select(index => $"error-{index}"));
201+
}
202+
203+
[Fact]
204+
public async Task TimingOutCompletesNormallyWithoutThrowing()
205+
{
206+
// Act
207+
var result = await Polling.WaitAsync(
208+
TimeSpan.FromMilliseconds(40),
209+
_ => Task.FromResult(Result.Failure(Error.Failure("nope", "nope"))),
210+
ShortTick,
211+
CancellationToken.None);
212+
213+
// Assert
214+
result.IsSuccess.ShouldBeFalse();
215+
result.PollingError.ShouldNotBeNull();
216+
}
217+
218+
[Fact]
219+
public async Task ExternalCancellationThrowsInsteadOfReturningATimeoutResult()
220+
{
221+
// Arrange
222+
using var externalCts = new CancellationTokenSource();
223+
var observedToken = default(CancellationToken);
224+
Task<Result> Poll(CancellationToken ct)
225+
{
226+
observedToken = ct;
227+
return Task.FromResult(Result.Failure(Error.Failure("still-going", "not yet")));
228+
}
229+
externalCts.CancelAfter(TimeSpan.FromMilliseconds(30));
230+
231+
// Act
232+
var exception = await Should.ThrowAsync<OperationCanceledException>(
233+
() => Polling.WaitAsync(TimeSpan.FromSeconds(30), Poll, TimeSpan.FromMilliseconds(10), externalCts.Token));
234+
235+
// Assert
236+
exception.ShouldNotBeNull();
237+
observedToken.IsCancellationRequested.ShouldBeTrue();
238+
}
239+
240+
[Fact]
241+
public async Task NullFuncThrowsArgumentNullException()
242+
{
243+
// Arrange
244+
Func<CancellationToken, Task<Result>>? func = null;
245+
246+
// Act
247+
var exception = await Should.ThrowAsync<ArgumentNullException>(
248+
() => Polling.WaitAsync(TimeSpan.FromSeconds(1), func!, CancellationToken));
249+
250+
// Assert
251+
exception.ParamName.ShouldBe("func");
252+
}
253+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<Import Project="$([MSBuild]::GetPathOfFileAbove('Vulthil.TestRunner.props', '$(MSBuildThisFileDirectory)'))" />
4+
5+
<ItemGroup>
6+
<ProjectReference Include="..\..\src\Vulthil.Extensions.Testing\Vulthil.Extensions.Testing.csproj" />
7+
</ItemGroup>
8+
9+
</Project>

tests/Vulthil.SharedKernel.Tests/Testing/BaseUnitTestCaseDisposalTests.cs renamed to tests/Vulthil.xUnit.Tests/BaseUnitTestCaseDisposalTests.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
using Vulthil.xUnit;
2-
3-
namespace Vulthil.SharedKernel.Tests.Testing;
1+
namespace Vulthil.xUnit.Tests;
42

53
public sealed class BaseUnitTestCaseDisposalTests : BaseUnitTestCase<BaseUnitTestCaseDisposalTests.DisposableTarget>
64
{

0 commit comments

Comments
 (0)