3

如果觉得redux async action单元测试难以入手,不妨尝试本文方法.

redux状态管理确实带来十分大的便利,但随之而来的单元测试实现却令人头痛(至少刚开始我不知道从何着手).尤其async action单元测试更甚,本文意旨简单实现redux async action单元测试.

实现工具

  • karma 测试管理工具

  • mocha 测试框架

  • chai 测试断言库

项目结构

.
├── LICENSE
├── README.md
├── app #前端相关
│   ├── actions #redux actions
│   │   └── about.js
│   └── helpers #validator
│       └── validator.js
├── package.json
├── test #测试相关
│   ├── actions #test redux actions
│   │   └── about_test.js
│   ├── karma.conf.js #karma配置文件
│   └── test_index.js #test 入口文件
├── webpack.test.js #test wepack
└── yarn.lock

karma搭建

karma配置文件
/**
 * test/karma.conf.js
 */
var webpackConfig = require('../webpack.test');
module.exports = function (config) {
    config.set({
        // 使用的测试框架&断言库
        frameworks: ['mocha', 'chai'],
        // 测试文件同时作为webpack入口文件
        files: [
            'test_index.js'
        ],
        // webpack&sourcemap处理测试文件
        preprocessors: {
            'test_index.js': ['webpack', 'sourcemap']
        },
        // 测试浏览器
        browsers: ['PhantomJS'],
        // 测试结束关闭PhantomJS
        phantomjsLauncher: {
            exitOnResourceError: true
        },
        // 生成测试报告
        reporters: ['mocha', 'coverage'],
        // 覆盖率配置
        coverageReporter: {
            dir: 'coverage',
            reporters: [{
                type: 'json',
                subdir: '.',
                file: 'coverage.json',
            }, {
                type: 'lcov',
                subdir: '.'
            }, {
                type: 'text-summary'
            }]
        },
        // webpack配置
        webpack: webpackConfig,
        webpackMiddleware: {
            stats: 'errors-only'
        },
        // 自动监测测试文件内容
        autoWatch: false,
        // 只运行一次
        singleRun: true,
        // 运行端口
        port: 9876,
        // 输出彩色
        colors: true,
        // 输出等级
        // config.LOG_DISABLE
        // config.LOG_ERROR
        // config.LOG_WARN
        // config.LOG_INFO
        // config.LOG_DEBUG
        logLevel: config.LOG_INFO
    });
};
karma测试入口文件
/**
 * test/test_index.js
 * 引入test目录下带_test文件
 */
var testsContext = require.context(".", true, /_test$/);
testsContext.keys().forEach(function (path) {
    try {
        testsContext(path);
    } catch (err) {
        console.error('[ERROR] WITH SPEC FILE: ', path);
        console.error(err);
    }
});

es6将会已经成为主流,所以搭建karma时选择webpack配合babel进行打包处理.

webpack
/**
 * webpack.test.js
 */
process.env.NODE_ENV = 'test';

var webpack = require('webpack');
var path = require('path');
module.exports = {
    name: 'run test webpack',
    devtool: 'inline-source-map', //Source Maps
    module: {
        loaders: [
            {
                test: /\.jsx|.js$/,
                include: [
                    path.resolve('app/'),
                    path.resolve('test/')
                ],
                loader: 'babel'
            }
        ],
        preLoaders: [{ //在webpackK打包前用isparta-instrumenter记录编译前文件,精准覆盖率
            test: /\.jsx|.js$/,
            include: [path.resolve('app/')],
            loader: 'isparta'
        }],
        plugins: [
            new webpack.DefinePlugin({
                'process.env.NODE_ENV': JSON.stringify('test')
            })
        ]
    }
};
babel
/**
 * .babelrc
 */
{
  "presets": ["es2015", "stage-0", "react"]
}

actions

为了对actions执行了什么有个具体的概念,此处贴一张图
about.png

/**
 * app/actions/about.js
 */
import 'isomorphic-fetch';
import * as Validators from '../helpers/validator';

export const GET_ABOUT_REQUEST = 'GET_ABOUT_REQUEST';
export const GET_ABOUT_SUCCEED = 'GET_ABOUT_SUCCEED';
export const GET_ABOUT_FAILED = 'GET_ABOUT_FAILED';
export const CHANGE_START = 'CHANGE_START';
export const CHANGE_ABOUT = 'CHANGE_ABOUT';

const fetchStateUrl = '/api/about';
/**
 * 异步获取about
 * method get
 */
