React Test Framework Study & Practise

Stephen Cui ... 2019-05-14 11:16:11
  • Enzyme
  • Redux-saga
  • Jest
  • Redux-Saga-Test-Plan
About 9 min

# React Test Framework Study & Practise

# 不是这里讨论的问题 💢

# 尝试找出答案的问题 😏


# 01. 一个简单的测试都经历了什么

// app.js
function add(left, right) {
  return left + right;
}
// app.spec.js
it('1 + 1 = 2', () => {
  expect(add(1, 1)).toEqual(2);
});
1
2
3
4
5
6
7
8
    Start
      |
package.json                   --- 项目启动入口、包含pacage依赖
      |
`npm run test`                 --- npm提供的运行脚本API,可指定测试框架,例如`test: "jest"`或者`test: "nodescripts/test.js"`
      |
search test file               --- 可以指定扫描文件夹以及扫面文件的类型如spec.js或者test.js, 可以在测试框架的配置文件中指定,如jest.config.js
      |
comiple test file              --- 有的时候需要借助Babel进行编译,可以使用更高级的语法特性
      |
   run test                    --- 运行测试,可以指定浏览器如Chrome(默认),IE等
      |
  assert test                  --- Jest测试框架集成了expect.js断言库,如test、expect
      |
collect test results           --- 收集测试结果,可进行代码覆盖率、圈复杂度等检测
      |
     End
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 流行的测试框架

  1. JestJS, Facebook 出品,集成了 expect 断言库,React 标配
  2. KarmaJS, AngularJS 团队推荐,不是一个大而全的框架,可以自由集成 Mocha、Jasmine、QUnit 等插件
  3. MochaJS, 基于NodeJS的框架

# 02. Jest API 介绍

