Skip to content

Commit

Permalink
introduced assertions for Task and Task<T> with CompleteWithin checks
Browse files Browse the repository at this point in the history
introduce ITimer to abstract real timing in the tests

rework after review

rework after review

Another pass of review.
  • Loading branch information
Lukas Grützmacher authored and dennisdoomen committed May 25, 2019
1 parent 9c87318 commit eae4a19
Show file tree
Hide file tree
Showing 13 changed files with 562 additions and 51 deletions.
41 changes: 41 additions & 0 deletions Src/FluentAssertions/AggregateExceptionExtractor.cs
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions.Common;
using FluentAssertions.Specialized;

namespace FluentAssertions
{
public class AggregateExceptionExtractor : IExtractExceptions
{
public IEnumerable<T> OfType<T>(Exception actualException)
where T : Exception
{
if (typeof(T).IsSameOrInherits(typeof(AggregateException)))
{
return (actualException is T exception) ? new[] { exception } : Enumerable.Empty<T>();
}

return GetExtractedExceptions<T>(actualException);
}

private static List<T> GetExtractedExceptions<T>(Exception actualException)
where T : Exception
{
var exceptions = new List<T>();

if (actualException is AggregateException aggregateException)
{
var flattenedExceptions = aggregateException.Flatten();

exceptions.AddRange(flattenedExceptions.InnerExceptions.OfType<T>());
}
else if (actualException is T genericException)
{
exceptions.Add(genericException);
}

return exceptions;
}
}
}
38 changes: 0 additions & 38 deletions Src/FluentAssertions/AssertionExtensions.Actions.cs
@@ -1,16 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions.Common;
using FluentAssertions.Specialized;

