From 9328bed599c90cfa549c2439dca520056fa8812d Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 2 Nov 2021 23:02:47 +0100 Subject: [PATCH 1/3] create PushMatch assertion --- .../ReactiveAssertions.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs index 9bd72f3..f7468b8 100644 --- a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs +++ b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Reactive; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; @@ -9,6 +10,7 @@ using FluentAssertions.Execution; using FluentAssertions.Primitives; using FluentAssertions.Specialized; +using JetBrains.Annotations; using Microsoft.Reactive.Testing; namespace FluentAssertions.Reactive @@ -248,6 +250,98 @@ public AndConstraint> NotComplete(TimeSpan timeout, public AndConstraint> NotComplete(string because = "", params object[] becauseArgs) => NotComplete(TimeSpan.FromMilliseconds(100), because, becauseArgs); + + /// + /// Asserts that at least one notification matching was pushed to the + /// within the specified .
+ /// This includes any previously recorded notifications since it has been created or cleared. + ///
+ /// A predicate to match the items in the collection against. + /// the maximum time to wait for the notification to arrive + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is null. + public AndConstraint> PushMatch([NotNull] Expression> predicate, TimeSpan timeout, string because = "", params object[] becauseArgs) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + IList notifications = new List(); + AssertionScope assertion = Execute.Assertion + .WithExpectation("Expected {context:observable} {0} to push an item matching {1}{reason}", Subject, predicate.Body) + .BecauseOf(because, becauseArgs); + + try + { + Func func = predicate.Compile(); + notifications = Observer.RecordedNotificationStream + .Select(r => r.Value) + .Dematerialize() + .Where(func) + .Take(1) + .Timeout(timeout) + .Catch(exception => Observable.Empty()) + .ToList() + .ToTask() + .ExecuteInDefaultSynchronizationContext(); + } + catch (Exception e) + { + if (e is AggregateException aggregateException) + e = aggregateException.InnerException; + assertion.FailWith(", but it failed with a {0}.", e); + } + + assertion + .ForCondition(notifications.Any()) + .FailWith(" within {0}.", timeout); + + return new AndConstraint>(this); + } + + /// + public async Task>> PushMatchAsync([NotNull] Expression> predicate, TimeSpan timeout, + string because = "", params object[] becauseArgs) + { + if (predicate == null) + throw new ArgumentNullException(nameof(predicate)); + + IList notifications = new List(); + AssertionScope assertion = Execute.Assertion + .WithExpectation("Expected {context:observable} {0} to push an item matching {1}{reason}", Subject, predicate.Body) + .BecauseOf(because, becauseArgs); + + try + { + Func func = predicate.Compile(); + notifications = await Observer.RecordedNotificationStream + .Select(r => r.Value) + .Dematerialize() + .Where(func) + .Take(1) + .Timeout(timeout) + .Catch(exception => Observable.Empty()) + .ToList() + .ToTask().ConfigureAwait(false); + } + catch (Exception e) + { + if (e is AggregateException aggregateException) + e = aggregateException.InnerException; + assertion.FailWith(", but it failed with a {0}.", e); + } + + assertion + .ForCondition(notifications.Any()) + .FailWith(" within {0}.", timeout); + + return new AndWhichConstraint, IEnumerable>(this, notifications); + } + protected Task>>> GetRecordedNotifications(TimeSpan timeout) => Observer.RecordedNotificationStream .TakeUntil(recorded => recorded.Value.Kind == NotificationKind.OnError) From 83eaadcc1183c48e2687843c0edb3ce19e4006e8 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 2 Nov 2021 23:10:58 +0100 Subject: [PATCH 2/3] create overloads with default timeout --- .../ReactiveAssertions.cs | 49 ++++++++++++++++--- .../ReactiveAssertionSpecs.cs | 25 +++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs index f7468b8..eed80fd 100644 --- a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs +++ b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs @@ -108,18 +108,18 @@ public async Task, IEnumerable - /// Asserts that at least notifications are pushed to the within the next 1 second.
+ /// Asserts that at least notifications are pushed to the within the next 1 seconds.
/// This includes any previously recorded notifications since it has been created or cleared. /// /// the number of notifications the observer should have recorded by now /// /// public AndWhichConstraint, IEnumerable> Push(int numberOfNotifications, string because = "", params object[] becauseArgs) - => Push(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs); + => Push(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs); /// public Task, IEnumerable>> PushAsync(int numberOfNotifications, string because = "", params object[] becauseArgs) - => PushAsync(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs); + => PushAsync(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs); /// /// Asserts that at least 1 notification is pushed to the within the next 1 second.
@@ -266,7 +266,11 @@ public AndConstraint> NotComplete(string because = /// Zero or more objects to format using the placeholders in . /// /// is null. - public AndConstraint> PushMatch([NotNull] Expression> predicate, TimeSpan timeout, string because = "", params object[] becauseArgs) + public AndConstraint> PushMatch( + [NotNull] Expression> predicate, + TimeSpan timeout, + string because = "", + params object[] becauseArgs) { if (predicate == null) throw new ArgumentNullException(nameof(predicate)); @@ -303,9 +307,33 @@ public AndConstraint> PushMatch([NotNull] Expressio return new AndConstraint>(this); } - /// - public async Task>> PushMatchAsync([NotNull] Expression> predicate, TimeSpan timeout, - string because = "", params object[] becauseArgs) + /// + /// Asserts that at least one notification matching was pushed to the + /// within the next 1 second.
+ /// This includes any previously recorded notifications since it has been created or cleared. + ///
+ /// A predicate to match the items in the collection against. + /// the maximum time to wait for the notification to arrive + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is null. + public AndConstraint> PushMatch( + [NotNull] Expression> predicate, + string because = "", + params object[] becauseArgs) + => PushMatch(predicate, TimeSpan.FromSeconds(1), because, becauseArgs); + + /// + public async Task>> PushMatchAsync( + [NotNull] Expression> predicate, + TimeSpan timeout, + string because = "", + params object[] becauseArgs) { if (predicate == null) throw new ArgumentNullException(nameof(predicate)); @@ -342,6 +370,13 @@ public async Task>> PushMatchAsync([N return new AndWhichConstraint, IEnumerable>(this, notifications); } + /// + public Task>> PushMatchAsync( + [NotNull] Expression> predicate, + string because = "", + params object[] becauseArgs) + => PushMatchAsync(predicate, TimeSpan.FromSeconds(1), because, becauseArgs); + protected Task>>> GetRecordedNotifications(TimeSpan timeout) => Observer.RecordedNotificationStream .TakeUntil(recorded => recorded.Value.Kind == NotificationKind.OnError) diff --git a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs index d67659b..b650869 100644 --- a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs +++ b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs @@ -126,8 +126,7 @@ public void When_the_observable_is_expected_to_fail_but_does_not_it_should_throw observer.Error.Should().BeNull(); } - - + [Fact] public void When_the_observable_completes_as_expected_it_should_not_throw() { @@ -159,5 +158,27 @@ public void When_the_observable_is_expected_to_complete_but_does_not_it_should_t observer.Error.Should().BeNull(); } + [Fact] + public void When_the_observable_pushes_an_expected_match_it_should_not_throw() + { + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnNext(100, 1), + OnNext(200, 2), + OnNext(300, 3)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + // push subscriptions + scheduler.AdvanceTo(400); + + // Act + Action act = () => observer.Should().PushMatch(i => i > 1); + + // Assert + act.Should().NotThrow(); + + observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages); + } } } From 62530b51575a051f74b2d5011bc6bf8fe79374b6 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Tue, 2 Nov 2021 23:23:55 +0100 Subject: [PATCH 3/3] test new assertions --- .../ReactiveAssertions.cs | 26 +++++----- .../ReactiveAssertionSpecs.cs | 47 +++++++++++++++++-- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs index eed80fd..4330392 100644 --- a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs +++ b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs @@ -275,9 +275,6 @@ public AndConstraint> PushMatch( if (predicate == null) throw new ArgumentNullException(nameof(predicate)); IList notifications = new List(); - AssertionScope assertion = Execute.Assertion - .WithExpectation("Expected {context:observable} {0} to push an item matching {1}{reason}", Subject, predicate.Body) - .BecauseOf(because, becauseArgs); try { @@ -297,12 +294,15 @@ public AndConstraint> PushMatch( { if (e is AggregateException aggregateException) e = aggregateException.InnerException; - assertion.FailWith(", but it failed with a {0}.", e); + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e); } - - assertion + + Execute.Assertion + .BecauseOf(because, becauseArgs) .ForCondition(notifications.Any()) - .FailWith(" within {0}.", timeout); + .FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout); return new AndConstraint>(this); } @@ -339,9 +339,6 @@ public async Task>> PushMatchAsync( throw new ArgumentNullException(nameof(predicate)); IList notifications = new List(); - AssertionScope assertion = Execute.Assertion - .WithExpectation("Expected {context:observable} {0} to push an item matching {1}{reason}", Subject, predicate.Body) - .BecauseOf(because, becauseArgs); try { @@ -360,12 +357,15 @@ public async Task>> PushMatchAsync( { if (e is AggregateException aggregateException) e = aggregateException.InnerException; - assertion.FailWith(", but it failed with a {0}.", e); + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e); } - assertion + Execute.Assertion + .BecauseOf(because, becauseArgs) .ForCondition(notifications.Any()) - .FailWith(" within {0}.", timeout); + .FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout); return new AndWhichConstraint, IEnumerable>(this, notifications); } diff --git a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs index b650869..786624f 100644 --- a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs +++ b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs @@ -164,13 +164,12 @@ public void When_the_observable_pushes_an_expected_match_it_should_not_throw() var scheduler = new TestScheduler(); var observable = scheduler.CreateColdObservable( OnNext(100, 1), - OnNext(200, 2), - OnNext(300, 3)); + OnNext(200, 2)); // observe the sequence using var observer = observable.Observe(scheduler); // push subscriptions - scheduler.AdvanceTo(400); + scheduler.AdvanceTo(250); // Act Action act = () => observer.Should().PushMatch(i => i > 1); @@ -180,5 +179,47 @@ public void When_the_observable_pushes_an_expected_match_it_should_not_throw() observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages); } + + [Fact] + public void When_the_observable_does_not_push_a_match_it_should_throw() + { + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnNext(100, 1), + OnNext(200, 2)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + // push subscriptions + scheduler.AdvanceTo(250); + + // Act + Action act = () => observer.Should().PushMatch(i => i > 3, TimeSpan.FromMilliseconds(1)); + + // Assert + act.Should().Throw().WithMessage( + $"Expected observable to push an item matching (i > 3) within {Formatter.ToString(TimeSpan.FromMilliseconds(1))}."); + + observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages); + } + + [Fact] + public void When_the_observable_fails_instead_of_pushing_a_match_it_should_throw() + { + var exception = new ArgumentException("That was wrong."); + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnError(1, exception)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + scheduler.AdvanceTo(10); + // Act + Action act = () => observer.Should().PushMatch(i => i > 1); + // Assert + act.Should().Throw().WithMessage( + $"Expected observable to push an item matching (i > 1), but it failed with a {Formatter.ToString(exception)}."); + observer.Error.Should().BeEquivalentTo(exception); + } } }