使用 jest 和 enzyme 在基于 class 的组件上通过上下文设置 mockFunction

Setting a mockFunction through context on a class based component with jest and enzyme

我有一个非常简单的基于 class 的组件。如下所示:

class MyComponent extends React.Component {
    onPressButton () {
        console.warn('button pressed')
        const { contextFunction } = this.context
        contextFunction()
    }

    render () {
        return (
            <div>
                My Component
                <button onClick={() => onPressButton()}>Press button</button>
            </div>
        )
    }
}


MyComponent.contextType = SomeContext

一切都很好,按预期工作。但是,我在使用 jest 和 enzyme 添加单元测试时遇到了问题。我当前的代码如下所示:

test('should render test Press button', async () => {
    const contextFunctionMock = jest.fn()
    const wrapper = shallow(<MyComponent {...props} />)
    wrapper.instance().context = { contextFunction: contextFunctionMock }

    console.log('wrapper.instance()', wrapper.instance())

    await wrapper.instance().onPressButton() // this works just fine
    expect(contextFunctionMock).toHaveBeenCalled() // this errors, basically because ti complains contextFunction is not a function
})

正如您在上面看到的,我 console.logged 我 wrapper.instance() 看看发生了什么。 有趣的是,实例对象根上的 context 确实是我期望它基于设置上下文的内容,类似于:

