Feature testing

Since the features are independent of the environment and the components they become quite straight forward to test. The question is more, what exactly are we testing?

What we want to test in a feature is how we move from one state to an other. We can easily identify the states as they are all explicit:

type State =
  | {
    state: 'UNAUTHENTICATED'
  }
  | {
    state: 'AUTHENTICATING'
  }
  | {
    state: 'AUTHENTICATED'
    user: { username: string }
  }
  | {
    state: 'ERROR'
    error: string
  }

Here the typing tells us what states are involved in our testing, but to figure out exactly what to test we should rather look at the transition implementation. So given these transitions:

import { createReducer } from 'react-states'
import { ApiEvent } from '../environment/api'

export type State =
  | {
    state: 'UNAUTHENTICATED'
  }
  | {
    state: 'AUTHENTICATING'
  }
  | {
    state: 'AUTHENTICATED'
  }
  | {
    state: 'ERROR'
    error: string
  }
  
export type PublicAction =
  | {
    type: 'SIGN_IN'
  }
  
export type PublicFeature = States<State, PublicAction>

export type Feature = PublicFeature
  
export type Transition = StateTransition<State>
  
const reducer = createReducer<Feature>({
  UNAUTHENTICATED: {
    SIGN_IN: (): Transition => ({ state: 'AUTHENTICATING' })
  },
  AUTHENTICATING: {
    'API:SIGN_IN_SUCCESS': ({ user }): Transition => ({ state: 'AUTHENTICATED', user }),
    'API:SIGN_IN_ERROR': ({ error }): Transition => ({ state: 'ERROR', error })
  },
  AUTHENTICATED: {},
  ERROR: {}
})

From this perspective we might be compelled to test if for example the SIGN_IN event indeed leads to a transition to AUTHENTICATING, and if we get a SIGN_IN_SUCCESS we go to AUTHENTICATING. But these kinds of tests has little to no value. The reason is that the implementation expresses these transitions so explicitly and constrained that it would be like testing if true is a boolean. Our declarative API and TypeScript typing ensures we do not go wrong here.

Where we do see the need to test a specific transition is when that transition holds logic. An example being:

const reducer = createReducer<Feature>({
  INVALID: ({ value }): Transition => ({
    state: isValid(value) ? 'VALID' : 'INVALID'
    value
  }),
  VALID: ({ value }): Transition => ({
    state: isValid(value) ? 'VALID' : 'INVALID'
    value
  }),
})

These transitions should be tested to see if the logic actually moves them into the correct state.

What we also want to test is environment effects firing related to a state transition. Here continuing with the authentication example:

useStateEffect(auth, 'AUTHENTICATING', () =>
  api.signIn()
)

Here we would need to create a test which starts in AUTHENTICATING, resolves our signIn effect to a user and verify that we indeed move into the intended AUTHENTICATED state. The same goes for resolving to an error. Do we end up in the UNAUTHENTICATED state?

Best practices

Test descriptions

It is a good idea to start every test description with "Should go to $STATE when...". This makes it easier to separate the tests and verify if you have covered all the state transitions in the feature.

Example test

import React from "react";
import { renderHook } from 'react-states/test'
import { act } from "@testing-library/react";
import { Environment } from "../environment";
import { createApi } from '../environment/Api/test'
import { AuthProvider, AuthState, useAuth } from './Auth'

describe('Auth', () => {
  test('Should go to AUTHENTICATED when signing in successfully', () => {
    // We create our mocked API to manually resolve
    const api = createApi()
    
    // We render out the feature with the mocked
    // environment and its consumer to do testing on. This
    // is done by using the test helper where the first
    // argument returns the hook, and the second attaches
    // a component representing that hook into the
    // render tree
    const [auth, dispatch] = renderHook(
      () => useAuth(),
      (UseAuth) => (
        <Environment environment={{api}}>
          <AuthProvider initialState={{ state: 'UNAUTHENTICATED' }}>
            <UseAuth />
          </AuthProvider>  
        </Environment>
      )
    )
    
    // The SIGN_IN causes an immediate state transition to
    // AUTHENTICATING, which is why we use "act"
    act(() => {
      dispatch({ type: 'SIGN_IN' })
    })
    
    // We'll check the intermediate state as well
    expect(auth.state).toBe('AUTHENTICATING')
    
    // At this point our AUTHENTICATING effect has
    // fired and our "signIn" has been called
    expect(api.signIn).toBeCalled()
    
    act(() => {
      // We emit an event from the api environment
      // effect to simulate that it has been triggered.
      // Since we are using subscriptions, all of this
      // happens synchronously
      api.subscription.emit({
        type: 'API:SIGN_IN_SUCCESS',
        user: { username: 'Karen' }
      })
    })
    
    expect(auth).toEqual<AuthState>({
      state: 'AUTHENTICATED',
      user: { username: 'Karen' }
    })
  })
})

Best practices

Writing environment effect mocks

Most environment effects are very straight forward to write. Since our environment effects are based on subscriptions we can test them synchronously, even though they run asynchronously when the application runs.

Given that we have an interface of:

environment/Api/index.ts
import { Subscription } from 'react-states'

export interface User {
  username: string
}

export type ApiEvent = 
  | {
    type: 'API:SIGN_IN_SUCCESS'
    user: User
  }
  | {
    type: 'API:SIGN_IN_ERROR'
    error: string
  }

export interface Api {
  subscription: Subscription<ApiEvent>
  signIn(): void
}

We can create a mock like this:

environment/Api/test.ts
import { createSubscription } from 'react-states'
import { Api } from './'

// We make every method on the interface a mocked
// version
export const createApi = (): Api => ({
  subscription: createSubscription(),
  signIn: jest.fn()
})

With the subscription in place we can just use a simple jest function mock for the rest of the API surface. This way we can check if the environment API has been called, with what arguments and then simulate a result by firing off an event from the subscription.

Last updated

Was this helpful?