// https://jestjs.io/docs/en/api
describe('This is Jest Framework API Document from office site', () => {
    describe('test lifecycle', function () {
        beforeAll(() => console.error('beforeAll'))
        beforeEach(() => console.error('beforeEach'))
        afterEach(() => console.error('afterEach'))
        afterAll(() => console.error('afterAll'))

        it('should print logs.', () => {
            expect(true).toBeTruthy()
        })
    })

    describe.each`
    a    | b    | expected
    ${1} | ${1} | ${2}
    ${1} | ${2} | ${3}
    ${2} | ${1} | ${3}
  `('test $a  $b to equal $expected', ({a, b, expected}) => {
        test(`returns ${expected}`, () => {
            expect(a + b).toBe(expected)
        })

        test(`returned value not be greater than ${expected}`, () => {
            expect(a + b).not.toBeGreaterThan(expected)
        })

        test(`returned value not be less than ${expected}`, () => {
            expect(a + b).not.toBeLessThan(expected)
        })
    })

    describe('test keyword `skip`', () => {
        test('it is raining', () => {
            expect(true).toBeTruthy()
        })

        test.skip('it is not snowing', () => {
            expect(false).toBeTruthy()
        })
    })

    // describe('use keyword `only`', () => {
    //   test.only('it is raining', () => {
    //     expect(true).toBeTruthy()
    //   })
    //
    //   test('it is not snowing', () => {
    //     expect(false).toBeTruthy()
    //   })
    // })

    describe('test exception', () => {
        function compileAndroidCode() {
            throw new Error('you are using the wrong JDK')
        }

        test('compiling android goes as expected', () => {
            expect(compileAndroidCode).toThrow()
            expect(compileAndroidCode).toThrow(Error)

            // You can also use the exact error message or a regexp
            expect(compileAndroidCode).toThrow('you are using the wrong JDK')
            expect(compileAndroidCode).toThrow(/JDK/)
        })
    })

    describe('test async function', () => {
        const fetchApi = (response, callbackFn) => {
            return Promise.resolve(response).then(callbackFn)
        }

        it('should success but not intended when not used async mode to test', () => {
            fetchApi(999, response => {
                expect(response).toEqual(0)
            })
        })

        it('should success intended when use callback mode', done => {
            fetchApi(999, response => {
                expect(response).toEqual(999)
                done()
            })
        })

        it('should success intended when use promise mode', () => {
            return fetchApi(999).then(response => {
                expect(response).toEqual(999)
            })
        })

        it('should success intended when use resolve mode', () => {
            return expect(fetchApi(999)).resolves.toBe(999)
        })

        it('should success intended when use async/await mode', async () => {
            await expect(fetchApi(999)).resolves.toBe(999)
        })
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100

这一部分主要介绍了Jest Framework API 以下几部分主要内容:

import axios from 'axios';
// const axios = require('axios')
jest.mock('axios')
//  https://jestjs.io/docs/en/mock-functions
describe('Introduce Jest Mock functions', function () {
    describe('simply mock a function', function () {
        let fnOriginal
        beforeEach(() => {
            fnOriginal = () => -1
        })

        it('should get value by fnOriginal', () => {
            expect(fnOriginal()).toEqual(-1)
        })

        it('should get value from mock function', () => {
            fnOriginal = jest.fn(() => 999)
            expect(fnOriginal()).toEqual(999)
            // The mock function is called once
            expect(fnOriginal.mock.calls.length).toEqual(1)
            // The first argument of the first call to the function was undefined
            expect(fnOriginal.mock.calls[0][0]).toEqual(undefined)
            // The return value of the first call to the function was 42
            expect(fnOriginal.mock.results[0].value).toBe(999)
        })

        it('should mock returned value', () => {
            fnOriginal = jest.fn()
            fnOriginal
                .mockReturnValueOnce(1)
                .mockReturnValueOnce('x')
                .mockReturnValueOnce(true)

            expect(fnOriginal()).toEqual(1)
            expect(fnOriginal()).toEqual('x')
            expect(fnOriginal()).toEqual(true)
        })
    })

    describe('simply mock a module', () => {
        it('should not exception when api not found', async () => {
            axios.get.mockResolvedValue({data: 'I m a ghost'})
            const data = await axios
                .get('/uri-not-exists')
                .then(response => response.data)
            expect(data).toEqual('I m a ghost')
        })
    })

    describe('simply mock function implement', () => {
        it('should use jest.fn', function () {
            const mockFn = jest.fn(() => 1);
            expect(mockFn()).toEqual(1);
        })

        it('should use mock object', function () {
            const mockFn = jest.fn().mockImplementation(() => 1);
            expect(mockFn()).toEqual(1);
        })
    })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

这一部分内容重点介绍了Jest Mock的几种使用方式:

# 03. 使用React-enzyme测试

import React from 'react';
export class Button extends React.Component {
    render() {
        return (
            <div>This is a button</div>
        )
    }
}

import React from 'react';
import {Button} from '../Button/button';
export class Page extends React.Component {
    render() {
        return (
            <div className="my-class">
                <div>Page</div>
                {this.props.children ? this.props.children : <Button/>}
            </div>
        )
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

首先,创建一个Button和Page控件,并且Page中包含Button组件

import React from 'react'
import {shallow, mount} from 'enzyme'
import { Page } from '../components/Page/page'
import { Button } from '../components/Button/button'

// TODO: Official Doc: https://github.com/airbnb/enzyme/blob/master/docs/api/shallow.md
describe('Component tests', () => {
    describe('when use shallow dom render', () => {
        it('should return corrected value', () => {
            const wrapper = shallow(<Page/>)
            expect(wrapper).toHaveLength(1)
        })

        it('should find child component Loader by component mode', () => {
            const props = {loading: true}
            const wrapper = shallow(<Page {...props} />)
            expect(wrapper.find(Button)).toHaveLength(1)
        })

        it('should find child component Loader by css class mode', () => {
            const props = {loading: true, className: 'my-class'}
            const wrapper = shallow(<Page {...props} />)
            expect(wrapper.find('.my-class')).toHaveLength(1)
        })

        it('should click button', () => {
            let buttonClicked = false
            const props = {
                loading: true,
                className: 'my-class',
                children: [<Button key="" onClick={() => (buttonClicked = true)}/>],
            }
            const wrapper = shallow(<Page {...props} />)
            wrapper.find(Button).simulate('click')

            expect(buttonClicked).toBeTruthy()
        })

        it('should get div content', () => {
            const wrapper1 = shallow(
                <div>
                    <b>important</b>
                </div>
            )
            const wrapper2 = shallow(
                <div>
                    <Page/>
                    <b>important</b>
                </div>
            )

            expect(wrapper1.text()).toEqual('important')
            expect(wrapper2.text()).toEqual('<Page />important')
        })

        it('should get component functions', () => {
            const props = {loading: true}
            const wrapper = shallow(<Page {...props} />)
            expect(wrapper.instance()).not.toBeNull()
        })
    })

    describe('when use full dom render', () => {
        it('should allows us to set props', () => {
            const wrapper = mount(<Page bar="baz"/>)
            expect(wrapper.props().bar).toEqual('baz')
            wrapper.setProps({bar: 'foo'})
            expect(wrapper.props().bar).toEqual('foo')
        })
    })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

然后,我们推荐使用EnzymeJs来帮助我们测试React组件,这其中包含几种方式:

# 04. 一个简单的Saga测试

import {put} from 'redux-saga/effects';
export function* changeColor(action) {
    const color = action.color;

    yield put({
        type: 'CHANGE_COLOR_ACTION',
        color
    });
}

import {changeColor} from '../effects/example-of-saga-effects';
describe('Example of Saga Effect Test', () => {
    it('should get change color action when invoke saga effect', () => {
        const color = 'red';
        const gen = changeColor({color});

        expect(gen.next().value).toEqual({
            '@@redux-saga/IO': true,
            combinator: false,
            type: 'PUT',
            payload:
                {
                    channel: undefined,
                    action: {type: 'CHANGE_COLOR_ACTION', color: 'red'}
                }
        });
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

这里介绍了一个包含Saga Effect的最简单、原始的方式,不依赖于第三方测试框架。

# 05. 优化后的Saga测试

import {put, call} from 'redux-saga/effects';
import axios from 'axios';

export const verifyColor = color => {
    return axios.get(`/example-of-saga-test/colors/${color}/verify`);
};

export function* verifyAndChangeColor(action) {
    let color = action.color;
    const response = yield call(verifyColor, color);
    if (!response.isOk) {
        color = 'green';
    }
    yield put({
        type: 'CHANGE_COLOR_ACTION',
        color
    });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

首先,定义了一个Saga Effect函数verifyAndChangeColor和一个API调用的异步函数verifyColor

import {verifyAndChangeColor, verifyColor} from '../effects/example-of-saga-effects';
import {put, call} from 'redux-saga/effects';

describe('Example of Saga Effect Test', () => {
    it('should get verify and save color when call verifyAndChangeColor function', () => {
        const color = 'red';
        const gen = verifyAndChangeColor({color});

        const firstYieldValue = gen.next().value;
        expect(firstYieldValue).toEqual(call(verifyColor, color));

        const secondYieldValue = gen.next({isOk: false}).value;
        expect(secondYieldValue).toEqual(
            put({
                type: 'CHANGE_COLOR_ACTION',
                color: 'green'
            })
        );
    });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

然后,编写最基本的测试,这里依然不借助于第三方测试框架,接下来是Saga配合Selector的测试。 详情1 (opens new window)

export function* verifyColorFromStoreData() {
    let color = yield select(getCarReducer);
    const response = yield call(verifyColor, color);
    yield put({
        type: 'CHANGE_COLOR_ACTION',
        color: response.color
    });
}
export const getCarReducer = state => state.color;
1
2
3
4
5
6
7
8
9
/* https://github.com/redux-saga/redux-saga/blob/master/examples/shopping-cart/src/sagas/index.js */
describe('Example of Saga Effect Test', () => {
    it('should get color from store and save color when call function', () => {
        const validColor = 'green';
        const gen = verifyColorFromStoreData();

        let next = gen.next();
        expect(next.value).toEqual(select(getCarReducer));

        next = gen.next(validColor);
        expect(next.value).toEqual(call(verifyColor, validColor));

        next = gen.next({color: validColor});
        expect(next.value).toEqual(
            put({
                type: 'CHANGE_COLOR_ACTION',
                color: validColor
            })
        );
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

详情2 (opens new window)

# 06. React-saga-test-plan UT

接下来介绍,如何使用React-saga-test-plan,这个测试库。

import {
    verifyColor,
    verifyColorFromStoreData,
    getCarReducer
} from '../effects/example-of-saga-effects';
import {testSaga} from 'redux-saga-test-plan';

/* https://blog.scottlogic.com/2018/01/16/evaluating-redux-saga-test-libraries.html */
describe('with redux-saga-test-plan unit testing', () => {
    it('should get color from store and save color when call function', () => {
        const validColor = 'green';
        testSaga(verifyColorFromStoreData)
            .next()
            .select(getCarReducer)
            .next(validColor)
            .call(verifyColor, validColor)
            .next({color: validColor})
            .put({
                type: 'CHANGE_COLOR_ACTION',
                color: validColor
            })
            .next()
            .isDone();
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

Unit Test (opens new window)

# 07. React-saga-test-plan Integration Test

上一节介绍了如何进行单元测试,下面介绍如何做集成测试

import {
    verifyColorFromStoreData,
    verifyColor,
    getCarReducer
} from '../effects/example-of-saga-effects';
import {expectSaga} from 'redux-saga-test-plan';
import {call, select} from 'redux-saga/effects';

/* https://github.com/jfairbank/redux-saga-test-plan#integration-testing */
describe('with redux-saga-test-plan integration testing', () => {
    it('should get color from store and save color when call function', () => {
        const validColor = 'green';
        return expectSaga(verifyColorFromStoreData)
            .provide([[select(getCarReducer), validColor], [call(verifyColor, validColor), {color: validColor}]])
            .put({
                type: 'CHANGE_COLOR_ACTION',
                color: validColor
            })
            .run();
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

Integration Test (opens new window)

# 08. 复杂一些的Saga Effects测试

下面是一些更复杂场景的测试:

# Effect中嵌套调用Effect

export function* verifyThreePrimaryColor(action) {
  let color = action.color;
  const response = yield call(verifyColor, color);
  if (!response.isOK) {
    return;
  }
  yield put({
    type: 'CHANGE_COLOR_ACTION',
    color
  });
}

export function* verifySelectedColors(action) {
  const colors = action.colors;

  yield verifyThreePrimaryColor(colors[0]);
  yield verifyThreePrimaryColor(colors[1]);
  yield verifyThreePrimaryColor(colors[2]);
  yield verifyThreePrimaryColor(colors[3]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { verifySelectedColors, verifyColor } from '../effects/example-of-saga-effects';
import { expectSaga } from 'redux-saga-test-plan';
import { call, put } from 'redux-saga/effects';

describe('with saga effects within effects', () => {
  it('should verify colors with saga effects', () => {
    const threePrimaryColors = ['Red', 'Blue', 'Green', 'Yellow'].map(c => {
      return { color: c };
    });

    return expectSaga(verifySelectedColors, { colors: threePrimaryColors })
      .provide([
        [call(verifyColor, threePrimaryColors[0].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[1].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[2].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[3].color), { isOK: false }]
      ])
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[0].color
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[1].color
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[2].color
      })
      .not.put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[3].color
      })
      .run();
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

Effect with Effects (opens new window)

# 使用Test Matcher测试

describe('with saga effects within effects', () => {
  it('should verify colors with saga effects', () => {
    const threePrimaryColors = ['Red', 'Blue', 'Green', 'Yellow'].map(c => {
      return { color: c };
    });

    return expectSaga(verifySelectedColors, { colors: threePrimaryColors })
      .provide([
        [call(verifyColor, threePrimaryColors[0].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[1].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[2].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[3].color), { isOK: false }]
      ])
      .call.like({ fn: verifyColor })
      .put.like({
        action: {
          type: 'CHANGE_COLOR_ACTION'
        }
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[1].color
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[2].color
      })
      .not.put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[3].color
      })
      .run();
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

Test Matcher (opens new window)

# 测试一个含有返回值的Effect

export function* verifySelectedColors(action) {
  const colors = action.colors;

  yield verifyThreePrimaryColor(colors[0]);
  yield verifyThreePrimaryColor(colors[1]);
  yield verifyThreePrimaryColor(colors[2]);
  yield verifyThreePrimaryColor(colors[3]);

  return 'hello world';
}
1
2
3
4
5
6
7
8
9
10
describe('with saga effects within effects', () => {
  it('should verify colors with saga effects', () => {
    const threePrimaryColors = ['Red', 'Blue', 'Green', 'Yellow'].map(c => {
      return { color: c };
    });

    return expectSaga(verifySelectedColors, { colors: threePrimaryColors })
      .provide([
        [call(verifyColor, threePrimaryColors[0].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[1].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[2].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[3].color), { isOK: false }]
      ])
      .call.like({ fn: verifyColor })
      .put.like({
        action: {
          type: 'CHANGE_COLOR_ACTION'
        }
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[1].color
      })
      .put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[2].color
      })
      .not.put({
        type: 'CHANGE_COLOR_ACTION',
        color: threePrimaryColors[3].color
      })
      .returns('hello world')
      .run();
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

Effect with Returned Value (opens new window)

# 重构后的Saga测试

export function* verifySelectedColors(action) {
  const colors = action.colors;

  yield all(
    colors.map(function*(c) {
      return yield verifyThreePrimaryColor(c);
    })
  );

  return 'hello world';
}
1
2
3
4
5
6
7
8
9
10
11
describe('with saga effects within effects', () => {
  it('should verify colors with saga effects', () => {
    const threePrimaryColors = ['Red', 'Blue', 'Green', 'Yellow'].map(c => {
      return { color: c };
    });

    return expectSaga(verifySelectedColors, { colors: threePrimaryColors })
      .provide([
        [call(verifyColor, threePrimaryColors[0].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[1].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[2].color), { isOK: true }],
        [call(verifyColor, threePrimaryColors[3].color), { isOK: false }]
      ])
      .run()
      .then(result => {
        const { effects, returnValue } = result;

        expect(returnValue).toEqual('hello world');
        expect(effects.put.length).toEqual(3);

        expect(effects.put[0]).toEqual(
          put({
            type: 'CHANGE_COLOR_ACTION',
            color: threePrimaryColors[0].color
          })
        );
        expect(effects.put[1]).toEqual(
          put({
            type: 'CHANGE_COLOR_ACTION',
            color: threePrimaryColors[1].color
          })
        );
        expect(effects.put[2]).toEqual(
          put({
            type: 'CHANGE_COLOR_ACTION',
            color: threePrimaryColors[2].color
          })
        );
      });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

Another Way to Test (opens new window)

# 问题 & 讨论

TOBE UPDATE

Comments
Powered by Waline v2.6.2