Skip to content

Commit 2a7964d

Browse files
authored
Merge pull request #5 from fluentassertions/pushmatch-assertions
Pushmatch assertions
2 parents 23a2ae9 + 62530b5 commit 2a7964d

File tree

2 files changed

+196
-5
lines changed

2 files changed

+196
-5
lines changed

Src/FluentAssertions.Reactive/ReactiveAssertions.cs

+132-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Linq.Expressions;
45
using System.Reactive;
56
using System.Reactive.Linq;
67
using System.Reactive.Threading.Tasks;
@@ -9,6 +10,7 @@
910
using FluentAssertions.Execution;
1011
using FluentAssertions.Primitives;
1112
using FluentAssertions.Specialized;
13+
using JetBrains.Annotations;
1214
using Microsoft.Reactive.Testing;
1315

1416
namespace FluentAssertions.Reactive
@@ -106,18 +108,18 @@ public async Task<AndWhichConstraint<ReactiveAssertions<TPayload>, IEnumerable<T
106108
}
107109

108110
/// <summary>
109-
/// Asserts that at least <paramref name="numberOfNotifications"/> notifications are pushed to the <see cref="FluentTestObserver{TPayload}"/> within the next 1 second.<br />
111+
/// Asserts that at least <paramref name="numberOfNotifications"/> notifications are pushed to the <see cref="FluentTestObserver{TPayload}"/> within the next 1 seconds.<br />
110112
/// This includes any previously recorded notifications since it has been created or cleared.
111113
/// </summary>
112114
/// <param name="numberOfNotifications">the number of notifications the observer should have recorded by now</param>
113115
/// <param name="because"></param>
114116
/// <param name="becauseArgs"></param>
115117
public AndWhichConstraint<ReactiveAssertions<TPayload>, IEnumerable<TPayload>> Push(int numberOfNotifications, string because = "", params object[] becauseArgs)
116-
=> Push(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs);
118+
=> Push(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs);
117119

118120
/// <inheritdoc cref="Push(int,string,object[])"/>
119121
public Task<AndWhichConstraint<ReactiveAssertions<TPayload>, IEnumerable<TPayload>>> PushAsync(int numberOfNotifications, string because = "", params object[] becauseArgs)
120-
=> PushAsync(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs);
122+
=> PushAsync(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs);
121123

