Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@
<PackageVersion Include="Microsoft.Testing.Platform" Version="2.0.1" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.0.1" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.0" />
<PackageVersion Include="ModularPipelines.DotNet" Version="2.48.1" />
<PackageVersion Include="ModularPipelines.Git" Version="2.48.1" />
<PackageVersion Include="ModularPipelines.GitHub" Version="2.48.1" />
<PackageVersion Include="ModularPipelines.DotNet" Version="2.48.8" />
<PackageVersion Include="ModularPipelines.Git" Version="2.48.8" />
<PackageVersion Include="ModularPipelines.GitHub" Version="2.48.8" />
<PackageVersion Include="MSTest" Version="4.0.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.0.1" />
<PackageVersion Include="MSTest.TestFramework" Version="4.0.1" />
Expand Down
168 changes: 168 additions & 0 deletions TUnit.Assertions.Tests/AsyncMapTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Net;
#if !NET472
using System.Net.Http.Json;
#endif
using TUnit.Assertions.Conditions;
using TUnit.Assertions.Core;
using TUnit.Assertions.Sources;

namespace TUnit.Assertions.Tests;

public class AsyncMapTests
{
#if !NET472
[Test]
public async Task Map_WithAsyncMapper_HttpResponseExample()
{
// Arrange
var json = """{"title":"Test Error","status":400}""";
var response = new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
};

// Act & Assert - Using custom assertion with async Map
await Assert.That(response)
.ToProblemDetails()
.And.Satisfies(pd => pd.Title == "Test Error")
.And.Satisfies(pd => pd.Status == 400);
}

[Test]
public async Task Map_WithAsyncMapper_ComplexObjectTransformation()
{
// Arrange
var container = new Container { Data = "42" };

// Act & Assert - Using custom assertion with async Map
await Assert.That(container)
.ToIntValue()
.And.IsEqualTo(42);
}

[Test]
public async Task Map_WithAsyncMapper_PropagatesExceptions()
{
// Arrange
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("invalid json", System.Text.Encoding.UTF8, "text/plain")
};

// Act & Assert - Exception during mapping should fail the assertion
await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(async () =>
{
await Assert.That(response).ToProblemDetails();
});
}
#endif

[Test]
public async Task Map_WithAsyncMapper_SyncCode()
{
// Arrange
var container = new Container { Data = "100" };

// Act & Assert - Test async Map even with synchronous operation
await Assert.That(container)
.ToIntValue()
.And.IsGreaterThan(50);
}

public record TestProblemDetails
{
public string? Title { get; init; }
public int Status { get; init; }
}

public class Container
{
public string? Data { get; init; }
}
}

// Extension methods for custom assertions
public static class AsyncMapTestExtensions
{
#if !NET472
public static ToProblemDetailsAssertion ToProblemDetails(
this IAssertionSource<HttpResponseMessage> source)
{
source.Context.ExpressionBuilder.Append(".ToProblemDetails()");
return new ToProblemDetailsAssertion(source.Context);
}
#endif

public static ToIntValueAssertion ToIntValue(
this IAssertionSource<AsyncMapTests.Container> source)
{
source.Context.ExpressionBuilder.Append(".ToIntValue()");
return new ToIntValueAssertion(source.Context);
}
}

#if !NET472
// Custom assertion using async Map
public class ToProblemDetailsAssertion : Assertion<AsyncMapTests.TestProblemDetails>
{
public ToProblemDetailsAssertion(AssertionContext<HttpResponseMessage> context)
: base(context.Map<AsyncMapTests.TestProblemDetails>(async response =>
{
var content = await response.Content.ReadFromJsonAsync<AsyncMapTests.TestProblemDetails>();
if (content is null)
{
throw new InvalidOperationException("Response body is not Problem Details");
}
return content;
}))
{
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<AsyncMapTests.TestProblemDetails> metadata)
{
if (metadata.Exception != null)
{
return Task.FromResult(AssertionResult.Failed(metadata.Exception.Message));
}

return Task.FromResult(AssertionResult.Passed);
}

protected override string GetExpectation()
{
return "HTTP response to be in the format of a Problem Details object";
}
}
#endif

// Custom assertion for testing async transformation with sync parsing
public class ToIntValueAssertion : Assertion<int>
{
public ToIntValueAssertion(AssertionContext<AsyncMapTests.Container> context)
: base(context.Map<int>(async container =>
{
await Task.Delay(1); // Simulate async work
if (container?.Data is null)
{
throw new InvalidOperationException("Container data is null");
}
return int.Parse(container.Data);
}))
{
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<int> metadata)
{
if (metadata.Exception != null)
{
return Task.FromResult(AssertionResult.Failed(metadata.Exception.Message));
}

return Task.FromResult(AssertionResult.Passed);
}

protected override string GetExpectation()
{
return "Container data to be parseable as an integer";
}
}
9 changes: 5 additions & 4 deletions TUnit.Assertions/Core/AssertionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ public AssertionContext<TNew> Map<TNew>(Func<TValue?, TNew?> mapper)
}

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

public AssertionContext<TException> MapException<TException>() where TException : Exception
Expand Down
27 changes: 27 additions & 0 deletions TUnit.Assertions/Core/EvaluationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ public EvaluationContext<TNew> Map<TNew>(Func<TValue?, TNew?> mapper)
});
}

/// <summary>
/// Creates a derived context by mapping the value to a different type using an async mapper.
/// Used for type transformations that require async operations (e.g., HTTP response to JSON).
/// The mapping function is only called if evaluation succeeds (no exception).
/// </summary>
public EvaluationContext<TNew> Map<TNew>(Func<TValue?, Task<TNew?>> asyncMapper)
{
return new EvaluationContext<TNew>(async () =>
{
var (value, exception) = await GetAsync();
if (exception != null)
{
return (default(TNew), exception);
}

try
{
var mappedValue = await asyncMapper(value);
return (mappedValue, null);
}
catch (Exception ex)
{
return (default(TNew), ex);
}
});
}

public EvaluationContext<TException> MapException<TException>() where TException : Exception
{
return new EvaluationContext<TException>(async () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1748,8 +1748,8 @@ namespace .Core
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
}
Expand Down Expand Up @@ -1804,6 +1804,7 @@ namespace .Core
"Start",
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1745,8 +1745,8 @@ namespace .Core
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
}
Expand Down Expand Up @@ -1801,6 +1801,7 @@ namespace .Core
"Start",
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1748,8 +1748,8 @@ namespace .Core
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
}
Expand Down Expand Up @@ -1804,6 +1804,7 @@ namespace .Core
"Start",
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1599,8 +1599,8 @@ namespace .Core
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<.<TValue>, .<TNew>> evaluationFactory) { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<.<TNew?>> MapAsync<TNew>(<TValue?, .<TNew?>> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
}
Expand Down Expand Up @@ -1655,6 +1655,7 @@ namespace .Core
"Start",
"End"})]
public <, > GetTiming() { }
public .<TNew> Map<TNew>(<TValue?, .<TNew?>> asyncMapper) { }
public .<TNew> Map<TNew>(<TValue?, TNew?> mapper) { }
public .<TException> MapException<TException>()
where TException : { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ public class IsProblemDetailsAssertion : Assertion<ProblemDetails>

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.

**Note:** The `Map` method supports both synchronous and asynchronous transformations:
- **Synchronous**: `context.Map<TTo>(value => transformedValue)`
- **Asynchronous**: `context.Map<TTo>(async value => await transformedValueAsync)`

In both cases, the Task is automatically unwrapped, allowing you to chain assertions directly on the result type.

### 2. Create the Extension Method

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