Skip to content

Commit 21b9d3e

Browse files
authored
feat(async): add async mapping support in AssertionContext and EvaluationContext (#3606)
* feat(async): add async mapping support in AssertionContext and EvaluationContext * feat(tests): add async mapping support in verification files for .NET versions * chore(deps): update ModularPipelines packages to version 2.48.8
1 parent 498afdc commit 21b9d3e

9 files changed

+217
-11
lines changed

Directory.Packages.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
<PackageVersion Include="Microsoft.Testing.Platform" Version="2.0.1" />
4646
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.0.1" />
4747
<PackageVersion Include="System.Threading.Channels" Version="9.0.0" />
48-
<PackageVersion Include="ModularPipelines.DotNet" Version="2.48.1" />
49-
<PackageVersion Include="ModularPipelines.Git" Version="2.48.1" />
50-
<PackageVersion Include="ModularPipelines.GitHub" Version="2.48.1" />
48+
<PackageVersion Include="ModularPipelines.DotNet" Version="2.48.8" />
49+
<PackageVersion Include="ModularPipelines.Git" Version="2.48.8" />
50+
<PackageVersion Include="ModularPipelines.GitHub" Version="2.48.8" />
5151
<PackageVersion Include="MSTest" Version="4.0.1" />
5252
<PackageVersion Include="MSTest.TestAdapter" Version="4.0.1" />
5353
<PackageVersion Include="MSTest.TestFramework" Version="4.0.1" />
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Net;
2+
#if !NET472
3+
using System.Net.Http.Json;
4+
#endif
5+
using TUnit.Assertions.Conditions;
6+
using TUnit.Assertions.Core;
7+
using TUnit.Assertions.Sources;
8+
9+
namespace TUnit.Assertions.Tests;
10+
11+
public class AsyncMapTests
12+
{
13+
#if !NET472
14+
[Test]
15+
public async Task Map_WithAsyncMapper_HttpResponseExample()
16+
{
17+
// Arrange
18+
var json = """{"title":"Test Error","status":400}""";
19+
var response = new HttpResponseMessage(HttpStatusCode.BadRequest)
20+
{
21+
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
22+
};
23+
24+
// Act & Assert - Using custom assertion with async Map
25+
await Assert.That(response)
26+
.ToProblemDetails()
27+
.And.Satisfies(pd => pd.Title == "Test Error")
28+
.And.Satisfies(pd => pd.Status == 400);
29+
}
30+
31+
[Test]
32+
public async Task Map_WithAsyncMapper_ComplexObjectTransformation()
33+
{
34+
// Arrange
35+
var container = new Container { Data = "42" };
36+
37+
// Act & Assert - Using custom assertion with async Map
38+
await Assert.That(container)
39+
.ToIntValue()
40+
.And.IsEqualTo(42);
41+
}
42+
43+
[Test]
44+
public async Task Map_WithAsyncMapper_PropagatesExceptions()
45+
{
46+
// Arrange
47+
var response = new HttpResponseMessage(HttpStatusCode.OK)
48+
{
49+
Content = new StringContent("invalid json", System.Text.Encoding.UTF8, "text/plain")
50+
};
51+
52+
// Act & Assert - Exception during mapping should fail the assertion
53+
await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(async () =>
54+
{
55+
await Assert.That(response).ToProblemDetails();
56+
});
57+
}
58+
#endif
59+
60+
[Test]
61+
public async Task Map_WithAsyncMapper_SyncCode()
62+
{
63+
// Arrange
64+
var container = new Container { Data = "100" };
65+
66+
// Act & Assert - Test async Map even with synchronous operation
67+
await Assert.That(container)
68+
.ToIntValue()
69+
.And.IsGreaterThan(50);
70+
}
71+
72+
public record TestProblemDetails
73+
{
74+
public string? Title { get; init; }
75+
public int Status { get; init; }
76+
}
77+
78+
public class Container
79+
{
80+
public string? Data { get; init; }
81+
}
82+
}
83+
84+
// Extension methods for custom assertions
85+
public static class AsyncMapTestExtensions
86+
{
87+
#if !NET472
88+
public static ToProblemDetailsAssertion ToProblemDetails(
89+
this IAssertionSource<HttpResponseMessage> source)
90+
{
91+
source.Context.ExpressionBuilder.Append(".ToProblemDetails()");
92+
return new ToProblemDetailsAssertion(source.Context);
93+
}
94+
#endif
95+
96+
public static ToIntValueAssertion ToIntValue(
97+
this IAssertionSource<AsyncMapTests.Container> source)
98+
{
99+
source.Context.ExpressionBuilder.Append(".ToIntValue()");
100+
return new ToIntValueAssertion(source.Context);
101+
}
102+
}
103+
104+
#if !NET472
105+
// Custom assertion using async Map
106+
public class ToProblemDetailsAssertion : Assertion<AsyncMapTests.TestProblemDetails>
107+
{
108+
public ToProblemDetailsAssertion(AssertionContext<HttpResponseMessage> context)
109+
: base(context.Map<AsyncMapTests.TestProblemDetails>(async response =>
110+
{
111+
var content = await response.Content.ReadFromJsonAsync<AsyncMapTests.TestProblemDetails>();
112+
if (content is null)
113+
{
114+
throw new InvalidOperationException("Response body is not Problem Details");
115+
}
116+
return content;
117+
}))
118+
{
119+
}
120+
121+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<AsyncMapTests.TestProblemDetails> metadata)
122+
{
123+
if (metadata.Exception != null)
124+
{
125+
return Task.FromResult(AssertionResult.Failed(metadata.Exception.Message));
126+
}
127+
128+
return Task.FromResult(AssertionResult.Passed);
129+
}
130+
131+
protected override string GetExpectation()
132+
{
133+
return "HTTP response to be in the format of a Problem Details object";
134+
}
135+
}
136+
#endif
137+
138+
// Custom assertion for testing async transformation with sync parsing
139+
public class ToIntValueAssertion : Assertion<int>
140+
{
141+
public ToIntValueAssertion(AssertionContext<AsyncMapTests.Container> context)
142+
: base(context.Map<int>(async container =>
143+
{
144+
await Task.Delay(1); // Simulate async work
145+
if (container?.Data is null)
146+
{
147+
throw new InvalidOperationException("Container data is null");
148+
}
149+
return int.Parse(container.Data);
150+
}))
151+
{
152+
}
153+
154+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<int> metadata)
155+
{
156+
if (metadata.Exception != null)
157+
{
158+
return Task.FromResult(AssertionResult.Failed(metadata.Exception.Message));
159+
}
160+
161+
return Task.FromResult(AssertionResult.Passed);
162+
}
163+
164+
protected override string GetExpectation()
165+
{
166+
return "Container data to be parseable as an integer";
167+
}
168+
}

