5

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?

felixmosh
  • 32,615
  • 9
  • 69
  • 88
Leff
  • 1,968
  • 24
  • 97
  • 201
  • Try with jest.advanceTimersByTime(n), and n is equal to 500 since delay={500} – lissettdm Jan 03 '21 at 19:42
  • I have tried it, but the test keeps failing, 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. – Leff Jan 03 '21 at 19:53
  • What library are you using to test? – lissettdm Jan 03 '21 at 20:04
  • Only react-test-rendere – Leff Jan 03 '21 at 20:12
  • 1
    Does your test pass if you [mock `lodash/debounce`](https://gist.github.com/apieceofbart/d28690d52c46848c39d904ce8968bb27)? `import debouce from 'lodash/debounce'; // Tell jest to mock this import jest.mock('lodash/debounce'); // Assign the import a new implementation, in this case it's execute the function given to you debouce.mockImplementation(fn => fn);` – bamse Jan 06 '21 at 12:12
  • I have tried with ```jest.mock('lodash.debounce', () => jest.fn(fn => fn))``` but it didn't help – Leff Jan 06 '21 at 13:18
  • Did you wait for the assertion? in react-testing-library, there is a waitFor function that will wait for the `expect` to be pass; maybe there is an equivalence for this functionality in react-test-rendere. – Alireza Jan 08 '21 at 18:00
  • But why `lodash.debounce` instead of `lodash/debounce`?. Seems to me the latter is the proper syntax to reference to the debounce function (or import { debounce } from 'lodash') – stilllife Jan 12 '21 at 18:07
  • Can you add the code which shows which component do u render? do you use enzyme? – felixmosh Jan 13 '21 at 19:06

1 Answers1

2

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

felixmosh
  • 32,615
  • 9
  • 69
  • 88