Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensures expectations involving multiple assertions can be cleared #925

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 5 additions & 3 deletions Src/FluentAssertions/Collections/CollectionAssertions.cs
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -246,7 +246,7 @@ public AndConstraint<TAssertions> Equal(IEnumerable expected, string because = "

ICollection<TExpected> expectedItems = expectation.ConvertOrCastToCollection<TExpected>();

AssertionScope assertion = Execute.Assertion.BecauseOf(because, becauseArgs);
IAssertionScope assertion = Execute.Assertion.BecauseOf(because, becauseArgs);
if (subjectIsNull)
{
assertion.FailWith("Expected {context:collection} to be equal to {0}{reason}, but found <null>.", expectedItems);
Expand Down Expand Up @@ -1371,7 +1371,9 @@ public AndConstraint<TAssertions> HaveElementPreceding(object successor, object
.Then
.Given(subject => PredecessorOf(successor, subject))
.ForCondition(predecessor => predecessor.IsSameOrEqualTo(expectation))
.FailWith("but found {0}.", predecessor => predecessor);
.FailWith("but found {0}.", predecessor => predecessor)
.Then
.ClearExpectation();

return new AndConstraint<TAssertions>((TAssertions)this);
}
Expand Down
Expand Up @@ -17,7 +17,7 @@ public static Continuation AssertCollectionsHaveSameCount<T>(ICollection<object>
.AssertCollectionHasNotTooManyItems(subject, expectation);
}

public static Continuation AssertEitherCollectionIsNotEmpty<T>(this AssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
public static Continuation AssertEitherCollectionIsNotEmpty<T>(this IAssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
{
return scope
.ForCondition((subject.Count > 0) || (expectation.Count == 0))
Expand All @@ -30,7 +30,7 @@ public static Continuation AssertEitherCollectionIsNotEmpty<T>(this AssertionSco
Environment.NewLine);
}

public static Continuation AssertCollectionHasEnoughItems<T>(this AssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
public static Continuation AssertCollectionHasEnoughItems<T>(this IAssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
{
return scope
.ForCondition(subject.Count >= expectation.Count)
Expand All @@ -41,7 +41,7 @@ public static Continuation AssertCollectionHasEnoughItems<T>(this AssertionScope
Environment.NewLine);
}

public static Continuation AssertCollectionHasNotTooManyItems<T>(this AssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
public static Continuation AssertCollectionHasNotTooManyItems<T>(this IAssertionScope scope, ICollection<object> subject, ICollection<T> expectation)
{
return scope
.ForCondition(subject.Count <= expectation.Count)
Expand Down
Expand Up @@ -184,7 +184,9 @@ private static Type GetIDictionaryInterface(Type expectedType)
.FailWith("but has additional key(s) {0}", keyDifference.AdditionalKeys)
.Then
.ForCondition(!hasMissingKeys || !hasAdditionalKeys)
.FailWith("but it misses key(s) {0} and has additional key(s) {1}", keyDifference.MissingKeys, keyDifference.AdditionalKeys);
.FailWith("but it misses key(s) {0} and has additional key(s) {1}", keyDifference.MissingKeys, keyDifference.AdditionalKeys)
.Then
.ClearExpectation();
}

private static KeyDifference<TSubjectKey, TExpectedKey> CalculateKeyDifference<TSubjectKey, TSubjectValue, TExpectedKey,
Expand Down
120 changes: 28 additions & 92 deletions Src/FluentAssertions/Execution/AssertionScope.cs
Expand Up @@ -2,6 +2,7 @@

using System;
using System.Linq;
using System.Threading;
using FluentAssertions.Common;

#endregion
Expand All @@ -11,7 +12,7 @@ namespace FluentAssertions.Execution
/// <summary>
/// Represents an implicit or explicit scope within which multiple assertions can be collected.
/// </summary>
public class AssertionScope : IDisposable
public class AssertionScope : IAssertionScope
{
#region Private Definitions

Expand All @@ -25,9 +26,9 @@ public class AssertionScope : IDisposable
private static AssertionScope current;

private AssertionScope parent;
private Func<string> expectation = null;
private readonly bool evaluateCondition = true;
private Func<string> expectation;
private string fallbackIdentifier = "object";
private bool? succeeded;

#endregion

Expand Down Expand Up @@ -69,21 +70,6 @@ public AssertionScope(string context)
/// </summary>
public string Context { get; set; }

/// <summary>
/// Creates a nested scope used during chaining.
/// </summary>
internal AssertionScope(AssertionScope sourceScope, bool sourceSucceeded)
{
assertionStrategy = sourceScope.assertionStrategy;
contextData = sourceScope.contextData;
reason = sourceScope.reason;
useLineBreaks = sourceScope.useLineBreaks;
parent = sourceScope.parent;
expectation = sourceScope.expectation;
evaluateCondition = sourceSucceeded;
Context = sourceScope.Context;
}

/// <summary>
/// Gets the current thread-specific assertion scope.
/// </summary>
Expand All @@ -93,10 +79,7 @@ public static AssertionScope Current
private set => current = value;
}

/// <summary>
/// Indicates that every argument passed into <see cref="FailWith"/> is displayed on a separate line.
/// </summary>
public AssertionScope UsingLineBreaks
public IAssertionScope UsingLineBreaks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 As IAssertionScope doesn't have all the public members of AssertionScope this is a breaking change

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. The members of IAssertionScope are only used in chaining scenarios. I've included a couple of them, but not all. Any strong opinions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tried to invoke AssertionScope-only members myself, I just looked for changes that could break the public API.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally, you're directly using the AssertionScope in a using block. The interface is only used for those complex chaining purposes. In the last commit, I've added almost all of them to the interface, just to be sure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But no, I don't have any strong opinions, except trying to avoid breaking changes.

Just as a note: the remaining differences between AssertionScope and IAssertionScope are:

public string Context { get; set; }
public void AddPreFormattedFailure(string formattedFailureMessage);
void AddNonReportable(string key, object value);
void AddReportable(string key, string value);
T Get<T>(string key);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I know. These are very specific things, mostly only used by BeEquivalentTo.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So? Approved?

{
get
{
Expand All @@ -105,25 +88,12 @@ public AssertionScope UsingLineBreaks
}
}

/// <summary>
/// Gets a value indicating whether or not the last assertion executed through this scope succeeded.
/// </summary>
public bool Succeeded { get; private set; }
public bool Succeeded
{
get => succeeded.HasValue && succeeded.Value;
}

/// <summary>
/// Specify the reason why you expect the condition to be <c>true</c>.
/// </summary>
/// <param name="because">
/// A formatted phrase compatible with <see cref="string.Format(string,object[])"/> explaining why
/// the condition should be satisfied. If the phrase does not start with the word <i>because</i>,
/// it is prepended to the message. If the format of <paramref name="because"/> or
/// <paramref name="becauseArgs"/> is not compatible with <see cref="string.Format(string,object[])"/>,
/// then a warning message is returned instead.
/// </param>
/// <param name="becauseArgs">
/// Zero or more values to use for filling in any <see cref="string.Format(string,object[])"/> compatible placeholders.
/// </param>
public AssertionScope BecauseOf(string because, params object[] becauseArgs)
public IAssertionScope BecauseOf(string because, params object[] becauseArgs)
{
reason = () =>
{
Expand Down Expand Up @@ -156,7 +126,7 @@ public AssertionScope BecauseOf(string because, params object[] becauseArgs)
/// </remarks>
/// <param name="message">The format string that represents the failure message.</param>
/// <param name="args">Optional arguments to any numbered placeholders.</param>
public AssertionScope WithExpectation(string message, params object[] args)
public IAssertionScope WithExpectation(string message, params object[] args)
{
var localReason = reason;
expectation = () =>
Expand All @@ -171,44 +141,30 @@ public AssertionScope WithExpectation(string message, params object[] args)
return this;
}

/// <summary>
/// Allows to safely select the subject for successive assertions, even when the prior assertion has failed.
/// </summary>
/// <paramref name="selector">
/// Selector which result is passed to successive calls to <see cref="ForCondition"/>.
/// </paramref>
public Continuation ClearExpectation()
{
expectation = null;

return new Continuation(this, !succeeded.HasValue || succeeded.Value);
}

public GivenSelector<T> Given<T>(Func<T> selector)
{
return new GivenSelector<T>(selector, evaluateCondition, this);
return new GivenSelector<T>(selector, !succeeded.HasValue || succeeded.Value, this);
}

/// <summary>
/// Specify the condition that must be satisfied.
/// </summary>
/// <param name="condition">
/// If <c>true</c> the assertion will be treated as successful and no exceptions will be thrown.
/// </param>
public AssertionScope ForCondition(bool condition)
public IAssertionScope ForCondition(bool condition)
{
if (evaluateCondition)
{
Succeeded = condition;
}
succeeded = condition;

return this;
}

/// <summary>
/// Sets the failure message when the assertion is not met, or completes the failure message set to a
/// prior call to <see cref="FluentAssertions.Execution.AssertionScope.WithExpectation"/>.
/// <paramref name="failReasonFunc"/> will not be called unless the assertion is not met.
/// </summary>
/// <param name="failReasonFunc">Function returning <see cref="FailReason"/> object on demand. Called only when the assertion is not met.</param>
public Continuation FailWith(Func<FailReason> failReasonFunc)
{
try
{
if (evaluateCondition && !Succeeded)
if (!succeeded.HasValue || !succeeded.Value)
{
string localReason = reason != null ? reason() : "";
var messageBuilder = new MessageBuilder(useLineBreaks);
Expand All @@ -222,35 +178,18 @@ public Continuation FailWith(Func<FailReason> failReasonFunc)
}

assertionStrategy.HandleFailure(result.Capitalize());

succeeded = false;
}

return new Continuation(this, Succeeded);
return new Continuation(this, succeeded.Value);
}
finally
{
Succeeded = false;
succeeded = null;
}
}

/// <summary>
/// Sets the failure message when the assertion is not met, or completes the failure message set to a
/// prior call to <see cref="FluentAssertions.Execution.AssertionScope.WithExpectation"/>.
/// </summary>
/// <remarks>
/// In addition to the numbered <see cref="string.Format(string,object[])"/>-style placeholders, messages may contain a few
/// specialized placeholders as well. For instance, {reason} will be replaced with the reason of the assertion as passed
/// to <see cref="FluentAssertions.Execution.AssertionScope.BecauseOf"/>. Other named placeholders will be replaced with
/// the <see cref="FluentAssertions.Execution.AssertionScope.Current"/> scope data passed through
/// <see cref="FluentAssertions.Execution.AssertionScope.AddNonReportable"/> and
/// <see cref="FluentAssertions.Execution.AssertionScope.AddReportable"/>. Finally, a description of the
/// current subject can be passed through the {context:description} placeholder. This is used in the message if no
/// explicit context is specified through the <see cref="AssertionScope"/> constructor.
/// Note that only 10 <paramref name="args"/> are supported in combination with a {reason}.
/// If an expectation was set through a prior call to <see cref="FluentAssertions.Execution.AssertionScope.WithExpectation"/>,
/// then the failure message is appended to that expectation.
/// </remarks>
/// <param name="message">The format string that represents the failure message.</param>
/// <param name="args">Optional arguments to any numbered placeholders.</param>
public Continuation FailWith(string message, params object[] args)
{
return FailWith(() => new FailReason(message, args));
Expand Down Expand Up @@ -288,9 +227,6 @@ public void AddReportable(string key, string value)
contextData.Add(key, value, Reportability.Reportable);
}

/// <summary>
/// Discards and returns the failures that happened up to now.
/// </summary>
public string[] Discard()
{
return assertionStrategy.DiscardFailures().ToArray();
Expand Down Expand Up @@ -326,9 +262,9 @@ public void Dispose()
}
}

public AssertionScope WithDefaultIdentifier(string identifier)
public IAssertionScope WithDefaultIdentifier(string identifier)
{
this.fallbackIdentifier = identifier;
fallbackIdentifier = identifier;
return this;
}
}
Expand Down
92 changes: 92 additions & 0 deletions Src/FluentAssertions/Execution/ChainedAssertionScope.cs
@@ -0,0 +1,92 @@
using System;

namespace FluentAssertions.Execution
{
/// <summary>
/// Allows chaining multiple assertion scopes together using <see cref="Continuation.Then"/>.
/// </summary>
/// <remarks>
/// If the parent scope has captured a failed assertion, this class ensures that successive assertions
/// are no longer evaluated.
/// </remarks>
public class ContinuedAssertionScope : IAssertionScope
{
private readonly AssertionScope predecessor;
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
private readonly bool predecessorSucceeded;

public ContinuedAssertionScope(AssertionScope predecessor, bool predecessorSucceeded)
{
this.predecessorSucceeded = predecessorSucceeded;
this.predecessor = predecessor;
}

public GivenSelector<T> Given<T>(Func<T> selector)
{
return predecessor.Given<T>(selector);
}

public IAssertionScope ForCondition(bool condition)
{
if (predecessorSucceeded)
{
return predecessor.ForCondition(condition);
}

return this;
}

public Continuation FailWith(Func<FailReason> failReasonFunc)
{
if (predecessorSucceeded)
{
return predecessor.FailWith(failReasonFunc);
}

return new Continuation(predecessor, false);
}

public Continuation FailWith(string message, params object[] args)
{
if (predecessorSucceeded)
{
return predecessor.FailWith(message, args);
}

return new Continuation(predecessor, false);
}

public IAssertionScope BecauseOf(string because, params object[] becauseArgs)
{
return predecessor.BecauseOf(because, becauseArgs);
}

public Continuation ClearExpectation()
{
return predecessor.ClearExpectation();
}

public IAssertionScope WithExpectation(string message, params object[] args)
{
return predecessor.WithExpectation(message, args);
}

public IAssertionScope WithDefaultIdentifier(string identifier)
{
return predecessor.WithDefaultIdentifier(identifier);
}

public IAssertionScope UsingLineBreaks => predecessor.UsingLineBreaks;

public bool Succeeded => predecessor.Succeeded;

public string[] Discard()
{
return predecessor.Discard();
}

public void Dispose()
{
predecessor.Dispose();
}
}
}