Skip to content

Commit

Permalink
Add sync and chainable Func<Task<T>> assertions
Browse files Browse the repository at this point in the history
This PR enables you to continue asserting on the result of `Func<Task<int>>` when it does not throw.

```c#
Func<Task<int>> func = () => Task.FromResult(42);

func.Should().NotThrow()
    .Which.Should().Be(42);

(await func.Should().NotThrowAsync())
    .Which.Should().Be(42);

func.Should().NotThrowAfter(10.Seconds(), 1.Seconds())
    .Which.Should().Be(42)

(await func.Should().NotThrowAfterAsync(10.Seconds(), 1.Seconds()))
    .Which.Should().Be(42);
```

The implementations and tests are mostly copied from the non-generic or sync versions.
To avoid the redefinitions of `Subject` in each class two changes in generics:
* `DelegateAssertions` and `AsyncFunctionAssertions` are now generic in `TAssertions`.
* `AsyncFunctionAssertions` is also generic in `TTask : Task` to support
  both `Task` and `Task<T>` equally.

This fixes fluentassertions#990
  • Loading branch information
jnyrup committed Apr 10, 2020
1 parent ee50c5f commit dd4af2f
Show file tree
Hide file tree
Showing 18 changed files with 890 additions and 307 deletions.
4 changes: 2 additions & 2 deletions Src/FluentAssertions/AssertionExtensions.cs
Expand Up @@ -71,7 +71,7 @@ public static Func<Task> Awaiting<T>(this T subject, Func<T, Task> action)

/// <summary>
/// Invokes the specified action on a subject so that you can chain it
/// with any of the assertions from <see cref="AsyncFunctionAssertions"/>
/// with any of the assertions from <see cref="NonGenericAsyncFunctionAssertions"/>
/// </summary>
[Pure]
public static Func<Task> Awaiting<T>(this T subject, Func<T, ValueTask> action)
Expand All @@ -81,7 +81,7 @@ public static Func<Task> Awaiting<T>(this T subject, Func<T, ValueTask> action)

/// <summary>
/// Invokes the specified action on a subject so that you can chain it
/// with any of the assertions from <see cref="AsyncFunctionAssertions"/>
/// with any of the assertions from <see cref="GenericAsyncFunctionAssertions{TResult}"/>
/// </summary>
[Pure]
public static Func<Task<TResult>> Awaiting<T, TResult>(this T subject, Func<T, ValueTask<TResult>> action)
Expand Down
8 changes: 1 addition & 7 deletions Src/FluentAssertions/Specialized/ActionAssertions.cs
Expand Up @@ -8,22 +8,16 @@ namespace FluentAssertions.Specialized
/// Contains a number of methods to assert that an <see cref="Action"/> yields the expected result.
/// </summary>
[DebuggerNonUserCode]
public class ActionAssertions : DelegateAssertions<Action>
public class ActionAssertions : DelegateAssertions<Action, ActionAssertions>
{
public ActionAssertions(Action subject, IExtractExceptions extractor) : this(subject, extractor, new Clock())
{
}

public ActionAssertions(Action subject, IExtractExceptions extractor, IClock clock) : base(subject, extractor, clock)
{
Subject = subject;
}

/// <summary>
/// Gets the <see cref="Action"/> that is being asserted.
/// </summary>
public new Action Subject { get; }

protected override void InvokeSubject()
{
Subject();
Expand Down
101 changes: 87 additions & 14 deletions Src/FluentAssertions/Specialized/AsyncFunctionAssertions.cs
Expand Up @@ -11,22 +11,18 @@ namespace FluentAssertions.Specialized
/// Contains a number of methods to assert that an asynchronous method yields the expected result.
/// </summary>
[DebuggerNonUserCode]
public class AsyncFunctionAssertions : DelegateAssertions<Func<Task>>
public class AsyncFunctionAssertions<TTask, TAssertions> : DelegateAssertions<Func<TTask>, TAssertions>
where TTask : Task
where TAssertions : AsyncFunctionAssertions<TTask, TAssertions>
{
public AsyncFunctionAssertions(Func<Task> subject, IExtractExceptions extractor) : this(subject, extractor, new Clock())
public AsyncFunctionAssertions(Func<TTask> subject, IExtractExceptions extractor) : this(subject, extractor, new Clock())
{
}

public AsyncFunctionAssertions(Func<Task> subject, IExtractExceptions extractor, IClock clock) : base(subject, extractor, clock)
public AsyncFunctionAssertions(Func<TTask> subject, IExtractExceptions extractor, IClock clock) : base(subject, extractor, clock)
{
Subject = subject;
}

/// <summary>
/// Gets the <see cref="Func{Task}"/> that is being asserted.
/// </summary>
public new Func<Task> Subject { get; }

protected override string Identifier => "async function";

private protected override bool CanHandleAsync => true;
Expand All @@ -36,6 +32,77 @@ protected override void InvokeSubject()
Subject.ExecuteInDefaultSynchronizationContext().GetAwaiter().GetResult();
}

/// <summary>
/// Asserts that the current <typeparamref name="TTask"/> will complete within specified time.
/// </summary>
/// <param name="timeSpan">The allowed time span for the operation.</param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<TAssertions> CompleteWithin(
TimeSpan timeSpan, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(Subject is object)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:task} to complete within {0}{reason}, but found <null>.", timeSpan);

TTask task = Subject.ExecuteInDefaultSynchronizationContext();
bool completed = Clock.Wait(task, timeSpan);

Execute.Assertion
.ForCondition(completed)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:task} to complete within {0}{reason}.", timeSpan);

return new AndConstraint<TAssertions>((TAssertions)this);
}

/// <summary>
/// Asserts that the current <typeparamref name="TTask"/> will complete within the specified time.
/// </summary>
/// <param name="timeSpan">The allowed time span for the operation.</param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public async Task<AndConstraint<TAssertions>> CompleteWithinAsync(
TimeSpan timeSpan, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(Subject is object)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:task} to complete within {0}{reason}, but found <null>.", timeSpan);

using (var timeoutCancellationTokenSource = new CancellationTokenSource())
{
TTask task = Subject.ExecuteInDefaultSynchronizationContext();

Task completedTask =
await Task.WhenAny(task, Clock.DelayAsync(timeSpan, timeoutCancellationTokenSource.Token));

if (completedTask == task)
{
timeoutCancellationTokenSource.Cancel();
await completedTask;
}

Execute.Assertion
.ForCondition(completedTask == task)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:task} to complete within {0}{reason}.", timeSpan);

return new AndConstraint<TAssertions>((TAssertions)this);
}
}

/// <summary>
/// Asserts that the current <see cref="Func{Task}"/> throws an exception of the exact type <typeparamref name="TException"/> (and not a derived exception type).
/// </summary>
Expand Down Expand Up @@ -108,7 +175,7 @@ protected override void InvokeSubject()
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public async Task NotThrowAsync(string because = "", params object[] becauseArgs)
public async Task<AndConstraint<TAssertions>> NotThrowAsync(string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(Subject is object)
Expand All @@ -123,6 +190,8 @@ public async Task NotThrowAsync(string because = "", params object[] becauseArgs
{
NotThrow(exception, because, becauseArgs);
}

return new AndConstraint<TAssertions>((TAssertions)this);
}

/// <summary>
Expand All @@ -135,7 +204,7 @@ public async Task NotThrowAsync(string because = "", params object[] becauseArgs
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public async Task NotThrowAsync<TException>(string because = "", params object[] becauseArgs)
public async Task<AndConstraint<TAssertions>> NotThrowAsync<TException>(string because = "", params object[] becauseArgs)
where TException : Exception
{
Execute.Assertion
Expand All @@ -151,6 +220,8 @@ public async Task NotThrowAsync<TException>(string because = "", params object[]
{
NotThrow<TException>(exception, because, becauseArgs);
}

return new AndConstraint<TAssertions>((TAssertions)this);
}

/// <summary>
Expand All @@ -176,7 +247,7 @@ public async Task NotThrowAsync<TException>(string because = "", params object[]
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">Throws if waitTime or pollInterval are negative.</exception>
public Task NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
public Task<AndConstraint<TAssertions>> NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
{
if (waitTime < TimeSpan.Zero)
{
Expand All @@ -198,7 +269,7 @@ public Task NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string

return assertionTask();

async Task assertionTask()
async Task<AndConstraint<TAssertions>> assertionTask()
{
TimeSpan? invocationEndTime = null;
Exception exception = null;
Expand All @@ -209,7 +280,7 @@ async Task assertionTask()
exception = await InvokeWithInterceptionAsync(wrappedSubject);
if (exception is null)
{
return;
return new AndConstraint<TAssertions>((TAssertions)this);
}

await Clock.DelayAsync(pollInterval, CancellationToken.None);
Expand All @@ -219,6 +290,8 @@ async Task assertionTask()
Execute.Assertion
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect any exceptions after {0}{reason}, but found {1}.", waitTime, exception);

return new AndConstraint<TAssertions>((TAssertions)this);
}
}

Expand Down
27 changes: 11 additions & 16 deletions Src/FluentAssertions/Specialized/DelegateAssertions.cs
Expand Up @@ -4,15 +4,16 @@
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using FluentAssertions.Common;
using FluentAssertions.Execution;
using FluentAssertions.Primitives;

namespace FluentAssertions.Specialized
{
[DebuggerNonUserCode]
public abstract class DelegateAssertions<TDelegate> : ReferenceTypeAssertions<Delegate, DelegateAssertions<TDelegate>> where TDelegate : Delegate
public abstract class DelegateAssertions<TDelegate, TAssertions> : ReferenceTypeAssertions<TDelegate, DelegateAssertions<TDelegate, TAssertions>>
where TDelegate : Delegate
where TAssertions : DelegateAssertions<TDelegate, TAssertions>
{
private readonly IExtractExceptions extractor;

Expand All @@ -24,14 +25,8 @@ private protected DelegateAssertions(TDelegate @delegate, IExtractExceptions ext
{
this.extractor = extractor ?? throw new ArgumentNullException(nameof(extractor));
Clock = clock ?? throw new ArgumentNullException(nameof(clock));
Subject = @delegate;
}

/// <summary>
/// Gets the <typeparamref name="TDelegate"/> that is being asserted.
/// </summary>
public new TDelegate Subject { get; }

private protected IClock Clock { get; }

private protected virtual bool CanHandleAsync => false;
Expand Down Expand Up @@ -69,7 +64,7 @@ public ExceptionAssertions<TException> Throw<TException>(string because = "", pa
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<DelegateAssertions<TDelegate>> NotThrow<TException>(string because = "", params object[] becauseArgs)
public AndConstraint<TAssertions> NotThrow<TException>(string because = "", params object[] becauseArgs)
where TException : Exception
{
Execute.Assertion
Expand All @@ -92,7 +87,7 @@ public AndConstraint<DelegateAssertions<TDelegate>> NotThrow<TException>(string
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<DelegateAssertions<TDelegate>> NotThrow(string because = "", params object[] becauseArgs)
public AndConstraint<TAssertions> NotThrow(string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(Subject is object)
Expand Down Expand Up @@ -167,7 +162,7 @@ public AndConstraint<DelegateAssertions<TDelegate>> NotThrow(string because = ""
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">Throws if waitTime or pollInterval are negative.</exception>
public AndConstraint<DelegateAssertions<TDelegate>> NotThrowAfter(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
public AndConstraint<TAssertions> NotThrowAfter(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(Subject is object)
Expand Down Expand Up @@ -206,7 +201,7 @@ public AndConstraint<DelegateAssertions<TDelegate>> NotThrowAfter(TimeSpan waitT
.ForCondition(exception is null)
.FailWith("Did not expect any exceptions after {0}{reason}, but found {1}.", waitTime, exception);

return new AndConstraint<DelegateAssertions<TDelegate>>(this);
return new AndConstraint<TAssertions>((TAssertions)this);
}

protected ExceptionAssertions<TException> Throw<TException>(Exception exception, string because, object[] becauseArgs)
Expand All @@ -231,25 +226,25 @@ protected ExceptionAssertions<TException> Throw<TException>(Exception exception,
return new ExceptionAssertions<TException>(expectedExceptions);
}

protected AndConstraint<DelegateAssertions<TDelegate>> NotThrow(Exception exception, string because, object[] becauseArgs)
protected AndConstraint<TAssertions> NotThrow(Exception exception, string because, object[] becauseArgs)
{
Execute.Assertion
.ForCondition(exception is null)
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect any exception{reason}, but found {0}.", exception);

return new AndConstraint<DelegateAssertions<TDelegate>>(this);
return new AndConstraint<TAssertions>((TAssertions)this);
}

protected AndConstraint<DelegateAssertions<TDelegate>> NotThrow<TException>(Exception exception, string because, object[] becauseArgs) where TException : Exception
protected AndConstraint<TAssertions> NotThrow<TException>(Exception exception, string because, object[] becauseArgs) where TException : Exception
{
IEnumerable<TException> exceptions = extractor.OfType<TException>(exception);
Execute.Assertion
.ForCondition(!exceptions.Any())
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect {0}{reason}, but found {1}.", typeof(TException), exception);

return new AndConstraint<DelegateAssertions<TDelegate>>(this);
return new AndConstraint<TAssertions>((TAssertions)this);
}

protected abstract void InvokeSubject();
Expand Down
64 changes: 64 additions & 0 deletions Src/FluentAssertions/Specialized/FunctionAssertionHelpers.cs
@@ -0,0 +1,64 @@
using System;
using FluentAssertions.Common;
using FluentAssertions.Execution;

namespace FluentAssertions.Specialized
{
internal class FunctionAssertionHelpers
{
internal static T NotThrow<T>(Func<T> subject, string because, object[] becauseArgs)
{
try
{
return subject();
}
catch (Exception exception)
{
Execute.Assertion
.ForCondition(exception is null)
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect any exception{reason}, but found {0}.", exception);

return default;
}
}

internal static TResult NotThrowAfter<TResult>(Func<TResult> subject, IClock clock, TimeSpan waitTime, TimeSpan pollInterval, string because, object[] becauseArgs)
{
if (waitTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(waitTime), $"The value of {nameof(waitTime)} must be non-negative.");
}

if (pollInterval < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(pollInterval), $"The value of {nameof(pollInterval)} must be non-negative.");
}

TimeSpan? invocationEndTime = null;
Exception exception = null;
ITimer timer = clock.StartTimer();

while (invocationEndTime is null || invocationEndTime < waitTime)
{
try
{
return subject();
}
catch (Exception ex)
{
exception = ex;
}

clock.Delay(pollInterval);
invocationEndTime = timer.Elapsed;
}

Execute.Assertion
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect any exceptions after {0}{reason}, but found {1}.", waitTime, exception);

return default;
}
}
}

0 comments on commit dd4af2f

Please sign in to comment.