namespace FluentAssertions
{
public static partial class AssertionExtensions
{
private static readonly AggregateExceptionExtractor extractor = new AggregateExceptionExtractor();

/// <summary>
/// Asserts that the <paramref name="actionAssertions"/> subject throws the exact exception (and not a derived exception type).
/// </summary>
Expand Down Expand Up @@ -88,38 +83,5 @@ public static partial class AssertionExtensions
exceptionAssertions.Which.GetType().Should().Be<TException>(because, becauseArgs);
return exceptionAssertions;
}

private class AggregateExceptionExtractor : IExtractExceptions
{
public IEnumerable<T> OfType<T>(Exception actualException)
where T : Exception
{
if (typeof(T).IsSameOrInherits(typeof(AggregateException)))
{
return (actualException is T exception) ? new[] { exception } : Enumerable.Empty<T>();
}

return GetExtractedExceptions<T>(actualException);
}

private static List<T> GetExtractedExceptions<T>(Exception actualException)
where T : Exception
{
var exceptions = new List<T>();

if (actualException is AggregateException aggregateException)
{
var flattenedExceptions = aggregateException.Flatten();

exceptions.AddRange(flattenedExceptions.InnerExceptions.OfType<T>());
}
else if (actualException is T genericException)
{
exceptions.Add(genericException);
}

return exceptions;
}
}
}
}
12 changes: 7 additions & 5 deletions Src/FluentAssertions/AssertionExtensions.cs
Expand Up @@ -25,6 +25,8 @@ namespace FluentAssertions
[DebuggerNonUserCode]
public static partial class AssertionExtensions
{
private static readonly AggregateExceptionExtractor extractor = new AggregateExceptionExtractor();

/// <summary>
/// Invokes the specified action on an subject so that you can chain it with any of the ShouldThrow or ShouldNotThrow
/// overloads.
Expand Down Expand Up @@ -638,19 +640,19 @@ public static ActionAssertions Should(this Action action)
/// current <see cref="System.Func{Task}"/> .
/// </summary>
[Pure]
public static AsyncFunctionAssertions Should(this Func<Task> action)
public static NonGenericAsyncFunctionAssertions Should(this Func<Task> action)
{
return new AsyncFunctionAssertions(action.ExecuteInDefaultSynchronizationContext, extractor);
return new NonGenericAsyncFunctionAssertions(action.ExecuteInDefaultSynchronizationContext, extractor, new TaskTimer());
}

/// <summary>
/// Returns a <see cref="AsyncFunctionAssertions"/> object that can be used to assert the
/// current <see><cref>System.Func{Task{T}}</cref></see>.
/// </summary>
[Pure]
public static AsyncFunctionAssertions Should<T>(this Func<Task<T>> action)
public static GenericAsyncFunctionAssertions<T> Should<T>(this Func<Task<T>> action)
{
return new AsyncFunctionAssertions(action.ExecuteInDefaultSynchronizationContext, extractor);
return new GenericAsyncFunctionAssertions<T>(action.ExecuteInDefaultSynchronizationContext, extractor, new TaskTimer());
}

/// <summary>
Expand All @@ -662,7 +664,7 @@ public static FunctionAssertions<T> Should<T>(this Func<T> func)
{
return new FunctionAssertions<T>(func, extractor);
}


#if NET45 || NET47 || NETCOREAPP2_0

Expand Down
29 changes: 29 additions & 0 deletions Src/FluentAssertions/Common/ITimer.cs
@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace FluentAssertions.Common
{
/// <summary>
/// Represents an abstract timer that is used to make some of this library's timing dependent functionality better testable.
/// </summary>
public interface ITimer
{
/// <summary>
/// Creates a task that will complete after a time delay.
/// </summary>
/// <param name="delay">The time span to wait before completing the returned task</param>
/// <param name="timeoutCancellationTokenSource"></param>
/// <returns>A task that represents the time delay.</returns>
/// <seealso cref="Task.Delay(TimeSpan)"/>
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);

/// <summary>
/// Waits for the task for a specified time.
/// </summary>
/// <param name="task">The task to be waited for.</param>
/// <param name="timeout">The time span to wait.</param>
/// <returns><c>true</c> if the task completes before specified timeout.</returns>
bool Wait(Task task, TimeSpan timeout);
}
}
25 changes: 25 additions & 0 deletions Src/FluentAssertions/Common/TaskTimer.cs
@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace FluentAssertions.Common
{
/// <summary>
/// Default implementation for <see cref="ITimer"/> for production use.
/// </summary>
internal class TaskTimer : ITimer
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
return Task.Delay(delay, cancellationToken);
}