122124
/// <summary>
123125
/// Asserts that at least 1 notification is pushed to the <see cref="FluentTestObserver{TPayload}"/> within the next 1 second.<br />
@@ -248,6 +250,133 @@ public AndConstraint<ReactiveAssertions<TPayload>> NotComplete(TimeSpan timeout,
248250
public AndConstraint<ReactiveAssertions<TPayload>> NotComplete(string because = "", params object[] becauseArgs)
249251
=> NotComplete(TimeSpan.FromMilliseconds(100), because, becauseArgs);
250252

253+
254+
/// <summary>
255+
/// Asserts that at least one notification matching <paramref name="predicate"/> was pushed to the <see cref="FluentTestObserver{TPayload}"/>
256+
/// within the specified <paramref name="timeout"/>.<br />
257+
/// This includes any previously recorded notifications since it has been created or cleared.
258+
/// </summary>
259+
/// <param name="predicate">A predicate to match the items in the collection against.</param>
260+
/// <param name="timeout">the maximum time to wait for the notification to arrive</param>
261+
/// <param name="because">
262+
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
263+
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
264+
/// </param>
265+
/// <param name="becauseArgs">
266+
/// Zero or more objects to format using the placeholders in <paramref name="because"/>.
267+
/// </param>
268+
/// <exception cref="ArgumentNullException"><paramref name="predicate"/> is <c>null</c>.</exception>
269+
public AndConstraint<ReactiveAssertions<TPayload>> PushMatch(
270+
[NotNull] Expression<Func<TPayload, bool>> predicate,
271+
TimeSpan timeout,
272+
string because = "",
273+
params object[] becauseArgs)
274+
{
275+
if (predicate == null) throw new ArgumentNullException(nameof(predicate));
276+
277+
IList<TPayload> notifications = new List<TPayload>();
278+
279+
try
280+
{
281+
Func<TPayload, bool> func = predicate.Compile();
282+
notifications = Observer.RecordedNotificationStream
283+
.Select(r => r.Value)
284+
.Dematerialize()
285+
.Where(func)
286+
.Take(1)
287+
.Timeout(timeout)
288+
.Catch<TPayload, TimeoutException>(exception => Observable.Empty<TPayload>())
289+
.ToList()
290+
.ToTask()
291+
.ExecuteInDefaultSynchronizationContext();
292+
}
293+
catch (Exception e)
294+
{
295+
if (e is AggregateException aggregateException)
296+
e = aggregateException.InnerException;
297+
Execute.Assertion
298+
.BecauseOf(because, becauseArgs)
299+
.FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e);
300+
}
301+
302+
Execute.Assertion
303+
.BecauseOf(because, becauseArgs)
304+
.ForCondition(notifications.Any())
305+
.FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout);
306+
307+
return new AndConstraint<ReactiveAssertions<TPayload>>(this);
308+
}
309+
310+
/// <summary>
311+
/// Asserts that at least one notification matching <paramref name="predicate"/> was pushed to the <see cref="FluentTestObserver{TPayload}"/>
312+
/// within the next 1 second.<br />
313+
/// This includes any previously recorded notifications since it has been created or cleared.
314+
/// </summary>
315+
/// <param name="predicate">A predicate to match the items in the collection against.</param>
316+
/// <param name="timeout">the maximum time to wait for the notification to arrive</param>
317+
/// <param name="because">
318+
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
319+
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
320+
/// </param>
321+
/// <param name="becauseArgs">
322+
/// Zero or more objects to format using the placeholders in <paramref name="because"/>.
323+
/// </param>
324+
/// <exception cref="ArgumentNullException"><paramref name="predicate"/> is <c>null</c>.</exception>
325+
public AndConstraint<ReactiveAssertions<TPayload>> PushMatch(
326+
[NotNull] Expression<Func<TPayload, bool>> predicate,
327+
string because = "",
328+
params object[] becauseArgs)
329+
=> PushMatch(predicate, TimeSpan.FromSeconds(1), because, becauseArgs);
330+
331+
/// <inheritdoc cref="PushMatch(Expression{Func{TPayload, bool}},TimeSpan,string,object[])"/>
332+
public async Task<AndConstraint<ReactiveAssertions<TPayload>>> PushMatchAsync(
333+
[NotNull] Expression<Func<TPayload, bool>> predicate,
334+
TimeSpan timeout,
335+
string because = "",
336+
params object[] becauseArgs)
337+
{
338+
if (predicate == null)
339+
throw new ArgumentNullException(nameof(predicate));
340+
341+
IList<TPayload> notifications = new List<TPayload>();
342+
343+
try
344+
{
345+
Func<TPayload, bool> func = predicate.Compile();
346+
notifications = await Observer.RecordedNotificationStream
347+
.Select(r => r.Value)
348+
.Dematerialize()
349+
.Where(func)
350+
.Take(1)
351+
.Timeout(timeout)
352+
.Catch<TPayload, TimeoutException>(exception => Observable.Empty<TPayload>())
353+
.ToList()
354+
.ToTask().ConfigureAwait(false);
355+
}
356+
catch (Exception e)
357+
{
358+
if (e is AggregateException aggregateException)
359+
e = aggregateException.InnerException;
360+
Execute.Assertion
361+
.BecauseOf(because, becauseArgs)
362+
.FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e);
363+
}
364+
365+
Execute.Assertion
366+
.BecauseOf(because, becauseArgs)
367+
.ForCondition(notifications.Any())
368+
.FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout);
369+
370+
return new AndWhichConstraint<ReactiveAssertions<TPayload>, IEnumerable<TPayload>>(this, notifications);
371+
}
372+
373+
/// <inheritdoc cref="PushMatch(Expression{Func{TPayload, bool}},string,object[])"/>
374+
public Task<AndConstraint<ReactiveAssertions<TPayload>>> PushMatchAsync(
375+
[NotNull] Expression<Func<TPayload, bool>> predicate,
376+
string because = "",
377+
params object[] becauseArgs)
378+
=> PushMatchAsync(predicate, TimeSpan.FromSeconds(1), because, becauseArgs);
379+
251380
protected Task<IList<Recorded<Notification<TPayload>>>> GetRecordedNotifications(TimeSpan timeout) =>
252381
Observer.RecordedNotificationStream
253382
.TakeUntil(recorded => recorded.Value.Kind == NotificationKind.OnError)

Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs

