Given-When-Then for your pure functions (e.g. Redux reducers).
circumstance lets you test your state-updating functions (including Redux reducers) using the Given-When-Then concept.
Note: This library is generated from README.md. That’s why you don’t see any JavaScript file in this repository. What you’re reading is the library’s source code and tests.
Here’s a calculator test. See how natural the test looks!
// examples/state/Calculator.test.js
import { given, shouldEqual } from '../..'
import {
initialState,
textToDisplay,
keyDigit,
pressPlusButton,
pressEqualButton
} from './Calculator'
it('should start with 0', () =>
given(initialState)
.then(textToDisplay, shouldEqual('0'))
)
it('should allow entering digits', () =>
given(initialState)
.when(keyDigit(1))
.and(keyDigit(5))
.then(textToDisplay, shouldEqual('15'))
)
it('should allow adding numbers', () =>
given(initialState)
.and(keyDigit(9))
.when(pressPlusButton)
.then(textToDisplay, shouldEqual('9'))
.when(keyDigit(3))
.then(textToDisplay, shouldEqual('3'))
.when(keyDigit(3))
.then(textToDisplay, shouldEqual('33'))
.when(pressEqualButton)
.then(textToDisplay, shouldEqual('42'))
)
And here’s the calculator state module. See how all functions are pure (although the calculator is still very incomplete and buggy!)
// examples/state/Calculator.js
export const initialState = { current: null, operand: 0 }
// Commands: they return functions that update states
export function keyDigit (digit) {
return state => ({ ...state,
current: (state.current || 0) * 10 + (+digit)
})
}
export function pressPlusButton (state) {
return { ...state,
current: null,
operand: state.current
}
}
export function pressEqualButton (state) {
return { ...state,
current: state.current + state.operand,
operand: null
}
}
// Queries: they return functions that queries states
export function textToDisplay (state) {
return `${state.current || state.operand || 0}`
}
circumstance
can easily be used with Redux reducers.
It contains few extra utility functions for testing reducers.
For example, here’s an example reducer:
// examples/reducers/counter.js
export default function counterReducer (state = 0, action) {
switch (action.type) {
case 'INCREMENT': return state + 1
case 'DECREMENT': return state - 1
}
return state
}
And here’s the corresponding test:
// examples/reducers/counter.test.js
import counterReducer from './counter'
import { given, withReducer, state, shouldEqual } from '../..'
const { dispatch, initialState } = withReducer(counterReducer)
it('should start with 0', () =>
given(initialState)
.then(state, shouldEqual(0))
)
it('should increment', () =>
given(initialState)
.when(dispatch({ type: 'INCREMENT' }))
.then(state, shouldEqual(1))
)
it('should allow repeated increment', () =>
given(initialState)
.when(dispatch({ type: 'INCREMENT' }))
.and(dispatch({ type: 'INCREMENT' }))
.then(state, shouldEqual(2))
)
it('should decrement', () =>
given(initialState)
.when(dispatch({ type: 'DECREMENT' }))
.then(state, shouldEqual(-1))
)
This is the public API:
// index.js
export { given } from './given'
export { shouldEqual } from './shouldEqual'
export { withReducer } from './redux/withReducer'
export { state } from './state'
Calling given
with a state
returns a Given
object with these methods:
and(fn)
Applyfn
to the state, e.g. to set up the scenario. Returns aGiven
object.when(fn)
Applyfn
to the state, e.g. to perform the action you intend to test. Returns aWhen
object.then(...fn, assertion)
Performs an assertion by callingassertion
withstate
applied throughfn
successively from left to right. Returns aThen
object. You can think of eachfn
as a selector. You can omit it, and theassertion
will be called with the state.
// given.js
import when from './_when'
import then from './_then'
export function given (state) {
return {
and: (fn) => given(fn(state)),
when: (fn) => when(fn(state)),
then: (...pipeline) => then(state)(...pipeline)
}
}
export default given
A When object represents a scenario during the time where actions are performed. It has two methods:
and(fn)
Applyfn
to the state, e.g. to perform more actions.then(...fns, assertion)
Performs an assertion by callingassertion
withstate
applied byfns
successively from left to right. Returns aThen
object.
// _when.js
import then from './_then'
export function when (state) {
return {
and: (fn) => when(fn(state)),
then: (...pipeline) => then(state)(...pipeline)
}
}
export default when
A Then represents a scenario after the actions are performed.
and(...fns, assertion)
Perform one more assertion. Returns a Then object.when(fn)
Perform one more action. Returns a When object.
// _then.js
import when from './_when'
export function then (state) {
return (...pipeline) => (pipeline.reduce((x, f) => f(x), state), {
and: (...nextPipeline) => then(state)(...nextPipeline),
when: (g) => when(g(state))
})
}
export default then
// _then.test.js
import { given } from '.'
it('lets me assert the state directly', () =>
given('hello')
.when(state => state + '!')
.then(state => assert(state.length === 6))
)
it('lets me assert a projection of the state', () =>
given('hello')
.when(state => state + '!')
.then(state => state.length, length => assert(length === 6))
)
// Tip: Use functions to make your test look more natural!
import { shouldEqual } from '.'
import { property } from 'lodash'
it('lets me assert multiple times', () =>
given({ x: 5, y: 20 })
.then(property('x'), shouldEqual(5))
.and(property('y'), shouldEqual(20))
)
Give it a Redux-style reducer, it will return two things:
initialState
The initial state of this reducer.dispatch
A function that takes an action object, and returns a function to update the state as if the reducer is called using this action.
// redux/withReducer.js
export function withReducer (reducer) {
return {
initialState: reducer(void 0, { type: '@@redux/INIT' }),
dispatch: action => state => reducer(state, action)
}
}
export default withReducer
Give it an expected value, returns an assertion function that performs a deep comparison against the given value.
// shouldEqual.js
import { deepEqual as assertDeepEqual } from 'assert'
export function shouldEqual (expectedValue) {
return actualValue => assertDeepEqual(actualValue, expectedValue)
}
export default shouldEqual
An identity function. For use with .then()
. (See the test for example.)
// state.js
export function state (currentState) {
return currentState
}
export default state
// state.test.js
import { given, state, shouldEqual } from '.'
it('lets me assert the state directly', () =>
given('hello')
.when(state => state.toUpperCase())
.then(state, shouldEqual('HELLO'))
)
// examples/reducers/todos.test.js
import { given, withReducer, state, shouldEqual } from '../..'
import todosReducer from './todos'
import { ADD_TODO } from '../constants/ActionTypes'
const { dispatch, initialState } = withReducer(todosReducer)
it('should start with a default todo item', () =>
given(initialState)
.then(state, shouldEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}
]))
)
it('should handle ADD_TODO after empty state', () =>
given([ ])
.when(dispatch({
type: ADD_TODO,
text: 'Run the tests'
}))
.then(state, shouldEqual([
{
text: 'Run the tests',
completed: false,
id: 0
}
]))
)
it('should handle more ADD_TODO', () =>
given([
{
text: 'Use Redux',
completed: false,
id: 0
}
])
.when(dispatch({
type: ADD_TODO,
text: 'Run the tests'
}))
.then(state, shouldEqual([
{
text: 'Run the tests',
completed: false,
id: 1
},
{
text: 'Use Redux',
completed: false,
id: 0
}
]))
)
The todos reducer is stolen from Redux docs:
// examples/reducers/todos.js
import { ADD_TODO } from '../constants/ActionTypes'
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
},
...state
]
default:
return state
}
}
And the action types…
// examples/constants/ActionTypes.js
export const ADD_TODO = 'ADD_TODO'