public bool Wait(Task task, TimeSpan timeout)
{
using (NoSynchronizationContextScope.Enter())
{
return task.Wait(timeout);
}
}
}
}
22 changes: 14 additions & 8 deletions Src/FluentAssertions/Specialized/AsyncFunctionAssertions.cs
Expand Up @@ -10,11 +10,11 @@ namespace FluentAssertions.Specialized
/// Contains a number of methods to assert that an asynchronous method yields the expected result.
/// </summary>
[DebuggerNonUserCode]
public class AsyncFunctionAssertions
public abstract class AsyncFunctionAssertions
{
private readonly IExtractExceptions extractor;

public AsyncFunctionAssertions(Func<Task> subject, IExtractExceptions extractor)
protected AsyncFunctionAssertions(Func<Task> subject, IExtractExceptions extractor)
{
this.extractor = extractor;
Subject = subject;
Expand Down Expand Up @@ -52,7 +52,8 @@ public ExceptionAssertions<TException> Throw<TException>(string because = "", pa
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <see cref="because" />.
/// </param>
public async Task<ExceptionAssertions<TException>> ThrowAsync<TException>(string because = "", params object[] becauseArgs)
public async Task<ExceptionAssertions<TException>> ThrowAsync<TException>(string because = "",
params object[] becauseArgs)
where TException : Exception
{
Exception exception = await InvokeSubjectWithInterceptionAsync();
Expand Down Expand Up @@ -159,7 +160,8 @@ private static void NotThrow(Exception exception, string because, object[] becau
nonAggregateException.GetType(), nonAggregateException.ToString());
}

private static void NotThrow<TException>(Exception exception, string because, object[] becauseArgs) where TException : Exception
private static void NotThrow<TException>(Exception exception, string because, object[] becauseArgs)
where TException : Exception
{
Exception nonAggregateException = GetFirstNonAggregateException(exception);

Expand Down Expand Up @@ -205,7 +207,8 @@ public void NotThrowAfter(TimeSpan waitTime, TimeSpan pollInterval, string becau

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

TimeSpan? invocationEndTime = null;
Expand All @@ -219,9 +222,11 @@ public void NotThrowAfter(TimeSpan waitTime, TimeSpan pollInterval, string becau
{
return;
}

Task.Delay(pollInterval).Wait();
invocationEndTime = watch.Elapsed;
}

Execute.Assertion
.BecauseOf(because, becauseArgs)
.FailWith("Did not expect any exceptions after {0}{reason}, but found {1}.", waitTime, exception);
Expand Down Expand Up @@ -251,7 +256,7 @@ public void NotThrowAfter(TimeSpan waitTime, TimeSpan pollInterval, string becau
/// </param>
/// <exception cref="ArgumentOutOfRangeException">Throws if waitTime or pollInterval are negative.</exception>
public
Task NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
Task NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string because = "", params object[] becauseArgs)
{
if (waitTime < TimeSpan.Zero)
{
Expand All @@ -260,7 +265,8 @@ Task NotThrowAfterAsync(TimeSpan waitTime, TimeSpan pollInterval, string because

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

return assertionTask();
Expand Down Expand Up @@ -303,7 +309,7 @@ private static Exception GetFirstNonAggregateException(Exception exception)
private ExceptionAssertions<TException> Throw<TException>(Exception exception, string because, object[] becauseArgs)
where TException : Exception
{
var exceptions = extractor.OfType<TException>(exception);
var exceptions = extractor.OfType<TException>(exception).ToArray();

Execute.Assertion
.ForCondition(exception != null)
Expand Down
81 changes: 81 additions & 0 deletions Src/FluentAssertions/Specialized/GenericAsyncFunctionAssertions.cs
@@ -0,0 +1,81 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions.Common;
using FluentAssertions.Execution;

namespace FluentAssertions.Specialized
{
public class GenericAsyncFunctionAssertions<TResult> : AsyncFunctionAssertions
{
private readonly Func<Task<TResult>> subject;
private readonly ITimer timer;

public GenericAsyncFunctionAssertions(Func<Task<TResult>> subject, IExtractExceptions extractor, ITimer timer) : base(subject, extractor)
{
this.subject = subject;
this.timer = timer;
}

/// <summary>
/// Asserts that the current <see cref="Task{T}"/> 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 <see cref="because" />.
/// </param>
public AndWhichConstraint<GenericAsyncFunctionAssertions<TResult>, Task<TResult>> CompleteWithin(
TimeSpan timeSpan, string because = "", params object[] becauseArgs)
{
Task<TResult> task = subject();
bool completed = timer.Wait(task, timeSpan);

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

return new AndWhichConstraint<GenericAsyncFunctionAssertions<TResult>, Task<TResult>>(this, task);
}

/// <summary>
/// Asserts that the current <see cref="Task{T}"/> 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 <see cref="because" />.
/// </param>
public async Task<AndWhichConstraint<GenericAsyncFunctionAssertions<TResult>, Task<TResult>>> CompleteWithinAsync(
TimeSpan timeSpan, string because = "", params object[] becauseArgs)
{
var timeoutCancellationTokenSource = new CancellationTokenSource();

Task<TResult> task = subject();

Task completedTask =
await Task.WhenAny(task, timer.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 AndWhichConstraint<GenericAsyncFunctionAssertions<TResult>, Task<TResult>>(this, task);
}

}
}

0 comments on commit eae4a19

Please sign in to comment.