context: {
        contextFunction: [Function: mockConstructor] {
          _isMockFunction: true,
          getMockImplementation: [Function (anonymous)],
          [...Other mock function properties]
        }
...

然而,还有第二个上下文,在wrapper.instance()的updater属性中,它是一个空对象。基本上如下所示:

updater: <ref *2> Updater {
        _renderer: ReactShallowRenderer {
          _context: {},
          ...
        }

不确定这是否是用于我的组件单元测试的上下文,但它目前只是一个空对象,这让我认为这可能是用于它的对象。

无论如何,我怎样才能在这个特定的单元测试中正确地将我的上下文函数模拟为 运行?另外,为什么会发生这种情况,但在具有类似情况的其他人身上却不会发生?

至于今天,新上下文 API 是 not supported by enzyme, the only solution I found is to use this utility https://www.npmjs.com/package/shallow-with-context

import { configure, shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { withContext } from "shallow-with-context";
import MyComponent from "./MyComponent";

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

describe("Context", () => {
  it("should render test Press button", async () => {
    const contextFunctionMock = jest.fn();
    const context = { contextFunction: contextFunctionMock };
    const MyComponentWithContext = withContext(MyComponent, context);
    const wrapper = shallow(<MyComponentWithContext />, { context });
    await wrapper.instance().onPressButton();
    expect(contextFunctionMock).toHaveBeenCalled();
  });
});

https://codesandbox.io/s/enzyme-context-test-xhfj3?file=/src/MyComponent.test.tsx

问题

上面代码的一个基本问题是无法断言上下文函数成功/失败被调用.现在,您正在单击一个按钮,但没有任何迹象表明 在单击该按钮后 发生了什么(changed/updated 在 context/component 以反映任何类型的 UI 变化)。因此,如果单击按钮没有结果,则断言调用上下文函数将无益。

除上述之外,酶适配器不支持使用 createContext 方法的上下文。

但是,有一个解决此限制的方法!除了对组件进行单元测试之外,您还需要使用上下文创建集成测试。您无需断言调用了上下文函数,而是针对单击更改上下文的按钮的 结果 以及它如何影响组件进行断言。

解决方案

由于组件与上下文中的内容相关联,因此您将创建一个集成测试。例如,您将在测试中使用上下文包装组件并对结果进行断言:

import * as React from "react";
import { mount } from "enzyme";
import Component from "./path/to/Component";
import ContextProvider from "./path/to/ContextProvider";

const wrapper = mount(
  <ContextProvider>
    <Component /> 
  </ContextProvider>
);

it("updates the UI when the button is clicked", () => {
  wrapper.find("button").simulate("click");

  expect(wrapper.find(...)).toEqual(...);
})

通过执行上述操作,您可以针对 Component 中的上下文更新做出断言。此外,通过使用 mount,您无需 dive 进入 ContextProvider 即可查看 Component 标记。

演示示例

此演示利用上下文将主题从“浅色”切换为“深色”,反之亦然。单击 Tests 选项卡以 运行 App.test.js 集成测试。

代码示例

App.js

import * as React from "react";
import { ThemeContext } from "./ThemeProvider";
import "./styles.css";

class App extends React.PureComponent {
  render() {
    const { theme, toggleTheme } = this.context;
    return (
      <div className="app">
        <h1>Current Theme</h1>
        <h2 data-testid="theme" className={`${theme}-text`}>
          {theme}
        </h2>
        <button
          className={`${theme}-button button`}
          data-testid="change-theme-button"
          type="button"
          onClick={toggleTheme}
        >
          Change Theme
        </button>
      </div>
    );
  }
}

App.contextType = ThemeContext;

export default App;

ThemeProvider.js

import * as React from "react";

export const ThemeContext = React.createContext();

class ThemeProvider extends React.Component {
  state = {
    theme: "light"
  };

  toggleTheme = () => {
    this.setState((prevState) => ({
      theme: prevState.theme === "light" ? "dark" : "light"
    }));
  };

  render = () => (
    <ThemeContext.Provider
      value={{ theme: this.state.theme, toggleTheme: this.toggleTheme }}
    >
      {this.props.children}
    </ThemeContext.Provider>
  );
}

export default ThemeProvider;

index.js

import * as React from "react";
import ReactDOM from "react-dom";
import ThemeProvider from "./ThemeProvider";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <ThemeProvider>
      <App />
    </ThemeProvider>
  </React.StrictMode>,
  document.getElementById("root")
);

测试示例

如何针对上面的演示示例进行测试的示例。

withTheme.js(一个可选的可重用测试工厂函数,用于用上下文包装组件——当您可能想调用 wrapper.setProps() 时特别有用更新组件道具的根)

import * as React from "react";
import { mount } from "enzyme";
import ThemeProvider from "./ThemeProvider";

/**
 * Factory function to create a mounted wrapper with context for a React component
 *
 * @param Component - Component to be mounted
 * @param options - Optional options for enzyme's mount function.
 * @function createElement - Creates a wrapper around passed in component with incoming props (now we can use wrapper.setProps on root)
 * @returns ReactWrapper - a mounted React component with context.
 */
export const withTheme = (Component, options = {}) =>
  mount(
    React.createElement((props) => (
      <ThemeProvider>{React.cloneElement(Component, props)}</ThemeProvider>
    )),
    options
  );

export default withTheme;

App.test.js

import * as React from "react";
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import withTheme from "./withTheme";
import App from "./App";

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

// wrapping "App" with some context
const wrapper = withTheme(<App />);

/*
  THIS "findByTestId" FUNCTION IS OPTIONAL! 
 
  I'm using "data-testid" attributes, since they're static properties in 
  the DOM that are easier to find within a "wrapper". 
  This is 100% optional, but easier to use when a "className" may be 
  dynamic -- such as when using css modules that create dynamic class names.
*/
const findByTestId = (id) => wrapper.find(`[data-testid='${id}']`);

describe("App", () => {
  it("initially displays a light theme", () => {
    expect(findByTestId("theme").text()).toEqual("light");
    expect(findByTestId("theme").prop("className")).toEqual("light-text");

    expect(findByTestId("change-theme-button").prop("className")).toContain(
      "light-button"
    );
  });

  it("clicking on the 'Change Theme' button toggles the theme between 'light' and 'dark'", () => {
    // change theme to "dark"
    findByTestId("change-theme-button").simulate("click");

    expect(findByTestId("theme").text()).toEqual("dark");
    expect(findByTestId("theme").prop("className")).toEqual("dark-text");
    expect(findByTestId("change-theme-button").prop("className")).toContain(
      "dark-button"
    );

    // change theme to "light"
    findByTestId("change-theme-button").simulate("click");

    expect(findByTestId("theme").text()).toEqual("light");
  });
});