TUnit.Assertions/Core/AssertionContext.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,13 @@ public AssertionContext<TNew> Map<TNew>(Func<TValue?, TNew?> mapper)
8080
}
8181

8282
/// <summary>
83-
/// Convenience overload for simple value-to-value transformations.
84-
/// Wraps a simple mapper function in an evaluation context transformation.
83+
/// Convenience overload for async value-to-value transformations.
84+
/// Wraps an async mapper function in an evaluation context transformation.
85+
/// The Task is unwrapped, allowing assertions to chain on the result type directly.
8586
/// </summary>
86-
public AssertionContext<Task<TNew?>> MapAsync<TNew>(Func<TValue?, Task<TNew?>> mapper)
87+
public AssertionContext<TNew> Map<TNew>(Func<TValue?, Task<TNew?>> asyncMapper)
8788
{
88-
return Map(evalContext => evalContext.Map(mapper));
89+
return Map(evalContext => evalContext.Map(asyncMapper));
8990
}
9091

9192
public AssertionContext<TException> MapException<TException>() where TException : Exception

TUnit.Assertions/Core/EvaluationContext.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,33 @@ public EvaluationContext<TNew> Map<TNew>(Func<TValue?, TNew?> mapper)
101101
});
102102
}
103103

104+
/// <summary>
105+
/// Creates a derived context by mapping the value to a different type using an async mapper.
106+
/// Used for type transformations that require async operations (e.g., HTTP response to JSON).
107+
/// The mapping function is only called if evaluation succeeds (no exception).
108+
/// </summary>
109+
public EvaluationContext<TNew> Map<TNew>(Func<TValue?, Task<TNew?>> asyncMapper)
110+
{
111+
return new EvaluationContext<TNew>(async () =>
112+
{
113+
var (value, exception) = await GetAsync();
114+
if (exception != null)
115+
{
116+
return (default(TNew), exception);
117+
}
118+
119+
try
120+
{
121+
var mappedValue = await asyncMapper(value);
122+
return (mappedValue, null);
123+
}
124+
catch (Exception ex)
125+
{
126+
return (default(TNew), ex);
127+
}
128+
});
129+
}
130+
104131
public EvaluationContext<TException> MapException<TException>() where TException : Exception
105132
{
106133
return new EvaluationContext<TException>(async () =>

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1748,8 +1748,8 @@ namespace .Core
17481748
"End"})]
17491749
public <, > GetTiming() { }
17501750
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
1751+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
17511752
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
1752-
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
17531753
public .<TException> MapException<TException>()
17541754
where TException : { }
17551755
}
@@ -1804,6 +1804,7 @@ namespace .Core
18041804
"Start",
18051805
"End"})]
18061806
public <, > GetTiming() { }
1807+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
18071808
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
18081809
public .<TException> MapException<TException>()
18091810
where TException : { }

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1745,8 +1745,8 @@ namespace .Core
17451745
"End"})]
17461746
public <, > GetTiming() { }
17471747
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
1748+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
17481749
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
1749-
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
17501750
public .<TException> MapException<TException>()
17511751
where TException : { }
17521752
}
@@ -1801,6 +1801,7 @@ namespace .Core
18011801
"Start",
18021802
"End"})]
18031803
public <, > GetTiming() { }
1804+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
18041805
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
18051806
public .<TException> MapException<TException>()
18061807
where TException : { }

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1748,8 +1748,8 @@ namespace .Core
17481748
"End"})]
17491749
public <, > GetTiming() { }
17501750
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
1751+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
17511752
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
1752-
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
17531753
public .<TException> MapException<TException>()
17541754
where TException : { }
17551755
}
@@ -1804,6 +1804,7 @@ namespace .Core
18041804
"Start",
18051805
"End"})]
18061806
public <, > GetTiming() { }
1807+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
18071808
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
18081809
public .<TException> MapException<TException>()
18091810
where TException : { }

TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1599,8 +1599,8 @@ namespace .Core
15991599
"End"})]
16001600
public <, > GetTiming() { }
16011601
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
1602+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
16021603
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
1603-
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
16041604
public .<TException> MapException<TException>()
16051605
where TException : { }
16061606
}
@@ -1655,6 +1655,7 @@ namespace .Core
16551655
"Start",
16561656
"End"})]
16571657
public <, > GetTiming() { }
1658+
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
16581659
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
16591660
public .<TException> MapException<TException>()
16601661
where TException : { }

docs/docs/assertions/extensibility/extensibility-chaining-and-converting.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ public class IsProblemDetailsAssertion : Assertion<ProblemDetails>
6969

7070
The `.Map<TTo>(...)` method handles the type conversion. If the conversion fails, throw an exception which will be captured and reported as an assertion failure.
7171

72+
**Note:** The `Map` method supports both synchronous and asynchronous transformations:
73+
- **Synchronous**: `context.Map<TTo>(value => transformedValue)`
74+
- **Asynchronous**: `context.Map<TTo>(async value => await transformedValueAsync)`
75+
76+
In both cases, the Task is automatically unwrapped, allowing you to chain assertions directly on the result type.
77+
7278
### 2. Create the Extension Method
7379

7480
Create an extension method on `IAssertionSource<TFrom>` that returns your assertion class:

0 commit comments

Comments
 (0)