simplified testing of user events in RxJS
OK; I admit it. I’ve based my way of using RxJS with react around Rx.Subject. It doesn’t mean I go all wild with them, but it does mean that I keep a strict line between my component’s render function, and the component’s behavior.
Let’s look at an input as a simple component. First of, lets think of how the component will behave.
Note: I don’t have anyone that can proof read this, and I know that there will be mistakes I won’t find. All corrections and critique will be heartfully welcome. In time, we will try to open source a lot of the code with more interesting components.
EDIT: Fixed state reducer to be a bit clearer in how to extend with more reducers. Thanks to dorus in Reactive-Extensions/RxJS gitter chat.
input behavior
The input should simply set its value as a string. Other alternatives would be to append each letter, but since we get the entire value with the onChange event, just setting the value will match better.
The user’s input will be the origin of the setValue event, which we will call a “trigger”. The trigger will just be an imperative function that fires off an event on the event’s corresponding stream.
// components/input/behaviors.jsexport default create({Rx}) {
const setValueSubject = new Subject()
function setValueTrigger(eventData) {
setValueSubject.next(eventData)
} return {
triggers: {
setValue: setValueTrigger
},
streams: {
setValue: setValueSubject.asObservable()
}
}
}
// components/input/index.jsimport Rx from 'rxjs'
import Behaviors from './behaviors'export default function create() {
const behaviors = Behaviors({Rx})
return {
behaviors
}
}
When writing enough basic behaviors, you tend to see that all tests just checks the same stuff. I added some basic type checking as an iterable test until we take the leap into static type land.
// components/component-behaviors-types.test.js
/*
Makes sure each component has the correct API surface
*/import Rx from 'rxjs'
import Test from 'tape'
import Input from './input'// Add all components in here to test them
const Components = {
Input
}Test('triggers type', function onTest(test) {
// Test each component
Object.keys(Components)
.forEach(function onForEachComponentFactory(componentKey) {
const {behaviors: {triggers}} = Components[componentKey]()
// test each trigger in the current component
Object.keys(triggers)
.forEach(function onMapParentObjectKeys(triggerKey) {
const actual = typeof triggers[triggerKey]
const expected = 'function'
test.equal(
actual,
expected,
`${componentKey}.${triggerKey}`
)
})
}
)
test.end()
})Test('streams type', function onTest(test) {
Object.keys(Components)
.forEach(function onForEachComponentFactory(componentKey) {
const {behaviors: {streams}} = Components[componentKey]()
Object.keys(streams)
.forEach(function onMapParentObjectKeys(streamKey) {
const actual = streams[streamKey] instanceof Rx.Observable
const expected = true
test.equal(actual, expected, `${componentKey}.${streamKey}`)
})
})
test.end()
})
There we go! Calling Input()/Component() creates a new instance of our component with our behavior’s trigger and stream. We check the type of the trigger and also the stream.
Bonus: We should also expand this to check that a trigger leads to an event on the correct stream so we don’t pair our triggers with wrong streams.
Now that we have a way to interact with our input, let’s see how we can make a state that contains the current value.
Sweet state
Since our state will just be an overwrite of the previous value, the reduction will be a bit too simple to be interesting. I will basically do an overkill here to show something that scales with multiple reducers.
// components/input/state.jsexport default function create({
Immutable,
behaviorStreams: {setValue}
}) {
const initialStateData = Immutable.Map({
value: ''
}) const setValueReducer = setValue
.map(function onMap(eventData) {
// map will return the reduce function to #scan()
// which holds the stateData
return function reduce(stateData) {
return stateData.set('value', eventData)
}
}) const stream = Rx.Observable
.merge(
setValueReducer,
// possibleOtherReducer
)
.startWith(initialStateData)
// Use #scan() to reduce each event into a new state.
.scan(function onScan(stateData, reduce) {
return reduce(stateData)
}) // give the latest state to the subscriber even though
// the event already happened
.publishReplay(1)
const streamConnection = stream.connect() return {
stream,
streamConnection
}
}// components/input/index.js (v2)import Immutable from 'immutable'
import Rx from 'rxjs'
import Behaviors from './behaviors'
import State from './state'export default function create() {
const behaviors = Behaviors({
Rx
})
const state = State({
Immutable,
behaviorStreams: behaviors.streams
})
return {
behaviors,
state
}
}
Now, since we have used the factory function #create(), we can actually unit test the state without our component. This is more important in more complex components where we don’t want all the behavior coupled with the state.
// components/input/state.test.jsimport Rx from 'rxjs'
import Immutable from 'immutable'
import Test from 'tape'
import State from './state'Test('initial state', function onTest(test) {
test.plan(1)
const expected = {
value: ''
}
const behaviorStreams = {
// We won't emit events, so an empty stream will do
setValue: Rx.Observable.empty()
}
const state = State({
Rx,
Immutable,
behaviorStreams
})
// #take(1) gets the latest state, and then unsubscribes
// in this case, we will get the initial state
state.stream.take(1).subscribe(function onSubscribe(stateData) {
test.deepEqual(stateData.toJS(), expected)
})
})Test('setValue', function onTest(test) {
test.plan(1)
const expected = {
value: 'Marcus Nielsen'
}
const setValueSubject = new Rx.Subject()
const behaviorStreams = {
setValue: setValueSubject
}
const state = State({
Rx,
Immutable,
behaviorStreams
})
state.stream
// Skip initial state
.skip(1)
// take only the next event, then unsubscribe
.take(1)
.subscribe(function onSubscribe(stateData) {
test.deepEqual(stateData.toJS(), expected)
}) setValueSubject.next('Marcus Nielsen')
})
That’s it. You use empty streams on all behavior streams you don’t need. The one you want to test is testable by making a simple Subject. This makes it possible to avoid different kinds of timing issues where something happens after a delay. All that would be contained in the behavior streams.
So that’s it! Hopefully the code speaks for it self, because I’m a terrible blogger :-)
As an extra bonus, let’s add the React render function.
Bonus round: Render
// components/input/render.jsexport default function create({React,triggers: {setValue}}) { function onChange(domEvent) {
setValue(domEvent.target.value)
} function render({state}) {
return (
<input
className="we-use-aphrodite-but-anything-will-do"
type="text"
value={state.get('value')}
onChange={onChange}
/>
)
} render.propTypes = {
state: React.PropTypes.object.isRequired
} return render
}// components/input/index.js (v3)import Immutable from 'immutable'
import React from 'react'
import Rx from 'rxjs'
import Behaviors from './behaviors'
import Render from './render'
import State from './state'export default function create() {
const behaviors = Behaviors({
Rx
})
const render = Render({
React,
triggers: behaviors.triggers
})
const state = State({
Immutable,
streams: behaviors.streams
})
return {
behaviors,
render,
state
}
}
You can subscribe to the state stream and render with const Input = input.render as long as you pass in the state data as a prop. If you have multiple components, just compose the states with #withLatestFrom() and distribute them to each render’s state prop.