Trellis.Testing — API Reference
- Package:
Trellis.Testing - Namespace:
Trellis.Testing - Purpose: FluentAssertions extensions, unwrap helpers, and test doubles (FakeRepository, TestActorProvider) for Trellis applications.
See also: trellis-api-cookbook.md — recipes using this package.
ASP.NET Core integration test helpers (WebApplicationFactory, DI replacement, fake time, MSAL tokens, and
.httpreplay) are in a separate package:Trellis.Testing.AspNetCore.
Use this file when
- You are writing unit/handler/domain tests for Trellis
Result,Maybe, errors, or mediator handlers. - You need FluentAssertions extensions for success/failure/error-shape assertions.
- You need test-only unwrap helpers, fake repositories, or test actor providers.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Assert a generic result succeeded | result.Should().BeSuccess() / .HaveValue(...) |
ResultAssertions<TValue> |
| Assert a result failed with a specific error case | result.Should().BeFailureOfType<TError>() |
ResultAssertions<TValue> |
| Assert error code/detail | .HaveErrorCode(...), .HaveErrorDetail(...), .HaveErrorDetailContaining(...) |
ResultAssertions<TValue> |
| Extract success value in tests only | result.Unwrap() |
Usage notes |
| Extract error in tests only | result.UnwrapError() |
Usage notes |
| Provide an actor in handler tests | TestActorProvider |
TestActorProvider |
| Stub repository behavior | FakeRepository<TAggregate,TId> |
FakeRepository<TAggregate,TId> |
Common traps
Unwrap()andUnwrapError()are test helpers. Do not copy them into production code or documentation snippets for application logic.- Test both the success path and the expected error branch; a compiling handler that never asserts failure semantics can still miss Trellis behavior.
- ASP.NET Core integration helpers are in trellis-api-testing-aspnetcore.md, not this package.
Types
Namespace Trellis.Testing
ResultAssertionsExtensions
public static class ResultAssertionsExtensions
{
public static ResultAssertions<TValue> Should<TValue>(this Result<TValue> result);
}
ResultAssertions<TValue>
public class ResultAssertions<TValue> : ReferenceTypeAssertions<Result<TValue>, ResultAssertions<TValue>>
{
public ResultAssertions(Result<TValue> result);
public AndWhichConstraint<ResultAssertions<TValue>, TValue> BeSuccess(
string because = "",
params object[] becauseArgs);
public AndWhichConstraint<ResultAssertions<TValue>, Error> BeFailure(
string because = "",
params object[] becauseArgs);
public AndWhichConstraint<ResultAssertions<TValue>, TError> BeFailureOfType<TError>(
string because = "",
params object[] becauseArgs)
where TError : Error;
public AndConstraint<ResultAssertions<TValue>> HaveValue(
TValue expectedValue,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> HaveValueMatching(
Func<TValue, bool> predicate,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> HaveValueEquivalentTo(
TValue expectedValue,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> HaveErrorCode(
string expectedCode,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> HaveErrorDetail(
string expectedDetail,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> HaveErrorDetailContaining(
string substring,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> Be(
Result<TValue> expected,
string because = "",
params object[] becauseArgs);
public AndConstraint<ResultAssertions<TValue>> NotBe(
Result<TValue> unexpected,
string because = "",
params object[] becauseArgs);
}
UnwrapError(this Result<Unit>)
The UnwrapError extension also has a Result<Unit>-specific overload at Trellis.Testing.UnwrapExtensions.UnwrapError(this Result<Unit>) for tests asserting on no-payload results.
ResultAssertionsAsyncExtensions
public static class ResultAssertionsAsyncExtensions
{
public static Task<AndWhichConstraint<ResultAssertions<TValue>, TValue>> BeSuccessAsync<TValue>(
this Task<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs);
public static Task<AndWhichConstraint<ResultAssertions<TValue>, Error>> BeFailureAsync<TValue>(
this Task<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs);
public static Task<AndWhichConstraint<ResultAssertions<TValue>, TError>> BeFailureOfTypeAsync<TValue, TError>(
this Task<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs)
where TError : Error;
public static ValueTask<AndWhichConstraint<ResultAssertions<TValue>, TValue>> BeSuccessAsync<TValue>(
this ValueTask<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs);
public static ValueTask<AndWhichConstraint<ResultAssertions<TValue>, Error>> BeFailureAsync<TValue>(
this ValueTask<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs);
public static ValueTask<AndWhichConstraint<ResultAssertions<TValue>, TError>> BeFailureOfTypeAsync<TValue, TError>(
this ValueTask<Result<TValue>> resultTask,
string because = "",
params object[] becauseArgs)
where TError : Error;
}
MaybeAssertionsExtensions
public static class MaybeAssertionsExtensions
{
public static MaybeAssertions<T> Should<T>(this Maybe<T> maybe)
where T : notnull;
}
MaybeAssertions<T>
public class MaybeAssertions<T> : ReferenceTypeAssertions<Maybe<T>, MaybeAssertions<T>>
where T : notnull
{
public MaybeAssertions(Maybe<T> maybe);
public AndWhichConstraint<MaybeAssertions<T>, T> HaveValue(
string because = "",
params object[] becauseArgs);
public AndConstraint<MaybeAssertions<T>> BeNone(
string because = "",
params object[] becauseArgs);
public AndConstraint<MaybeAssertions<T>> HaveValueEqualTo(
T expectedValue,
string because = "",
params object[] becauseArgs);
public AndConstraint<MaybeAssertions<T>> HaveValueMatching(
Func<T, bool> predicate,
string because = "",
params object[] becauseArgs);
public AndConstraint<MaybeAssertions<T>> HaveValueEquivalentTo(
T expectedValue,
string because = "",
params object[] becauseArgs);
}
ErrorAssertionsExtensions
public static class ErrorAssertionsExtensions
{
public static ErrorAssertions Should(this Error? error);
}
ErrorAssertions
public class ErrorAssertions : ReferenceTypeAssertions<Error, ErrorAssertions>
{
public ErrorAssertions(Error error);
public AndConstraint<ErrorAssertions> Be(
Error expected,
string because = "",
params object[] becauseArgs);
public AndConstraint<ErrorAssertions> HaveCode(
string expectedCode,
string because = "",
params object[] becauseArgs);
public AndConstraint<ErrorAssertions> HaveDetail(
string expectedDetail,
string because = "",
params object[] becauseArgs);
public AndConstraint<ErrorAssertions> HaveDetailContaining(
string substring,
string because = "",
params object[] becauseArgs);
public new AndWhichConstraint<ErrorAssertions, TError> BeOfType<TError>(
string because = "",
params object[] becauseArgs)
where TError : Error;
}
Note: The
HaveInstance(...)assertion was removed.Error.Instanceis no longer part of the closed-ADT base — the ASP wire layer synthesizesProblemDetails.Instancefrom the request URL plus anyResourceRefcarried by the typed payload (e.g.Error.NotFound.Resource). Assert againstResourceRefdirectly viaBeOfType<Error.NotFound>().Which.Resource.
ValidationErrorAssertionsExtensions
public static class ValidationErrorAssertionsExtensions
{
// Bound to Error.UnprocessableContent (the replacement for the previous validation error class).
// Method names preserved for source-compat at test sites.
public static ValidationErrorAssertions Should(this Error.UnprocessableContent error);
}
ValidationErrorAssertions
public class ValidationErrorAssertions : ReferenceTypeAssertions<Error.UnprocessableContent, ValidationErrorAssertions>
{
public ValidationErrorAssertions(Error.UnprocessableContent error);
public AndConstraint<ValidationErrorAssertions> HaveFieldError(
string fieldName, // accepted as either "email" or "/email" — normalized via InputPointer.ForProperty
string because = "",
params object[] becauseArgs);
public AndConstraint<ValidationErrorAssertions> HaveFieldErrorWithDetail(
string fieldName,
string expectedDetail, // matches FieldViolation.Detail exactly
string because = "",
params object[] becauseArgs);
public AndConstraint<ValidationErrorAssertions> HaveFieldCount(
int expectedCount, // counts distinct field paths
string because = "",
params object[] becauseArgs);
}
UnwrapExtensions
public static class UnwrapExtensions
{
public static T Unwrap<T>(this Result<T> result);
public static Error UnwrapError<T>(this Result<T> result);
public static Error UnwrapError(this Result<Unit> result);
public static T Unwrap<T>(this Maybe<T> maybe)
where T : notnull;
public static Task<T> UnwrapAsync<T>(this Task<Result<T>> resultTask);
public static ValueTask<T> UnwrapAsync<T>(this ValueTask<Result<T>> resultTask);
}
UnwrapFailedException
public sealed class UnwrapFailedException : Exception
{
public UnwrapFailedException();
public UnwrapFailedException(string message);
public UnwrapFailedException(string message, Exception innerException);
}
AggregateTestMutator
public static class AggregateTestMutator
{
public static TEntity SetMaybeField<TEntity, TValue>(
this TEntity entity,
Expression<Func<TEntity, Maybe<TValue>>> propertySelector,
TValue? value)
where TEntity : class
where TValue : notnull;
public static TEntity ClearMaybeField<TEntity, TValue>(
this TEntity entity,
Expression<Func<TEntity, Maybe<TValue>>> propertySelector)
where TEntity : class
where TValue : notnull;
}
FakeRepository<TAggregate, TId>
public class FakeRepository<TAggregate, TId>
where TAggregate : Aggregate<TId>
where TId : notnull
{
public IReadOnlyList<IDomainEvent> PublishedEvents { get; }
public int Count { get; }
public FakeRepository<TAggregate, TId> WithUniqueConstraint(Func<TAggregate, object?> propertySelector);
public Task<Result<TAggregate>> GetByIdAsync(TId id, CancellationToken cancellationToken = default);
public Task<Maybe<TAggregate>> FindByIdAsync(TId id, CancellationToken cancellationToken = default);
// Setup surface — mirrors RepositoryBase<TAggregate, TId>. Use these in handlers and
// in test setup so the same IRepository contract works in both the EF and fake paths.
public void Add(TAggregate aggregate);
public void Remove(TAggregate aggregate);
public Task<Result<Unit>> RemoveByIdAsync(TId id, CancellationToken cancellationToken = default);
// Result-shape surface — only on the fake. Reserve for tests that explicitly assert
// on Result-of-save shape (e.g., conflict-result handling). NOT part of RepositoryBase.
public Task<Result<Unit>> SaveAsync(TAggregate aggregate, CancellationToken cancellationToken = default);
public Task<Result<Unit>> DeleteAsync(TId id, CancellationToken cancellationToken = default);
public void Clear();
public bool Exists(TId id);
public TAggregate? Get(TId id);
public IEnumerable<TAggregate> GetAll();
public Task<Maybe<TAggregate>> FindAsync(Func<TAggregate, bool> predicate);
public Task<IReadOnlyList<TAggregate>> WhereAsync(Func<TAggregate, bool> predicate);
public Task<IReadOnlyList<TAggregate>> WhereAsync(Specification<TAggregate> specification);
}
FakeSharedResourceLoader<TResource, TId>
public class FakeSharedResourceLoader<TResource, TId> : SharedResourceLoaderById<TResource, TId>
where TResource : Aggregate<TId>
where TId : notnull
{
public FakeSharedResourceLoader(FakeRepository<TResource, TId> repository);
public override Task<Result<TResource>> GetByIdAsync(TId id, CancellationToken cancellationToken);
}
TestActorProvider
public sealed class TestActorProvider : IActorProvider
{
public TestActorProvider(Actor actor);
public TestActorProvider(string userId, params string[] permissions);
public Task<Actor> GetCurrentActorAsync(CancellationToken cancellationToken = default);
public TestActorScope WithActor(Actor actor);
public TestActorScope WithActor(string userId, params string[] permissions);
}
TestActorScope
public sealed class TestActorScope : IAsyncDisposable, IDisposable
{
public ValueTask DisposeAsync();
public void Dispose();
}
Usage notes
Assertions
- Synchronous assertions start from
Result<T>orMaybe<T>:result.Should().BeSuccess()result.Should().BeFailureOfType<Error.UnprocessableContent>()maybe.Should().HaveValue()
- Async assertions are extension methods on
Task<Result<T>>andValueTask<Result<T>>, not onResultAssertions<T>.- Correct:
await resultTask.BeSuccessAsync(); - Correct:
await valueTaskResult.BeFailureAsync(); - Wrong:
await result.Should().BeSuccessAsync();
- Correct:
FakeRepository
- Setup surface (mirrors
RepositoryBase<TAggregate, TId>inTrellis.EntityFrameworkCore) — use these from handlers and test setup so the sameIRepositorycontract works in both worlds:void Add(TAggregate)— stages an insert; in the fake, immediately visible. ThrowsInvalidOperationExceptionon unique-constraint violation (setup mistakes should fail loud at the call site).void Remove(TAggregate)— stages a delete; no-op if the aggregate is not in the store.Task<Result<Unit>> RemoveByIdAsync(TId)— looks up by ID and removes; returnsError.NotFoundif missing.
- Result-shape surface (only on the fake —
RepositoryBasedoes not expose these) — use only when the test specifically asserts on theResultof the persistence call:Task<Result<Unit>> SaveAsync(TAggregate)— returnsError.Conflicton unique-constraint violation. Use to test conflict-handling code paths.Task<Result<Unit>> DeleteAsync(TId)— returnsError.NotFoundon missing. Use to test not-found handling. (RemoveByIdAsyncis the staging-API-named alias.)
WithUniqueConstraint(Func<TAggregate, object?> propertySelector)— fluent constraint registration; checked eagerly byAdd(throws) and at-call bySaveAsync(returnsResult).Clear(),Exists(TId id),Get(TId id),GetAll(),Count— direct inspection helpersGetByIdAsync/DeleteAsync/RemoveByIdAsyncreturnError.NotFounddetails in the format:"{AggregateTypeName} with ID {id} not found"
- Unique-constraint conflicts return:
"A {AggregateTypeName} with the same value already exists."
See cookbook Recipe 16 — Unit of work in handlers for guidance on which surface to use from where, and the pitfall of accidentally calling
SaveAsyncfrom a production-shaped repository contract.
Compilable examples
Result assertions
using FluentAssertions;
using Trellis;
using Trellis.Testing;
var success = Result.Ok(42);
success.Should().BeSuccess().Which.Should().Be(42);
var notFound = Result.Fail<int>(new Error.NotFound(ResourceRef.For("Order", "123")) { Detail = "Order 123 not found" });
notFound.Should().BeFailure()
.Which.Detail.Should().Be("Order 123 not found");
Async assertions
using System.Threading.Tasks;
using FluentAssertions;
using Trellis;
using Trellis.Testing;
Task<Result<int>> resultTask = Task.FromResult(Result.Ok(42));
ValueTask<Result<int>> valueTaskResult = ValueTask.FromResult(Result.Ok(7));
(await resultTask.BeSuccessAsync()).Which.Should().Be(42);
(await valueTaskResult.BeSuccessAsync()).Which.Should().Be(7);
FakeRepository
using System;
using FluentAssertions;
using Trellis;
using Trellis.Testing;
public sealed record OrderId(Guid Value);
public sealed class Order : Aggregate<OrderId>
{
public Order(OrderId id) : base(id) { }
}
var repo = new FakeRepository<Order, OrderId>()
.WithUniqueConstraint(order => order.Id);
var order = new Order(new OrderId(Guid.NewGuid()));
await repo.SaveAsync(order).BeSuccessAsync();
(await repo.GetByIdAsync(order.Id)).Should().BeSuccess().Which.Should().BeSameAs(order);
repo.Exists(order.Id).Should().BeTrue();
repo.Count.Should().Be(1);