exports.fetchAbout = ()=> {
    return async(dispatch)=> {
        // 初始化about
        dispatch(aboutRequest());
        try {//成功则执行aboutSucceed
            let response = await fetch(fetchStateUrl);
            let data = await response.json();
            return dispatch(aboutSucceed(data));
        } catch (e) {//失败则执行aboutFailed
            return dispatch(aboutFailed());
        }
    }
};
/**
 * 改变start
 * value 星数
 */
exports.changeStart = (value)=> ({
    type: CHANGE_START,
    value: value,
    error: Validators.changeStart(value)
});
/**
 * 异步改变about
 * method post
 */
exports.changeAbout = ()=> {
    return async(dispatch)=> {
        try {
            let response = await fetch('/api/about', {
                method: 'POST'
            });
            let data = await response.json();
            return dispatch({
                type: CHANGE_ABOUT,
                data: data
            });
        } catch (e) {

        }
    }
};

const aboutRequest = ()=> ({
    type: GET_ABOUT_REQUEST
});

const aboutSucceed = (data)=>({
    type: GET_ABOUT_SUCCEED,
    data: data
});

const aboutFailed = ()=> {
    return {
        type: GET_ABOUT_FAILED
    }
};

因为对星数有限制,编写validator限制

validator
/**
 * app/helpers/validator.js
 */

// 限制星数必须为正整数且在1~5之间
export function changeStart(value) {
    var reg = new RegExp(/^[1-5]$/);
    if (typeof(value) === 'number' && reg.test(value)) {
        return ''
    }
    return '星数必须为正整数且在1~5之间'
}

单元测试

这里测试了actions应该暴露的const,普通的actions,异步的actions.

测试async actions主要靠fetch-mock拦截actions本身,并且返回期望的结果.

注意:fetch-mock mock(matcher, response, options)方法,matcher使用begin:匹配相应url.如:begin:http://www.example.com/,即匹配http://www.example.com/也匹配http://www.example.com/api/about

/**
 * test/actions/about_test.js
 */
import 'babel-polyfill'; // 转换es6新的API 这里主要为Promise
import 'isomorphic-fetch'; // fetchMock依赖
import fetchMock from 'fetch-mock';// fetch拦截并模拟数据
import configureMockStore from 'redux-mock-store';// 模拟store
import thunk from 'redux-thunk';
import * as Actions from '../../app/actions/about';
//store通过middleware进行模拟
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

