Testing component with lodash.debounce delay failing

Viewed 2210

I have a rich text editor input field that I wanted to wrap with a debounced component. Debounced input component looks like this:

import { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';

const useDebounce = (callback, delay) => {
  const debouncedFn = useCallback(
    debounce((...args) => callback(...args), delay),
    [delay] // will recreate if delay changes
  );
  return debouncedFn;
};

function DebouncedInput(props) {
  const [value, setValue] = useState(props.value);
  const debouncedSave = useDebounce((nextValue) => props.onChange(nextValue), props.delay);

  const handleChange = (nextValue) => {
    setValue(nextValue);
    debouncedSave(nextValue);
  };

  return props.renderProps({ onChange: handleChange, value });
}

export default DebouncedInput;

I am using DebouncedInput as a wrapper component for MediumEditor:

<DebouncedInput
  value={task.text}
  onChange={(text) => onTextChange(text)}
  delay={500}
  renderProps={(props) => (
    <MediumEditor
      {...props}
      id="task"
      style={{ height: '100%' }}
      placeholder="Task text…"
      disabled={readOnly}
      key={task.id}
    />
  )}
/>;

MediumEditor component does some sanitation work that I would like to test, for example stripping html tags:

class MediumEditor extends React.Component {
  static props = {
    id: PropTypes.string,
    value: PropTypes.string,
    onChange: PropTypes.func,
    disabled: PropTypes.bool,
    uniqueID: PropTypes.any,
    placeholder: PropTypes.string,
    style: PropTypes.object,
  };

  onChange(text) {
    this.props.onChange(stripHtml(text) === '' ? '' : fixExcelPaste(text));
  }

  render() {
    const {
      id,
      value,
      onChange,
      disabled,
      placeholder,
      style,
      uniqueID,
      ...restProps
    } = this.props;
    return (
      <div style={{ position: 'relative', height: '100%' }} {...restProps}>
        {disabled && (
          <div
            style={{
              position: 'absolute',
              width: '100%',
              height: '100%',
              cursor: 'not-allowed',
              zIndex: 1,
            }}
          />
        )}
        <Editor
          id={id}
          data-testid="medium-editor"
          options={{
            toolbar: {
              buttons: ['bold', 'italic', 'underline', 'subscript', 'superscript'],
            },
            spellcheck: false,
            disableEditing: disabled,
            placeholder: { text: placeholder || 'Skriv inn tekst...' },
          }}
          onChange={(text) => this.onChange(text)}
          text={value}
          style={{
            ...style,
            background: disabled ? 'transparent' : 'white',
            borderColor: disabled ? 'grey' : '#FF9600',
            overflowY: 'auto',
            color: '#444F55',
          }}
        />
      </div>
    );
  }
}

export default MediumEditor;

And this is how I am testing this:

it('not stripping html tags if there is text', async () => {
  expect(editor.instance.state.text).toEqual('Lorem ipsum ...?');
  const mediumEditor = editor.findByProps({ 'data-testid': 'medium-editor' });
  const newText = '<p><b>New text, Flesk</b></p>';
  mediumEditor.props.onChange(newText);
  // jest.runAllTimers();
  expect(editor.instance.state.text).toEqual(newText);
});

When I run this test I get:

Error: expect(received).toEqual(expected) // deep equality

Expected: "<p><b>New text, Flesk</b></p>"
Received: "Lorem ipsum ...?"

I have also tried running the test with jest.runAllTimers(); before checking the result, but then I get:

Error: Ran 100000 timers, and there are still more! Assuming we've hit an infinite recursion and bailing out...

I have also tried with:

jest.advanceTimersByTime(500);

But the test keeps failing, I get the old state of the text. It seems like the state just doesn't change for some reason, which is weird since the component used to work and the test were green before I had them wrapped with DebounceInput component. The parent component where I have MediumEditor has a method onTextChange that should be called from the DebounceInput component since that is the function that is being passed as the onChange prop to the DebounceInput, but in the test, I can see this method is never reached. In the browser, everything works fine, so I don't know why it is not working in the test?

onTextChange(text) {
  console.log('text', text);
  this.setState((state) => {
    return {
      task: { ...state.task, text },
      isDirty: true,
    };
  });
}

On inspecting further I could see that the correct value is being passed in the test all the way to handleChange in DebouncedInput. So, I suspect, there are some problems with lodash.debounce in this test. I am not sure if I should mock this function or does mock come with jest?

const handleChange = (nextValue) => {
  console.log(nextValue);
  setValue(nextValue);
  debouncedSave(nextValue);
};

This is where I suspect the problem is in the test:

const useDebounce = (callback, delay) => {
  const debouncedFn = useCallback(
    debounce((...args) => callback(...args), delay),
    [delay] // will recreate if delay changes
  );
  return debouncedFn;
};

I have tried with mocking debounce like this:

import debounce from 'lodash.debounce'
jest.mock('lodash.debounce');
debounce.mockImplementation(() => jest.fn(fn => fn));

That gave me error:

TypeError: _lodash.default.mockImplementation is not a function

How should I fix this?

1 Answers

I'm guessing that you are using enzyme (from the props access). In order to test some code that depends on timers in jest:

  1. mark to jest to use fake timers with call to jest.useFakeTimers()
  2. render your component
  3. make your change (which will start the timers, in your case is the state change), pay attention that when you change the state from enzyme, you need to call componentWrapper.update()
  4. advance the timers using jest.runOnlyPendingTimers()

This should work.

Few side notes regarding testing react components:

  1. If you want to test the function of onChange, test the immediate component (in your case MediumEditor), there is no point of testing the entire wrapped component for testing the onChange functionality
  2. Don't update the state from tests, it makes your tests highly couple to specific implementation, prove, rename the state variable name, the functionality of your component won't change, but your tests will fail, since they will try to update a state of none existing state variable.
  3. Don't call onChange props (or any other props) from test. It makes your tests more implementation aware (=high couple with component implementation), and actually they doesn't check that your component works properly, think for example that for some reason you didn't pass the onChange prop to the input, your tests will pass (since your test is calling the onChange prop), but in real it won't work.

The best approach of component testing is to simulate actions on the component like your user will do, for example, in input component, simulate a change / input event on the component (this is what your user does in real app when he types).

Related