0

I am trying to test a React component using Enzyme. Tests worked fine until we converted the component to hooks. Now I am getting the error, "Error: Uncaught [TypeError: Cannot read property 'history' of undefined]"

I have already read through the following similar issues and wasn't able to solve it:

Also this article: * https://medium.com/7shifts-engineering-blog/testing-usecontext-react-hook-with-enzyme-shallow-da062140fc83

Full component, AccessBarWithRouter.jsx:

/**
 * @description Accessibility bar component to allow user to jump focus to different components on screen. 
 * One dropdown will focus to elements on screen.
 * The other will route you to routes in your navigation bar.
 *  
 */

import React, { useState, useEffect, useRef } from 'react';
import Dropdown from 'react-dropdown-aria';
import { useHistory } from 'react-router-dom';

const AccessBarWithRouter = () => {
  const pathname = useHistory().location.pathname;
  const [sectionInfo, setSectionInfo] = useState(null);
  const [navInfo, setNavInfo] = useState(null);
  const [isHidden, setIsHidden] = useState(true);

  // creating the refs to change focus
  const sectionRef = useRef(null);
  const accessBarRef = useRef(null);


  // sets focus on the current page from the 1st dropdown
  const setFocus = e => {
    const currentLabel = sectionInfo[e];
    const currentElement = document.querySelector(`[aria-labelledBy='${currentLabel}']`);
    currentElement.tabIndex = -1;
    sectionRef.current = currentElement;
    // can put a .click() after focus to focus with the enter button
    // works, but gives error
    sectionRef.current.focus();
  };


  // Changes the page when selecting a link from the 2nd dropdown
  const changeView = e => {
    const currentPath = navInfo[e];
    const accessLinks = document.querySelectorAll('.accessNavLink');
    accessLinks.forEach(el => {
      if (el.pathname === currentPath) {
        el.click();
      };
    });
  };

  // event handler to toggle visibility of AccessBar and set focus to it
  const accessBarHandlerKeyDown = e => {
    if (e.altKey && e.keyCode === 191) {
      if (isHidden) {
        setIsHidden(false)
        accessBarRef.current.focus();
      } else setIsHidden(true);
    }
  }


  /**
   *
   * useEffect hook to add and remove the event handler when 'alt' + '/' are pressed  
   * prior to this, multiple event handlers were being added on each button press 
   * */ 
  useEffect(() => {
    document.addEventListener('keydown', accessBarHandlerKeyDown);
    const navNodes = document.querySelectorAll('.accessNavLink');
    const navValues = {};
    navNodes.forEach(el => {
      navValues[el.text] = el.pathname;
    });
    setNavInfo(navValues);
    return () => document.removeEventListener('keydown', accessBarHandlerKeyDown);
  }, [isHidden]);


  /**
   * @todo figure out how to change the dropdown current value after click
   */
  useEffect(() => {
    //  selects all nodes with the aria attribute aria-labelledby
    setTimeout(() => {
      const ariaNodes = document.querySelectorAll('[aria-labelledby]');
      let sectionValues = {};

      ariaNodes.forEach(node => {
        sectionValues[node.getAttribute('aria-labelledby')] = node.getAttribute('aria-labelledby');
      });

      setSectionInfo(sectionValues);
    }, 500);

  }, [pathname]);



  // render hidden h1 based on isHidden
  if (isHidden) return <h1 id='hiddenH1' style={hiddenH1Styles}>To enter navigation assistant, press alt + /.</h1>;

  // function to create dropDownKeys and navKeys 
  const createDropDownValues = dropDownObj => {
    const dropdownKeys = Object.keys(dropDownObj);
    const options = [];
    for (let i = 0; i < dropdownKeys.length; i++) {
      options.push({ value: dropdownKeys[i]});
    }
    return options;
  };

  const sectionDropDown = createDropDownValues(sectionInfo);
  const navInfoDropDown = createDropDownValues(navInfo);

  return (
    <div className ='ally-nav-area' style={ barStyle }>
        <div className = 'dropdown' style={ dropDownStyle }> 
          <label htmlFor='component-dropdown' tabIndex='-1' ref={accessBarRef} > Jump to section: </label>
          <div id='component-dropdown' >
            <Dropdown
              options={ sectionDropDown }
              style={ activeComponentDDStyle }
              placeholder='Sections of this page'
              ariaLabel='Navigation Assistant'
              setSelected={setFocus} 
            />
          </div>
        </div>
          <div className = 'dropdown' style={ dropDownStyle }> 
          <label htmlFor='page-dropdown'> Jump to page: </label>
          <div id='page-dropdown' >
            <Dropdown
              options={ navInfoDropDown }
              style={ activeComponentDDStyle }
              placeholder='Other pages on this site'
              ariaLabel='Navigation Assistant'
              setSelected={ changeView } 
            />
          </div>
        </div>
      </div>
  );
};

/** Style for entire AccessBar */
const barStyle =  {
  display: 'flex',
  paddingTop: '.1em',
  paddingBottom: '.1em',
  paddingLeft: '5em',
  alignItems: 'center',
  justifyContent: 'flex-start',
  zIndex: '100',
  position: 'sticky',
  fontSize: '.8em',
  backgroundColor: 'gray',
  fontFamily: 'Roboto',
  color: 'white'
};

const dropDownStyle = {
  display: 'flex',
  alignItems: 'center',
  marginLeft: '1em',
};

/** Style for Dropdown component **/
const activeComponentDDStyle = {
  DropdownButton: base => ({
    ...base,
    margin: '5px',
    border: '1px solid',
    fontSize: '.5em',
  }),
  OptionContainer: base => ({
    ...base,
    margin: '5px',
    fontSize: '.5em',
  }),
};

/** Style for hiddenH1 */
const hiddenH1Styles = {
  display: 'block',
  overflow: 'hidden',
  textIndent: '100%',
  whiteSpace: 'nowrap',
  fontSize: '0.01px',
};

export default AccessBarWithRouter;

Here is my test, AccessBarWithRouter.unit.test.js:

import React from 'react';
import Enzyme, { mount } from 'enzyme';
import AccessBarWithRouter from '../src/AccessBarWithRouter.jsx';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

describe('AccessBarWithRouter component', () => {
  it('renders hidden h1 upon initial page load (this.state.isHidden = true)', () => {
    const location = { pathname: '/' };
    const wrapper = mount(
      <AccessBarWithRouter location={location}/>
  );
    // if AccessBarWithRouter is hidden it should only render our invisible h1
    expect(wrapper.exists('#hiddenH1')).toEqual(true);
  })
  it('renders full AccessBarWithRouter when this.state.isHidden is false', () => {
    // set dummy location within test to avoid location.pathname is undefined error
    const location = { pathname: '/' };
    const wrapper = mount(
        <AccessBarWithRouter location={location} />
    );
    wrapper.setState({ isHidden: false }, () => {
      // If AccessBar is not hidden the outermost div of the visible bar should be there
      // Test within setState waits for state change before running test
      expect(wrapper.exists('.ally-nav-area')).toEqual(true);
    });
  });
});

I am new to React Hooks so trying to wrap my mind around it. My understanding is that I have to provide some sort of mock history value for my test. I tried creating a separate useContext file as so and wrapping it around my component in the test, but that didn't work:

import React, { useContext } from 'react';

export const useAccessBarWithRouterContext = () => useContext(AccessBarWithRouterContext);

const defaultValues = { history: '/' };

const AccessBarWithRouterContext = React.createContext(defaultValues);

export default useAccessBarWithRouterContext;

My current versions of my devDependencies:

  • "@babel/cli": "^7.8.4",
  • "@babel/core": "^7.8.6",
  • "@babel/polyfill": "^7.0.0-beta.51",
  • "@babel/preset-env": "^7.8.6",
  • "@babel/preset-react": "^7.8.3",
  • "babel-core": "^7.0.0-bridge.0",
  • "babel-jest": "^25.1.0",
  • "enzyme": "^3.3.0",
  • "enzyme-adapter-react-16": "^1.1.1",
  • "jest": "^25.1.0",
  • "react": "^16.13.0",
  • "react-dom": "^16.13.0"

I'm not finding much documentation for testing a component utilizing the useHistory hook in general. It seems Enzyme only started working with React Hooks a year ago, and only for mock, not for shallow rendering.

Anyone have any idea how I can go about this?

nabramow
  • 17
  • 3
  • 9

1 Answers1

1

The problem here comes from inside of useHistory hook as you can imagine. The hook is designed to be used in consumers of a router provider. If you know the structure of Providers and Consumers, it'll make perfect sense to you that, here the consumer (useHistory) is trying to access some information from provider, which doesn't exist in your text case. There are two possible solutions:

  1. Wrap your test case with a router

    it('renders hidden h1 upon initial page load (this.state.isHidden = true)', () => {
       const location = { pathname: '/' };
       const wrapper = mount(
         <Router>
            <AccessBarWithRouter location={location}/>
         </Router>
       )
    });
    
  2. Mock useHistory hook with a fake history data

    jest.mock('react-router-dom', () => {
      const actual = require.requireActual('react-router-dom')
      return {
        ...actual,
        useHistory: () => ({ methods }),
      }
    })
    

I personally prefer the 2nd one as you can place it in setupTests file and forget about it. If you need to mock it or spy on it, you can overwrite the mock from setupTests file in your specific unit test file.

Wai Ha Lee
  • 8,598
  • 83
  • 57
  • 92
Doğancan Arabacı
  • 3,934
  • 2
  • 17
  • 25
  • Thank you! Wrapping my mounted component in a (and importing Router into my test of course) fixed the first test and solved the history is undefined error. Also thanks for the advice to read up on Providers and Consumers. Now I just need to troubleshoot my second test, this is giving me an issue with the useState hook that I am reading up on now. – nabramow Mar 04 '20 at 16:40
  • No problem. I didn't check your second test while answering but looking into it now, probably a wrapper.update() would fix the issue as it'll force a re-render with the new state. But a side note for that test: It's not a proper test as it's relying on implementation detail of the component (directly calling setState). You should set hidden state by simulating events (clicks, inputs, scrolls etc..). – Doğancan Arabacı Mar 04 '20 at 16:47
  • thanks for the feedback, that's a good point. The issue is we have a command that triggers a state change on the entire document.body, which makes it a bit trickier than simulating a button click, etc. setState seems to no longer work in the test since we've switched to using the useState hook. It's not recognizing "this.state.isHidden". Might just leave out that test for now. – nabramow Mar 04 '20 at 18:47
  • Yeah, it makes sense that it's not that easy to reach state of a function component – Doğancan Arabacı Mar 05 '20 at 09:07