+64-2
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ public void When_the_observable_is_expected_to_fail_but_does_not_it_should_throw
126126

127127
observer.Error.Should().BeNull();
128128
}
129-
130-
129+
131130
[Fact]
132131
public void When_the_observable_completes_as_expected_it_should_not_throw()
133132
{
@@ -159,5 +158,68 @@ public void When_the_observable_is_expected_to_complete_but_does_not_it_should_t
159158
observer.Error.Should().BeNull();
160159
}
161160

161+
[Fact]
162+
public void When_the_observable_pushes_an_expected_match_it_should_not_throw()
163+
{
164+
var scheduler = new TestScheduler();
165+
var observable = scheduler.CreateColdObservable(
166+
OnNext(100, 1),
167+
OnNext(200, 2));
168+
169+
// observe the sequence
170+
using var observer = observable.Observe(scheduler);
171+
// push subscriptions
172+
scheduler.AdvanceTo(250);
173+
174+
// Act
175+
Action act = () => observer.Should().PushMatch(i => i > 1);
176+
177+
// Assert
178+
act.Should().NotThrow();
179+
180+
observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages);
181+
}
182+
183+
[Fact]
184+
public void When_the_observable_does_not_push_a_match_it_should_throw()
185+
{
186+
var scheduler = new TestScheduler();
187+
var observable = scheduler.CreateColdObservable(
188+
OnNext(100, 1),
189+
OnNext(200, 2));
190+
191+
// observe the sequence
192+
using var observer = observable.Observe(scheduler);
193+
// push subscriptions
194+
scheduler.AdvanceTo(250);
195+
196+
// Act
197+
Action act = () => observer.Should().PushMatch(i => i > 3, TimeSpan.FromMilliseconds(1));
198+
199+
// Assert
200+
act.Should().Throw<XunitException>().WithMessage(
201+
$"Expected observable to push an item matching (i > 3) within {Formatter.ToString(TimeSpan.FromMilliseconds(1))}.");
202+
203+
observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages);
204+
}
205+
206+
[Fact]
207+
public void When_the_observable_fails_instead_of_pushing_a_match_it_should_throw()
208+
{
209+
var exception = new ArgumentException("That was wrong.");
210+
var scheduler = new TestScheduler();
211+
var observable = scheduler.CreateColdObservable(
212+
OnError<int>(1, exception));
213+
214+
// observe the sequence
215+
using var observer = observable.Observe(scheduler);
216+
scheduler.AdvanceTo(10);
217+
// Act
218+
Action act = () => observer.Should().PushMatch(i => i > 1);
219+
// Assert
220+
act.Should().Throw<XunitException>().WithMessage(
221+
$"Expected observable to push an item matching (i > 1), but it failed with a {Formatter.ToString(exception)}.");
222+
observer.Error.Should().BeEquivalentTo(exception);
223+
}
162224
}
163225
}

0 commit comments

Comments
 (0)