Commit 56862127 authored by Simeon Anfinrud's avatar Simeon Anfinrud Committed by Commit Bot

[Chromecast] Add ReactiveRecorder test util class.

This can be used in lieu of mutating an ArrayList and using
Hamcrest matchers to check the behavior of Observables.

I have a couple of in-progress refactors that could use
something like this to help with testing.

Bug: None
Test: cast_base_junit_tests
Change-Id: Ieaac43cc2cb9d7f5b1577a7992b6211035fc36f9
Reviewed-on: https://chromium-review.googlesource.com/982514Reviewed-by: default avatarLuke Halliwell <halliwell@chromium.org>
Commit-Queue: Simeon Anfinrud <sanfin@chromium.org>
Cr-Commit-Position: refs/heads/master@{#547462}
parent ee7d1499
......@@ -309,6 +309,20 @@ if (is_android) {
]
}
android_library("cast_base_test_utils_java") {
java_test_dir = "//chromecast/base/java/test"
testonly = true
java_files = [
"$java_test_dir/org/chromium/chromecast/base/Inheritance.java",
"$java_test_dir/org/chromium/chromecast/base/ReactiveRecorder.java",
]
deps = [
":base_java",
"//third_party/hamcrest:hamcrest_java",
"//third_party/junit",
]
}
junit_binary("cast_base_junit_tests") {
java_test_dir = "//chromecast/base/java/test"
java_files = [
......@@ -316,12 +330,13 @@ if (is_android) {
"$java_test_dir/org/chromium/chromecast/base/CircularBufferTest.java",
"$java_test_dir/org/chromium/chromecast/base/ItertoolsTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableAndControllerTest.java",
"$java_test_dir/org/chromium/chromecast/base/ReactiveRecorderTest.java",
"$java_test_dir/org/chromium/chromecast/base/ScopeFactoriesTest.java",
"$java_test_dir/org/chromium/chromecast/base/TestUtils.java",
"$java_test_dir/org/chromium/chromecast/base/UnitTest.java",
]
deps = [
":base_java",
":cast_base_test_utils_java",
"//third_party/hamcrest:hamcrest_java",
]
}
......
......@@ -4,7 +4,7 @@
package org.chromium.chromecast.base;
class TestUtils {
class Inheritance {
// Use to test consumption of superclasses of some generic parameter.
public static class Base {
@Override
......
......@@ -12,8 +12,8 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.chromium.chromecast.base.TestUtils.Base;
import org.chromium.chromecast.base.TestUtils.Derived;
import org.chromium.chromecast.base.Inheritance.Base;
import org.chromium.chromecast.base.Inheritance.Derived;
import java.util.ArrayList;
import java.util.List;
......
......@@ -12,8 +12,8 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.chromium.chromecast.base.TestUtils.Base;
import org.chromium.chromecast.base.TestUtils.Derived;
import org.chromium.chromecast.base.Inheritance.Base;
import org.chromium.chromecast.base.Inheritance.Derived;
import java.util.ArrayList;
import java.util.List;
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.emptyIterable;
import org.junit.Assert;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Records events emitted by Observables, and provides a fluent interface to perform assertions on
* the received events. Use this in unit tests to get descriptive output for assertion failures.
*/
public class ReactiveRecorder {
private final List<Event> mRecord;
private final Map<Observable<?>, String> mObservableNames;
public static ReactiveRecorder record(Observable<?>... observables) {
return new ReactiveRecorder(observables);
}
private ReactiveRecorder(Observable<?>... observables) {
mRecord = new ArrayList<>();
mObservableNames = new HashMap<>();
int id = 0;
for (Observable<?> observable : observables) {
mObservableNames.put(observable, "Observable" + id++);
observable.watch((Object value) -> {
mRecord.add(enterEvent(observable, value));
return () -> mRecord.add(exitEvent(observable, value));
});
}
}
public ReactiveRecorder reset() {
mRecord.clear();
return this;
}
public Validator verify() {
return new Validator();
}
/**
* The fluent interface used to perform assertions. Each entered() or exited() call pops the
* least-recently-added event from the record, and verifies that it meets the description
* provided by the arguments given to entered() or exited(). Use end() to assert that no more
* events were received.
*/
public class Validator {
private Validator() {}
public Validator entered(Observable observable, Object value) {
Event event = pop();
event.checkType("enter");
event.checkObservable(observable);
event.checkValue(value);
return this;
}
public Validator entered(Object value) {
Event event = pop();
event.checkType("enter");
event.checkValue(value);
return this;
}
public Validator entered() {
Event event = pop();
event.checkType("enter");
return this;
}
public Validator exited(Observable observable) {
Event event = pop();
event.checkType("exit");
event.checkObservable(observable);
return this;
}
public Validator exited() {
Event event = pop();
event.checkType("exit");
return this;
}
public void end() {
Assert.assertThat(mRecord, emptyIterable());
}
}
private Event pop() {
return mRecord.remove(0);
}
private String observableName(Observable<?> observable) {
String name = mObservableNames.get(observable);
if (name == null) {
return "(Unknown)";
}
return name;
}
private Event enterEvent(Observable<?> observable, Object value) {
Event result = new Event();
result.type = "enter";
result.observable = observable;
result.value = value;
return result;
}
private Event exitEvent(Observable<?> observable, Object value) {
Event result = new Event();
result.type = "exit";
result.observable = observable;
result.value = value;
return result;
}
private class Event {
public String type;
public Observable<?> observable;
public Object value;
private Event() {}
public void checkType(String type) {
Assert.assertEquals("Event " + this + " is not an " + type + " event", type, this.type);
}
public void checkObservable(Observable<?> observable) {
Assert.assertEquals("Event " + this + " has wrong observable, expected "
+ observableName(observable),
observable, this.observable);
}
public void checkValue(Object value) {
Assert.assertEquals(
"Event " + this + " has wrong value, expected " + value, value, this.value);
}
@Override
public String toString() {
return "(" + type + " " + observableName(observable) + ": " + value + ")";
}
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
/**
* Tests that assertionss of ReactiveRecorder are thrown.
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ReactiveRecorderTest {
@Test(expected = AssertionError.class)
public void testFailEndAtStart() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
recorder.verify().end();
}
@Test(expected = AssertionError.class)
public void testFailEndAtEnd() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
controller.reset();
controller.set(Unit.unit());
recorder.verify().entered(Unit.unit()).exited().end();
}
@Test(expected = AssertionError.class)
public void testFailEnteredWrongValue() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set("actual");
recorder.verify().entered("expected");
}
@Test(expected = AssertionError.class)
public void testFailEnteredGotExit() {
Controller<String> controller = new Controller<>();
controller.set("before");
ReactiveRecorder recorder = ReactiveRecorder.record(controller).reset();
controller.set("after");
recorder.verify().entered("after");
}
@Test(expected = AssertionError.class)
public void testFailExitedGotEnter() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
recorder.verify().exited();
}
@Test(expected = AssertionError.class)
public void testEnteredWrongObservable() {
Controller<Unit> controller = new Controller<>();
Controller<Unit> wrong = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller, wrong);
controller.set(Unit.unit());
recorder.verify().entered(wrong, Unit.unit());
}
@Test(expected = AssertionError.class)
public void testExitedWrongObservable() {
Controller<Unit> controller = new Controller<>();
Controller<Unit> wrong = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller, wrong);
controller.set(Unit.unit());
wrong.set(Unit.unit());
recorder.reset();
controller.reset();
recorder.verify().exited(wrong);
}
@Test
public void testHappyPathForOneObservable() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
controller.reset();
controller.set(Unit.unit());
controller.reset();
recorder.verify().entered().exited().entered().exited().end();
}
@Test
public void testHappyPathForManyObservables() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
Controller<String> c = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a, b, c);
a.set("a");
b.set("b");
c.set("c");
b.reset();
a.reset();
c.reset();
recorder.verify()
.entered(a, "a")
.entered(b, "b")
.entered(c, "c")
.exited(b)
.exited(a)
.exited(c)
.end();
}
}
......@@ -12,8 +12,8 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.chromium.chromecast.base.TestUtils.Base;
import org.chromium.chromecast.base.TestUtils.Derived;
import org.chromium.chromecast.base.Inheritance.Base;
import org.chromium.chromecast.base.Inheritance.Derived;
import java.util.ArrayList;
import java.util.List;
......
......@@ -811,6 +811,67 @@ inside `ScopeFactory` event handlers altogether, but there are many safe ways
that are useful. `and()`-composed `Observable`s, for example, use `Controller`s
under the hood to know when to notify.
### Testing
One of the most important aspects of using `Observable`s is that they are very
testable. The `Observable` cleanly separates the concerns of *mutating* program
state and *responding to* program state. Reactors, or observers, registered in
`watch()` methods tend to be **functional**, i.e. with no side effects, though
this isn't a strict requirement (see the above section).
If you write a class that implements `Observable` or returns an `Observable` in
one of its methods, it's easy to test the events it emits by using the
`ReactiveRecorder` test utility function. This class, which is only allowed in
tests, provides a fluent interface for describing the expected output of an
`Observable`.
To use this in your tests, add `//chromecast/base:cast_base_test_utils_java` to
your JUnit test target's GN `deps`.
As an example, imagine we want to test a class called `FlipFlop`, which
implements `Observable` and changes from deactivated to activated every time its
`flip` method is called. The tests might look like this:
```java
import org.chromium.chromecast.base.ReactiveRecorder;
... // other imports
public class FlipFlopTest {
@Test
public void testStartsDeactivated() {
FlipFlop f = new FlipFlop();
ReactiveRecorder recorder = ReactiveRecorder.record(f);
// No events should be emitted.
recorder.verify().end();
}
@Test
public void testFlipOnceActivatesObserver() {
FlipFlop f = new FlipFlop();
ReactiveRecorder recorder = ReactiveRecorder.record(f);
f.flip();
// A single activation should have been emitted.
recorder.verify().entered().end();
}
@Test
public void testFlipTwiceActivatesThenDeactivates() {
FlipFlop f = new FlipFlop();
ReactiveRecorder recorder = ReactiveRecorder.record(f);
f.flip();
f.flip();
// Expect an activation followed by a deactivation.
recorder.verify().entered().exited().end();
}
}
```
`ReactiveRecorder`'s `entered()` and `exited()` methods can also take arguments
to perform assertions on the activation data. `ReactiveRecorder.record()` can
also take arbitrarily many `Observable` arguments and receive the events of all
of the given `Observable`s. In this case, the `entered()` and `exited()` methods
have overloads that take an `Observable` as an argument, which can be used to
assert *which* `Observable` emitted an event.
## When to use Observables
`Observable`s and `Controller`s are intended to succinctly adapt common Android
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment