• React单元测试--- 以Jest为例


      对React 进行单元测试,就是把React 组件渲染出来,看看渲染出来的内容符不符合我们的预期。比如组件加载的时候有loading, 那就渲染组件,看看渲染出的内容中有没有loading. 再比如,请求数据完成后,组件要显示返回的数据, 那就渲染组件, 等待请求完成,看看渲染出来的内容有没有数据显示。你可能会问,怎么看?我们是在命令行中跑测试,又不是在浏览器中进行测试?这就要感谢Jest了,它使用了jsdom, jsdom提供了DOM的无头实现,纵然是在命令行中跑测试,在测试中仍然可以获取到document, document.body 等DOM 元素,使用documet.getElementId() 等DOM 方法,也可以click 来测试浏览器的交互形为。如果你听说无头浏览器,把它看成无头浏览器就可以了。

      jsdom也称为Jest环境(浏览器环境),它是Jest 的默认环境,也就是说,只要安装jest, 测试中就可以使用和操作DOM,不用任何配置。当然Jest不只有jsdom环境,还有node环境,如果你想使用node 环境,在package.json 中  

     "jest": {
        "testEnvironment""node"
      }

      这时,就不能测试中使用document等DOM对象了。React测试的就是浏览器行为,使用jest的默认环境就可以了,不用配置node环境了。

      那怎么渲染呢?有两个测试库enzyme和@testing-library, 提供了渲染方法。enzyme 提供了两种渲染机制,shallow 和mount. @test-library则只提供了mount。

      shallow是浅渲染,只渲染本组件,如果它import了子组件,子组件的内容不会渲染出来。mount 则是深渲染,组件和它包含的子组件全都渲染成真实的dom, 挂载到body或你创建的dom元素上。举个例子,一看就明白了。create-react-app test-react, 创建test-react 项目。create-react-app包含了@test-library库,我们只需要安装enzyme. 由于react 的版本太多,enzyme 使用了适配器的模式,安装enzyme 的同时,要安装适配器。npm i --save-dev enzyme enzyme-adapter-react-16。 安装完成后,还要配置一下,让enzyme 知道你使用的是哪一个适配器。怎么配置呢?

      Jest 有一个配置项setupFilesAfterEnv,是一个路径数组,如果把某个文件所在的路径放到这个数组中,那么在跑测试之前Jest都会先运行这些文件中内容,路径数组中的各个文件相当于对Jest 进行了初始配置。在跑每个enzyme测试之前,都要配置enzyme, 所以把enzyme 的配置文件放到setupFilesAfterEnv 中,那就要配置setupFilesAfterEnv。create-react-app 配置了setupFilesAfterEnv,并指向了src/setupTest.js, 在setUpTest.js中配置enzyme就可以了,这样,在跑每一个测试之前,都会先配置Enzyme,在setUpTest.js 中添加如下内容

    import Enzyme from 'enzyme';
    import Adapter from 'enzyme-adapter-react-16';
    
    Enzyme.configure({ adapter: new Adapter() });

      配置完成,看一下shollow 和mount. 在src 目录下新建Header.js 和Footer.js,

    import React from 'react';
    export function Header() {
        return <div>Header</div>
    }
    import React from 'react';
    export function Footer() {
        return <div>Footer</div>
    }

      再建一个Home.js,引入它们

    import React from 'react';
    import { Header } from './Header';
    import { Footer } from './Footer';
    
    export function Home() {
        return <React.Fragment>
            <Header></Header>
            <Footer></Footer>
        </React.Fragment>
    }

      现在建立Home.test.js进行测试,使用enzyme 的shallow和mount 方法,这两个方法都是接受一个React Element作为参数,返回渲染后的内容,称为wrappper. wrapper 有一个debug() 方法,可以看到渲染后的内容。

    import React from 'react';
    import { Home } from "./Home";
    import {shallow, mount} from 'enzyme';

    test('render', () => {
      const wrapper = shallow(<Home></Home>);
      console.log(wrapper.debug());
    })

      npm run test, console.log 的结果是

       可以看到,shallow确实没有渲染子组件的内容,这时把shallow 换成mount

     const wrapper = mount(<Home></Home>);

      测试自动跑,结果如下

       mount 确实把子组件渲染出来了。再测试一下@test-library.  @test-library有两个库@test-library/dom 和@test-library/react. dom库是基础库,react库是针对React的对dom库的封装。React 测试,直接使用@test-library/react 就可以了。它有一个render 方法,也是接受一个React Element 作为参数,对其进行渲染, 渲染的结果有一个debug() 方法,可以看到渲染的结果。Home.test.js 修改如下

    import React from 'react';
    import { Home } from "./Home";
    import { render } from '@testing-library/react';
    
    test('render', () => {
        const { debug }  = render(<Home></Home>);
        debug();
    })

      测试结果如下

       @test-library 的render 方法也是深渲染,把子组件都渲染出来了,并且挂载到body 上。

      能渲染组件,也能操作DOM, 就可以测试React了。组件渲染出来,document.getElementById() 等查找元素,进行断言。有时,使用DOM 原生方法比较麻烦,enzyme 和 @test-library也对其进行了封装,提供了简洁的查找方法。写一个简单的例子,文章刚开始提到的,组件加载的时候显示loading, 然后请求数据, 展示数据。App.js 修改如下

    import React from 'react';
    export default class App extends React.Component {
        state = { todo: null}
        componentDidMount() {
            fetch('https://jsonplaceholder.typicode.com/todos/1')
                .then(res => res.json())
                .then(todo => this.setState({todo}))
                .catch(e => console.log(e));
        }

        render() {
            return (
                <React.Fragment>
                    {
                        this.state.todo ? 
                        <p className="title">title: {this.state.todo.title}</p>
                        : <p className="spinner">loading</p>
                    }
                </React.Fragment>
            )
        }
    }

      使用enzyme的shallow进行测试。测试的第一种情况是组件有没有显示 loading, 这和浏览器的行为是一致的,也和用户看到的内容是一致的。浏览器先渲染loading, 再渲染todo的title。用户先看到的是loading, 再看到的是title。测试的目的,就是模拟浏览器和用户的形为,模拟的越接近,测试越有效。那怎么测试呢?渲染组件,查找class="spinner"的元素 ,断言其存在,如果测试通过,就表示存在。enzyme 提供了find() 方法,它接受一个css选择器,然后返回一个对象,这个对象有一个exists() 方法,返回true or false. App.test.js 修改如下

    import React from 'react';
    import { shallow } from 'enzyme';
    import App from './App';

    describe('app test', () => {
        test('render a loading when component shows', () => {
           const wrapper = shallow(<App></App>);
           expect(wrapper.find('.spinner').exists()).toBeTruthy();
        });
        
    });

       npm run test ,启动测试,通过了。怎么通过呢?看一下React组件的执行过程,state.todo是null,render 方法返回<p className="spinner">loading</p>, 然后调用componentDidMount() 方法,fetch去请求数据(异步的),而在测试中,渲染出组件以后,直接断言了(同步),并没有等待fetch请求回来,fetch 在请求的过程中,测试已结束,所以渲染出的wrapper 只包含<p className="spinner">loading</p>, 测试通过,可以console.log(wrapper.debug()) 看组件的渲染结果。这也引出了测试的第二种情况,等待fetch请求结束,看返回的数据有没有正确渲染出来。有两个问题需要解决,一个是fetch, 在测试中,不是真正地去请求数据,所以要对fetch 进行mock.  fetch是window 对象的属性,所以对fetch的mock, 就是让window.fetch = jest.fn()。 jest.fn接受一个函数作为参数,函数的返回值就是mock函数的返回值。fetch的mock如下

    window.fetch = jest.fn(() => {
        return Promise.resolve({
            status: 200,
            json: () => {
                return Promise.resolve({"title": "delectus aut autem"
                })
            }
        })
    })

      一个是等待, 等待fetch请求成功。等待用的是Jest测试的done参数,只要一个test测试中,参数有done, Jest 在测试的时候,就会等待这个done 的调用,如果done 不调用,Jest就会停在这个测试中。那么现在的问题变成了什么时候调用done(). fetch 返回的是promise, 所有注册的回调函数都放到异步队列中。异步队列的执行是node 的事件循环机制,还是无法知道,所有的回调函数什么时候执行完,什么时候调用done()。 但我们可以注册一个回调函数,只要保证fetch中注册的回调函数都执行完了,再执行我们注册的回调函数就可以了。这让我想到了setTimeout(), 同一个事件循环中,promise中的回调函数会在setTimeOut中的回调函数之前执行,那就在setTimeout 中调用done 就可以了

    test('should render todo.title when fetch successfully ', (done) => {
        window.fetch = jest.fn(() => {
            return Promise.resolve({
                status: 200,
                json: () => {
                    return Promise.resolve({"title": "delectus aut autem"
                    })
                }
            })
        })
    
        const wrapper = shallow(<App></App>);
    
        setTimeout(() => {
            expect(wrapper.find("p.title").text()).toContain("delectus");
            expect(wrapper.find(".spinner").length).toBe(0);
            done();
        }, 10);
    });
    });

      看一下执行顺序,shallow(<App></App>)  -> componentDidMount() 执行 -> fetch发送请求,由于fetch是异步的,所以 shallow(<App></App>); 这一行代码算是执行完了,但由于mock, fetch立即resolve了,在执行下一行代码代码之前,fetch注册的回调函数已到异步队列中。再执行setTimeout, 告诉node, 10ms 之后注册断言的回调函数。顺序执行完毕,开始执行队列。执行res => res.json(),setState(), React 重新渲染,10ms 之后,断言的回调函数注册并执行,由于也执行了done() 测试结束,这时测试的就是fetch 返回数据之后的组件内容。注意,这里的setTimeout的延迟10s 只是举例,真正起作用的是事件循环队列的micro-task 和macro-task。promise是micro-task, setTimeout是micro-task。

       两种情况都通过测试,这个React组件就算测试完成了,因为它只有这两种情况。再稍微延伸一下,有人使用fetch的时候喜欢return 

     componentDidMount() {
            return fetch('https://jsonplaceholder.typicode.com/todos/1')
                .then(res => res.json())
                .then(todo => this.setState({todo}))
                .catch(e => console.log(e));
        }

      或有人喜欢使用async/await

        async componentDidMount() {
            try {
                const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
                const todo = await res.json();
                this.setState({todo})
            } catch (e) {
                console.log(e);
            }
        }

      这时componentDidMount() 调用的时候,就会返回promise, 在测试的时候,给这个promise注册回调函数,在回调函数里面时进行测试,可以保证fetch请求结束再进行断言。这时,你就想手动调用componentDidMount().  在shallow 渲染下是可以的,它接受第二个参数,是个对象,对shallow进行配置,disableLifecycleMethods: true, 表示渲染组件的时候不会调用生命周期函数。它返回的wrapper 有一个instance() 方法,返回react 实例,用它调用componentDidMount(), 测试内容修改如下, 还有一点要注意,在mock数据使用完成之后,最好把mock的函数进行还原。

    test('should render todo.title when fetch successfully ', (done) => {
        window.fetch = jest.fn(() => {
            return Promise.resolve({
                status: 200,
                json: () => {
                    return Promise.resolve({"title": "delectus aut autem"
                    })
                }
           })
        })
    
        const wrapper = shallow(<App></App>, {disableLifecycleMethods: true});
        let didMount = wrapper.instance().componentDidMount();
    
        didMount.then(() => {
            expect(wrapper.find("p.title").text()).toContain("delectus");
            expect(wrapper.find(".spinner").length).toBe(0);
            console.log("ues");
            fetch.mockClear(); // mock 还原
            done();
        })

       fetch 还有一种使用情况,对其进行封装,新建一个request.js 文件,定义一个getData()

    export function getData(url) {
        return fetch(url).then(res => res.json())
    }

      在App.js 中就要引入getData, 然后compentDidMount() 中使用它

        componentDidMount() {
            return getData('https://jsonplaceholder.typicode.com/todos/1')
                .then(todo => this.setState({todo}))
                .catch(e => console.log(e));
        }

      现在要怎么测试呢?组件依赖了另外一个模块,如果不想受这个模块影响,那就mock 这个模块。jest.mock() 一个模块,这个模块暴露出来的函数都变成了mock 函数,再从这个模块中引入函数,引入的都是mock函数,mock函数就可以mock实现,返回值等。

    jest.mock('./request.js');
    import { getData } from './request';

      这时,你会发现两个测试都报错了。第一个测试,shallow并没有禁止调用生命周期函数,compentDidMount会调用getData(), getData() 返回的是jest.fn() 没有then, 所以报错了,第二个也是如此。第一个可以禁止生命周期函数的调用。

     const wrapper = shallow(<App></App>, {disableLifecycleMethods: true});

       第二个对getData mock 实现

    getData.mockResolvedValue({
        "title": "delectus aut autem",
    })

      测试通过。这时,两个测试中都有const wrapper = shallow(<App></App>, {disableLifecycleMethods: true}); 可以进行抽取,因为在每一个测试之前都会shallow, 所以使用beforeEach()。mock的还原可以使用afterEach()

    import React from 'react';
    import { shallow } from 'enzyme';
    import App from './App';
    jest.mock('./request.js');
    import { getData } from './request';
    
    describe('app test', () => {
    
        let wrapper;
        beforeEach(() => {
            wrapper = shallow(<App></App>, {disableLifecycleMethods: true});
        });
        afterEach(() => {
            getData.mockClear(); // mock 还原
        });
    
        test('render a loading when component shows', () => {
           expect(wrapper.find('.spinner').exists()).toBeTruthy();
        });
    
        test('should render todo.title when fetch successfully ', (done) => {
            getData.mockResolvedValue({
                "title": "delectus aut autem",
            })
            let didMount = wrapper.instance().componentDidMount();
    
            didMount.then(() => {
                expect(wrapper.find("p.title").text()).toContain("delectus");
                expect(wrapper.find(".spinner").length).toBe(0);
                done();
            })
        });
    });

       这里要注意,每个test之间要相互独立,不要使用共享数据,尤其是使用window 和document 对象的时候。当把变量提升到test 外面,放到describe中的时候,使用这个变量之前,一定要先进行赋值操作,可以使用beforeEach, 也可以在每一个test 的第一句,每一个test 都要使用它自己创建的变量。

  • 相关阅读:
    Web服务器的Socket通信
    Love story
    Excel初探索 - 2008/3
    One Person Game ZOJ Problem Set 3329
    只有程序员才看得懂的情书
    Collecting Bugs_poj2096
    Scout YYF I_poj3744
    hdu 3336 Count the string
    hdu 1669 Jamie's Contact Groups
    poj 1240
  • 原文地址:https://www.cnblogs.com/SamWeb/p/13599947.html
Copyright © 2020-2023  润新知