Search code examples
reactjsenzymejestjsrecompose

How to test functions in component with enzyme, jest, recompose, react


Ok, so I'm a little stumped on how to test my component's functionality using enzyme/jest. Still learning how to test my components - I can write simple tests but now I need to make them more complex.

I'd like to know the best way to test that my component's functions are being called properly and that they update state props as they're supposed to. What I'm finding is tricky is that my functions and state all live in my component's props.

If I need to use a spy I'd like to preferably know how to with Jest, but if a dep like Sinon or Jasmine is better suited for the job I'm open to it (just let me know why so I can better understand).

As an example, I have a UserDetails component

const UserDetails = ({
 userInfo,
 onUserInfoUpdate,
 className,
 error,
 title,
 primaryBtnTitle,
 submit,
 secondaryBtnTitle,
 secondaryBtnFunc,
 ...props
}) => (
 <div className={className}>
   <div className="user-details-body">
     <Section title="User details">
       <TextInput
         className="firstName"
         caption="First Name"
         value={userInfo.first}
         onChange={onUserInfoUpdate('first')}
         name="first-name"
         min="1"
         max="30"
         autoComplete="first-name"
       />
       <TextInput
         className="lastName"
         caption="Last Name"
         value={userInfo.last}
         onChange={onUserInfoUpdate('last')}
         name="last-name"
         min="1"
         max="30"
         autoComplete="last-name"
       />
     </Section>
   </div>

   <div className="errorBar">
     {error && <Alert type="danger">{error}</Alert>}
   </div>

   <ActionBar>
     <ButtonGroup>
       <Button type="secondary" onClick={secondaryBtnFunc}>
         {secondaryBtnTitle}
       </Button>
       <Button type="primary" onClick={submit}>
         {primaryBtnTitle}
       </Button>
     </ButtonGroup>
   </ActionBar>
 </div>  

TextInput consists of:

<label className={className}>
 {Boolean(caption) && <Caption>{caption}</Caption>}
 <div className="innerContainer">
   <input value={value} onChange={updateValue} type={type} {...rest} />     
 </div>
</label>

Here is example code of my index.js file that composes my withState and withHandlers to my Component:

import UserDetails from './UserDetails'
import { withState, withHandlers, compose } from 'recompose'

export default compose(
  withState('error', 'updateError', ''),
  withState('userInfo', 'updateUserInfo', {
    first: '',
    last: '',
  }),
  withHandlers({
    onUserInfoUpdate: ({ userInfo, updateUserInfo }) => key => e => {
      e.preventDefault()
      updateCardInfo({
        ...cardInfo,
        [key]: e.target.value,
      })
    },
    submit: ({ userInfo, submitUserInfo }) => key => e => {
      e.preventDefault()
      submitUserInfo(userInfo) 
      //submitUserInfo is a graphQL mutation
      })
    }  
  }) 
)

So far, my test file looks like this:

import React from 'react'
import { mount } from 'enzyme' 
import UserDetails from './'
import BareUserDetails from './UserDetails'

describe('UserDetails handlers', () => {
  let tree, bareTree

  beforeEach(() => {
    tree = mount(
      <ThemeProvider theme={theme}>
        <UserDetails />
      </ThemeProvider>
    )
    bareTree = tree.find(BareUserDetails)
  })

  it('finds BareUserDetails props', () => {
    console.log(bareTree.props())
    console.log(bareTree.props().userInfo)
    console.log(bareTree.find('label.firstName').find('input').props())
  })

})

the console logs return me the right information in regards what I expect to see when I call on them:

//console.log(bareTree.props())
   { error: '',
      updateError: [Function],
      userInfo: { first: '', last: '' },
      updateUserInfo: [Function],
      onUserInfoUpdate: [Function] }

//console.log(bareTree.props().userInfo)
   { first: '', last: '' }

//console.log(bareTree.find('label.firstName').find('input).props()
   { value: '',
      onChange: [Function],
      type: 'text',
      name: 'first-name',
      min: '1',
      max: '30',
      autoComplete: 'first-name' }

Now the question is how I can use them, and the best way. Do I even use my functions or do I just check to see that onChange was called?

UPDATE (sort of)

I've tried this and I get the following:

  it('Input element updates userInfo with name onChange in FirstName input', () => {
    const firstNameInput = bareTree.find('label.firstName').find('input)
    ccNameInput.simulate('change', {target: {value: 'John'}})
    expect(ccNameInput.prop('onChange')).toHaveBeenCalled()
  })

In my terminal I get:

 expect(jest.fn())[.not].toHaveBeenCalled()

    jest.fn() value must be a mock function or spy.
    Received:
      function: [Function anonymous]

If I try and create a spyOn with Jest, however, I get an error that it cannot read the function of 'undefined'.

I've tried spy = jest.spyOn(UserDetails.prototypes, 'onUpdateUserInfo') and spy = jest.spyOn(BareUserDetails.prototypes, 'onUpdateUserInfo') and they both throw the error.


Solution

  • I believe you should probably test the dumb component (UserDetails) and HOC separately. For the dumb component you want to render the component with shallow and inject the props. To mock the onUserInfoUpdate, you need to do const onUserInfoUpdate = jest.fn();

    You want something along the lines of ....

    import React from 'react'
    import { shallow } from 'enzyme' 
    import UserDetails from './UserDetails'
    
    const onUserInfoUpdate = jest.fn(); // spy
    const props = {
      onUserInfoUpdate,
      // list all your other props and assign them mock values
    };
    
    describe('UserDetails', () => {
      let tree;
    
      beforeAll(() => {
        tree = shallow(<UserDetails {...props} />)
      });
    
      it('should invoke the onUserInfoUpdate method', () => {
        const firstNameInput = tree.find('label.firstName').find('input');
        firstNameInput.simulate('change', { target: { value: 'John' } });
    
        expect(onUserInfoUpdate).toHaveBeenCalledWith('first');
      });
    });