Skip to content
Given-When-Then for your state-updating functions (e.g. Redux reducers).
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.gitignore
.travis.yml
README.md
package.json

README.md

circumstance

NPM version Build status Code coverage

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.

Example 1: A state updating function

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}`
}

Example 2: A Redux reducer

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))
)

API

This is the public API:

// index.js
export { given } from './given'
export { shouldEqual } from './shouldEqual'
export { withReducer } from './redux/withReducer'
export { state } from './state'

given(state) → Given

Calling given with a state returns a Given object with these methods:

  • and(fn) Apply fn to the state, e.g. to set up the scenario. Returns a Given object.
  • when(fn) Apply fn to the state, e.g. to perform the action you intend to test. Returns a When object.
  • then(...fn, assertion) Performs an assertion by calling assertion with state applied through fn successively from left to right. Returns a Then object. You can think of each fn as a selector. You can omit it, and the assertion 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

The When object

A When object represents a scenario during the time where actions are performed. It has two methods:

  • and(fn) Apply fn to the state, e.g. to perform more actions.
  • then(...fns, assertion) Performs an assertion by calling assertion with state applied by fns successively from left to right. Returns a Then 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

The Then object

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))
)

withReducer(reducer) (Redux)

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

shouldEqual(expectedValue)

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

state(state)

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'))
)

More examples

Example 3: A Todos reducer

// 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'
You can’t perform that action at this time.