describe('actions/about', () => {
    //export constant test
    describe('export constant', ()=> {
        it('Should export a constant GET_ABOUT_REQUEST.', () => {
            expect(Actions.GET_ABOUT_REQUEST).to.equal('GET_ABOUT_REQUEST');
        });
        it('Should export a constant GET_ABOUT_SUCCEED.', () => {
            expect(Actions.GET_ABOUT_SUCCEED).to.equal('GET_ABOUT_SUCCEED');
        });
        it('Should export a constant GET_ABOUT_FAILED.', () => {
            expect(Actions.GET_ABOUT_FAILED).to.equal('GET_ABOUT_FAILED');
        });
        it('Should export a constant CHANGE_START.', () => {
            expect(Actions.CHANGE_START).to.equal('CHANGE_START');
        });
        it('Should export a constant GET_ABOUT_REQUEST.', () => {
            expect(Actions.CHANGE_ABOUT).to.equal('CHANGE_ABOUT');
        });
    });
    //normal action test
    describe('action fetchAbout', ()=> {
        it('fetchAbout should be exported as a function.', () => {
            expect(Actions.fetchAbout).to.be.a('function')
        });
        it('fetchAbout should return a function (is a thunk).', () => {
            expect(Actions.fetchAbout()).to.be.a('function')
        });
    });
    describe('action changeStart', ()=> {
        it('changeStart should be exported as a function.', () => {
            expect(Actions.changeStart).to.be.a('function')
        });
        it('Should be return an action and return correct results', () => {
            const action = Actions.changeStart(5);
            expect(action).to.have.property('type', Actions.CHANGE_START);
            expect(action).to.have.property('value', 5);
        });
        it('Should be return an action with error while input empty value.', () => {
            const action = Actions.changeStart();
            expect(action).to.have.property('error').to.not.be.empty
        });
    });
    describe('action changeAbout', ()=> {
        it('changeAbout be exported as a function.', () => {
            expect(Actions.changeAbout).to.be.a('function')
        });
    });
    //async action test
    describe('async action', ()=> {
        //对每个执行完的测试恢复fetchMock
        afterEach(fetchMock.restore);

        describe('action fetchAbout', ()=> {
            it('Should be done when fetch action fetchAbout', async()=> {
                const data = {
                    "code": 200,
                    "msg": "ok",
                    "result": {
                        "value": 4,
                        "about": "it's my about"
                    }
                };
                // 期望的发起请求的 action
                const actRequest = {
                    type: Actions.GET_ABOUT_REQUEST
                };
                // 期望的请求成功的 action
                const actSuccess = {
                    type: Actions.GET_ABOUT_SUCCEED,
                    data: data
                };
                const expectedActions = [
                    actRequest,
                    actSuccess,
                ];
                //拦截/api/about请求并返回自定义数据
                fetchMock.mock(`begin:/api/about`, data);
                const store = mockStore({});
                await store.dispatch(Actions.fetchAbout());
                //比较store.getActions()与期望值
                expect(store.getActions()).to.deep.equal(expectedActions);
            });
            it('Should be failed when fetch action fetchAbout', async()=> {
                // 期望的发起请求的 action
                const actRequest = {
                    type: Actions.GET_ABOUT_REQUEST
                };
                // 期望的请求失败的 action
                const actFailed = {
                    type: Actions.GET_ABOUT_FAILED
                };
                const expectedActions = [
                    actRequest,
                    actFailed,
                ];
                //拦截/api/about请求并返回500错误
                fetchMock.mock(`begin:/api/about`, 500);
                const store = mockStore({});
                await store.dispatch(Actions.fetchAbout());
                //比较store.getActions()与期望值
                expect(store.getActions()).to.deep.equal(expectedActions);
            });
        });
        describe('action changeAbout', ()=> {
            it('Should be done when fetch action changeAbout', async()=> {
                const data = {
                    "code": 200,
                    "msg": "ok",
                    "result": {
                        "about": "it's changeAbout fetch about"
                    }
                };
                const acSuccess = {
                    type: Actions.CHANGE_ABOUT,
                    data: data
                };
                const expectedActions = [
                    acSuccess
                ];
                //拦截/api/about post请求并返回自定义数据
                fetchMock.mock(`begin:/api/about`, data, {method: 'POST'});
                const store = mockStore({});
                await store.dispatch(Actions.changeAbout());
                //比较store.getActions()与期望值
                expect(store.getActions()).to.deep.equal(expectedActions);
            });
        });
    });
});

dependencies

 "dependencies": {
    "isomorphic-fetch": "^2.2.1",
    "react": "^15.4.1",
    "react-dom": "^15.4.1",
    "redux": "^3.6.0",
    "webpack": "^1.14.0"
  },
  "devDependencies": {
    "babel-cli": "^6.18.0",
    "babel-loader": "^6.2.10",
    "babel-polyfill": "^6.20.0",
    "babel-preset-es2015": "^6.18.0",
    "babel-preset-react": "^6.16.0",
    "babel-preset-stage-0": "^6.16.0",
    "chai": "^3.5.0",
    "fetch-mock": "^5.8.0",
    "isparta-loader": "^2.0.0",
    "karma": "^1.3.0",
    "karma-chai": "^0.1.0",
    "karma-coverage": "^1.1.1",
    "karma-mocha": "^1.3.0",
    "karma-mocha-reporter": "^2.2.1",
    "karma-phantomjs-launcher": "^1.0.2",
    "karma-sourcemap-loader": "^0.3.7",
    "karma-webpack": "^1.8.1",
    "mocha": "^3.2.0",
    "redux-mock-store": "^1.2.1",
    "redux-thunk": "^2.1.0",
    "sinon": "next"
  }

script

直接在项目根目录中执行npm test则可以进行测试

"scripts": {
    "test": "./node_modules/karma/bin/karma start test/karma.conf.js"
  }

测试结果
testInfo.png

持续集成

  • Travis-cli

    • Github进行绑定

    • 每次push执行npm test进行测试
      由于Travis默认测试Ruby项目,所以在根目录下添加.travis.yml文件

language: node_js #项目标注为javascript(nodeJs)
node_js: '6' #nodeJs版本
sudo: true
cache: yarn #yarn缓存目录 $HOME/.yarn-cache

若项目通过可得到属于该项目的小图标 Build Status

项目地址

https://github.com/timmyLan/r...

参考资料


愤怒的小雏鸟
58 声望5 粉丝

实践出真章