├── __tests__
├── mocks
│ └── file-mock.js
├── setup
│ └── enzyme-setup.js
├── utils
│ ├── default-settings-util.test.js
│ └── fetcher.test.js
├── store
│ ├── selectors
│ │ ├── biztoolbar.test.js
│ │ └── biztable.test.js
│ ├── reducers
│ │ ├── biztoolbar.test.js
│ │ └── biztable.test.js
│ ├── actions
│ │ ├── biztoolbar.test.js
│ │ └── biztable.test.js
│ └── sagas
│ │ ├── biztoolbar.test.js
│ │ └── biztable.test.js
├── services
│ └── bizapi.test.js
├── containers
│ ├── biztoolbar.test.js
│ └── biztable.test.js
└── components
│ ├── biztoolbar.test.js
│ └── biztable.test.js
├── images
├── coverage.png
├── components.gif
└── source-folders.png
├── postcss.config.js
├── src
├── server
│ ├── views
│ │ ├── error.ejs
│ │ └── app.ejs
│ ├── routes
│ │ ├── pageRouter.js
│ │ ├── rootRouter.js
│ │ ├── routeErrorHandler.js
│ │ └── bizRouter.js
│ ├── utils
│ │ └── responseUtil.js
│ └── index.js
└── client
│ ├── store
│ ├── types
│ │ ├── bizToolbar.js
│ │ └── bizTable.js
│ ├── actions
│ │ ├── bizToolbar.js
│ │ └── bizTable.js
│ ├── reducers
│ │ ├── index.js
│ │ ├── bizToolbar.js
│ │ └── bizTable.js
│ ├── sagas
│ │ ├── index.js
│ │ ├── bizToolbar.js
│ │ └── bizTable.js
│ ├── selectors
│ │ └── index.js
│ └── index.js
│ ├── services
│ └── bizApi.js
│ ├── components
│ ├── styles
│ │ ├── bizToolbar.less
│ │ └── bizIndex.less
│ ├── BizIndex.js
│ ├── BizToolbar.js
│ └── BizTable.js
│ ├── utils
│ ├── defaultSettingsUtil.js
│ └── fetcher.js
│ ├── containers
│ ├── BizTable.js
│ └── BizToolbar.js
│ └── app.js
├── .editorconfig
├── bin
└── www.js
├── jest.config.js
├── .babelrc
├── webpack.dev.config.js
├── .gitignore
├── package.json
└── README.md
/__tests__/mocks/file-mock.js:
--------------------------------------------------------------------------------
1 | module.exports = 'test-file-stub';
2 |
--------------------------------------------------------------------------------
/images/coverage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepfunc/react-test-demo/HEAD/images/coverage.png
--------------------------------------------------------------------------------
/images/components.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepfunc/react-test-demo/HEAD/images/components.gif
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('autoprefixer')
4 | ]
5 | };
6 |
--------------------------------------------------------------------------------
/images/source-folders.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepfunc/react-test-demo/HEAD/images/source-folders.png
--------------------------------------------------------------------------------
/src/server/views/error.ejs:
--------------------------------------------------------------------------------
1 |
<%= message %>
2 | <%= error.status %>
3 | <%= error.stack %>
4 |
--------------------------------------------------------------------------------
/__tests__/setup/enzyme-setup.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/src/client/store/types/bizToolbar.js:
--------------------------------------------------------------------------------
1 | export const BIZ_TOOLBAR_KEYWORDS_UPDATE = 'BIZ_TOOLBAR_KEYWORDS_UPDATE';
2 |
3 | export const BIZ_TOOLBAR_RELOAD = 'BIZ_TOOLBAR_RELOAD';
4 |
--------------------------------------------------------------------------------
/src/client/services/bizApi.js:
--------------------------------------------------------------------------------
1 | import { fetcher } from '@/utils/fetcher';
2 |
3 | export function getBizTableData(payload) {
4 | return fetcher.postJSON('/api/biz/get-table', payload);
5 | }
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/bin/www.js:
--------------------------------------------------------------------------------
1 | require('@babel/polyfill');
2 |
3 | const env = process.env.NODE_ENV;
4 |
5 | // Require Hook
6 | if (env === 'development') {
7 | require('@babel/register');
8 | }
9 |
10 | require('../src/server');
11 |
--------------------------------------------------------------------------------
/src/client/components/styles/bizToolbar.less:
--------------------------------------------------------------------------------
1 | .toolbarContainer {
2 | margin-bottom: 16px;
3 |
4 | .searchCol {
5 | text-align: right;
6 |
7 | .search {
8 | width: 200px;
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/server/routes/pageRouter.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 |
3 | const pageRouter = new Router();
4 |
5 | pageRouter.get('/', async ctx => {
6 | await ctx.render('app');
7 | });
8 |
9 | module.exports = pageRouter;
10 |
--------------------------------------------------------------------------------
/src/client/utils/defaultSettingsUtil.js:
--------------------------------------------------------------------------------
1 | export const pagination = {
2 | size: 'small',
3 | showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`,
4 | pageSizeOptions: ['15', '25', '40', '60'],
5 | showSizeChanger: true,
6 | showQuickJumper: true
7 | };
8 |
--------------------------------------------------------------------------------
/src/server/routes/rootRouter.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 | const bizRouter = require('./bizRouter');
3 |
4 | const rootRouter = new Router();
5 |
6 | rootRouter.use('/api/biz', bizRouter.routes(), bizRouter.allowedMethods());
7 |
8 | module.exports = rootRouter;
9 |
--------------------------------------------------------------------------------
/src/client/store/actions/bizToolbar.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions';
2 | import * as type from '../types/bizToolbar';
3 |
4 | export const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE);
5 |
6 | export const reload = createAction(type.BIZ_TOOLBAR_RELOAD);
7 |
--------------------------------------------------------------------------------
/src/client/store/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import bizToolbarReducer from './bizToolbar';
3 | import bizTableReducer from './bizTable';
4 |
5 | export default combineReducers({
6 | bizToolbar: bizToolbarReducer,
7 | bizTable: bizTableReducer
8 | });
9 |
--------------------------------------------------------------------------------
/src/server/routes/routeErrorHandler.js:
--------------------------------------------------------------------------------
1 | const routeErrorHandler = async (ctx, next) => {
2 | await next();
3 | const status = ctx.status || 404;
4 | if (status === 404) {
5 | ctx.status = 404;
6 | ctx.body = 'Sorry, Not Found';
7 | }
8 | };
9 |
10 | module.exports = routeErrorHandler;
11 |
--------------------------------------------------------------------------------
/src/client/store/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all } from 'redux-saga/effects';
2 | import { watchBizToolbarFlow } from './bizToolbar';
3 | import { watchBizTableFlow } from './bizTable';
4 |
5 | export default function* () {
6 | yield all([
7 | watchBizToolbarFlow(),
8 | watchBizTableFlow()
9 | ]);
10 | }
11 |
--------------------------------------------------------------------------------
/src/client/components/styles/bizIndex.less:
--------------------------------------------------------------------------------
1 | .indexContainer {
2 | padding: 24px;
3 | background: white;
4 | }
5 |
6 | :global {
7 | body {
8 | background: #f0f2f5;
9 | padding: 48px;
10 | }
11 |
12 | .ant-table-thead > tr > th,
13 | .ant-table-tbody > tr > td {
14 | padding: 8px;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/client/store/types/bizTable.js:
--------------------------------------------------------------------------------
1 | export const BIZ_TABLE_GET_REQ = 'BIZ_TABLE_GET_REQ';
2 |
3 | export const BIZ_TABLE_GET_RES_SUCCESS = 'BIZ_TABLE_GET_RES_SUCCESS';
4 |
5 | export const BIZ_TABLE_GET_RES_FAIL = 'BIZ_TABLE_GET_RES_FAIL';
6 |
7 | export const BIZ_TABLE_PARAMS_UPDATE = 'BIZ_TABLE_PARAMS_UPDATE';
8 |
9 | export const BIZ_TABLE_RELOAD = 'BIZ_TABLE_RELOAD';
10 |
--------------------------------------------------------------------------------
/__tests__/utils/default-settings-util.test.js:
--------------------------------------------------------------------------------
1 | import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
2 |
3 | describe('default settings utils', () => {
4 |
5 | test('check default pagination', () => {
6 | const pagination = Object.assign({}, defaultSettingsUtil.pagination);
7 |
8 | expect(pagination.showTotal(100, [1, 5])).toBe('1-5 / 100');
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/client/components/BizIndex.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import BizToolbar from '@/containers/BizToolbar';
3 | import BizTable from '@/containers/BizTable';
4 | import styles from './styles/bizIndex.less';
5 |
6 | const BizIndex = () => (
7 |
8 |
9 |
10 |
11 | );
12 |
13 | export default BizIndex;
14 |
--------------------------------------------------------------------------------
/src/client/store/reducers/bizToolbar.js:
--------------------------------------------------------------------------------
1 | import { handleActions } from 'redux-actions';
2 | import Immutable from 'seamless-immutable';
3 | import * as type from '../types/bizToolbar';
4 |
5 | export const defaultState = Immutable({
6 | keywords: ''
7 | });
8 |
9 | export default handleActions(
10 | {
11 | [type.BIZ_TOOLBAR_KEYWORDS_UPDATE]: (state, { payload }) => state.set('keywords', payload)
12 | },
13 | defaultState
14 | );
15 |
--------------------------------------------------------------------------------
/src/server/views/app.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | react-test-demo
8 |
9 |
10 |
11 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/client/store/selectors/index.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import * as defaultSettings from '@/utils/defaultSettingsUtil';
3 |
4 | export const getBizToolbar = (state) => state.bizToolbar;
5 |
6 | const getBizTableState = (state) => state.bizTable;
7 |
8 | export const getBizTable = createSelector(getBizTableState, (bizTable) => {
9 | return bizTable.merge({
10 | pagination: defaultSettings.pagination
11 | }, { deep: true });
12 | });
13 |
--------------------------------------------------------------------------------
/__tests__/store/selectors/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import Immutable from 'seamless-immutable';
2 | import { getBizToolbar } from '@/store/selectors';
3 |
4 | /* 测试 bizToolbar selector */
5 | describe('bizToolbar selector', () => {
6 |
7 | const state = Immutable({
8 | bizToolbar: {
9 | keywords: 'some keywords'
10 | }
11 | });
12 |
13 | /* 测试返回正确的 bizToolbar state */
14 | test('should return bizToolbar state', () => {
15 | expect(getBizToolbar(state)).toEqual(state.bizToolbar);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/client/containers/BizTable.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { getBizTable } from '@/store/selectors';
3 | import * as actions from '@/store/actions/bizTable';
4 | import BizTable from '@/components/BizTable';
5 |
6 | const mapStateToProps = (state) => ({
7 | ...getBizTable(state)
8 | });
9 |
10 | const mapDispatchToProps = {
11 | getData: actions.getBizTableData,
12 | updateParams: actions.updateBizTableParams
13 | };
14 |
15 | export default connect(mapStateToProps, mapDispatchToProps)(BizTable);
16 |
--------------------------------------------------------------------------------
/src/client/containers/BizToolbar.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { getBizToolbar } from '@/store/selectors';
3 | import * as actions from '@/store/actions/bizToolbar';
4 | import BizToolbar from '@/components/BizToolbar';
5 |
6 | const mapStateToProps = (state) => ({
7 | ...getBizToolbar(state)
8 | });
9 |
10 | const mapDispatchToProps = {
11 | reload: actions.reload,
12 | updateKeywords: actions.updateKeywords
13 | };
14 |
15 | export default connect(mapStateToProps, mapDispatchToProps)(BizToolbar);
16 |
--------------------------------------------------------------------------------
/src/client/store/actions/bizTable.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions';
2 | import * as type from '../types/bizTable';
3 |
4 | export const getBizTableData = createAction(type.BIZ_TABLE_GET_REQ);
5 |
6 | export const putBizTableDataSuccessResult = createAction(type.BIZ_TABLE_GET_RES_SUCCESS);
7 |
8 | export const putBizTableDataFailResult = createAction(type.BIZ_TABLE_GET_RES_FAIL);
9 |
10 | export const updateBizTableParams = createAction(type.BIZ_TABLE_PARAMS_UPDATE);
11 |
12 | export const reloadBizTableData = createAction(type.BIZ_TABLE_RELOAD);
13 |
--------------------------------------------------------------------------------
/src/server/utils/responseUtil.js:
--------------------------------------------------------------------------------
1 | function returnRawError(code, message, details) {
2 | return {
3 | success: false,
4 | error: { code, message, details }
5 | };
6 | }
7 |
8 | module.exports = {
9 | returnSuccess: function (data) {
10 | return {
11 | success: true,
12 | result: data
13 | };
14 | },
15 |
16 | returnError: function (code, message, details) {
17 | return returnRawError(code, message, details);
18 | },
19 |
20 | returnError500: function (details) {
21 | return returnRawError(500, '产生了一个服务器内部错误!', details);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/src/client/store/sagas/bizToolbar.js:
--------------------------------------------------------------------------------
1 | import { all, takeLatest, put } from 'redux-saga/effects';
2 | import * as type from '../types/bizToolbar';
3 | import * as bizTableActions from '../actions/bizTable';
4 |
5 | export function* watchBizToolbarFlow() {
6 | yield all([
7 | takeLatest(type.BIZ_TOOLBAR_KEYWORDS_UPDATE, onUpdateKeywords),
8 | takeLatest(type.BIZ_TOOLBAR_RELOAD, onReload)
9 | ]);
10 | }
11 |
12 | export function* onUpdateKeywords() {
13 | yield put(bizTableActions.reloadBizTableData());
14 | }
15 |
16 | export function* onReload() {
17 | yield put(bizTableActions.reloadBizTableData());
18 | }
19 |
--------------------------------------------------------------------------------
/src/client/app.js:
--------------------------------------------------------------------------------
1 | import '@babel/polyfill';
2 | import 'es6-promise/auto';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import { Provider } from 'react-redux';
6 | import { LocaleProvider } from 'antd';
7 | import zhCN from 'antd/lib/locale-provider/zh_CN';
8 | import store from './store';
9 | import BizIndex from './components/BizIndex';
10 |
11 | const App = () => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | setTimeout(() => {
22 | ReactDOM.render(, document.getElementById('app'));
23 | }, 100);
24 |
--------------------------------------------------------------------------------
/src/client/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import rootReducer from './reducers';
4 | import rootSaga from './sagas';
5 |
6 | let store = null;
7 | const sagaMiddleware = createSagaMiddleware();
8 |
9 | if (process.env.NODE_ENV === 'development') {
10 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
11 | store = createStore(
12 | rootReducer,
13 | composeEnhancers(applyMiddleware(sagaMiddleware))
14 | );
15 |
16 | } else {
17 | store = createStore(rootReducer, applyMiddleware(sagaMiddleware));
18 | }
19 |
20 | sagaMiddleware.run(rootSaga);
21 |
22 | export default store;
23 |
--------------------------------------------------------------------------------
/src/server/routes/bizRouter.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 |
3 | const bizRouter = new Router();
4 |
5 | bizRouter.post('/get-table', ctx => {
6 | const total = 100;
7 | const { paging } = ctx.request.body;
8 | const items = [];
9 |
10 | for (let i = paging.skip + 1; i <= paging.skip + paging.max; i++) {
11 | if (i > total) break;
12 |
13 | const item = {
14 | id: i,
15 | code: `编码${i}`,
16 | name: `名称${i}`,
17 | wholeName: `完整名称${i}`,
18 | remark: `备注${i}`,
19 | lastModificationTime: '2018-08-06 14:21:24',
20 | lastModifierUserId: '某某'
21 | };
22 | items.push(item);
23 | }
24 |
25 | ctx.body = ctx.responseUtil.returnSuccess({ items, total });
26 | });
27 |
28 | module.exports = bizRouter;
29 |
--------------------------------------------------------------------------------
/__tests__/store/reducers/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import * as type from '@/store/types/bizToolbar';
2 | import reducer, { defaultState } from '@/store/reducers/bizToolbar';
3 |
4 | /* 测试 bizToolbar reducer */
5 | describe('bizToolbar reducer', () => {
6 |
7 | /* 测试未指定 state 参数情况下返回缺省 state */
8 | test('should return the default state', () => {
9 | expect(reducer(undefined, { type: 'UNKNOWN' })).toEqual(defaultState);
10 | });
11 |
12 | /* 测试更新关键字 */
13 | test('should handle update keywords', () => {
14 | const keywords = 'some keywords';
15 | const expectedState = defaultState.set('keywords', keywords);
16 |
17 | expect(
18 | reducer(defaultState, {
19 | type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,
20 | payload: keywords
21 | })
22 | ).toEqual(expectedState);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/__tests__/store/actions/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import * as type from '@/store/types/bizToolbar';
2 | import * as actions from '@/store/actions/bizToolbar';
3 |
4 | /* 测试 bizToolbar 相关 actions */
5 | describe('bizToolbar actions', () => {
6 |
7 | /* 测试更新搜索关键字 */
8 | test('should create an action for update keywords', () => {
9 | // 构建目标 action
10 | const keywords = 'some keywords';
11 | const expectedAction = {
12 | type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,
13 | payload: keywords
14 | };
15 |
16 | // 断言 redux-actions 产生的 action 是否正确
17 | expect(actions.updateKeywords(keywords)).toEqual(expectedAction);
18 | });
19 |
20 | /* 测试刷新 */
21 | test('should create an action for reload', () => {
22 | const expectedAction = { type: type.BIZ_TOOLBAR_RELOAD };
23 |
24 | expect(actions.reload()).toEqual(expectedAction);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: true,
3 | transform: {
4 | '^.+\\.jsx?$': 'babel-jest'
5 | },
6 | testRegex: '/__tests__/.*\\.(test|spec)\\.jsx?$',
7 | testPathIgnorePatterns: [
8 | 'node_modules',
9 | 'setup/.*-setup.js',
10 | 'mocks/.*.js'
11 | ],
12 | coveragePathIgnorePatterns: [
13 | 'node_modules',
14 | 'setup/.*-setup.js',
15 | 'mocks/.*.js'
16 | ],
17 | moduleFileExtensions: ['js', 'jsx', 'json', 'node'],
18 | moduleNameMapper: {
19 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
20 | '/__tests__/mocks/file-mock.js',
21 | '\\.(css|less)$': 'identity-obj-proxy',
22 | '@/(.*)': '/src/client/$1'
23 | },
24 | setupFiles: [
25 | '/__tests__/setup/enzyme-setup.js'
26 | ],
27 | /* JSDom 11.12 causes SecurityError: localStorage is not available for opaque origins */
28 | testURL: 'http://localhost:3000',
29 | globals: {}
30 | };
31 |
--------------------------------------------------------------------------------
/__tests__/services/bizapi.test.js:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import { fetcher } from '@/utils/fetcher';
3 | import * as api from '@/services/bizApi';
4 |
5 | /* 测试 bizApi */
6 | describe('bizApi', () => {
7 |
8 | let fetcherStub;
9 |
10 | beforeAll(() => {
11 | fetcherStub = sinon.stub(fetcher);
12 | });
13 |
14 | afterEach(() => {
15 | const keys = Object.keys(fetcherStub);
16 | for (const key of keys) {
17 | if (fetcherStub[key].isSinonProxy) {
18 | fetcherStub[key].reset();
19 | }
20 | }
21 | });
22 |
23 | afterAll(() => {
24 | fetcherStub.restore();
25 | });
26 |
27 | /* getBizTableData api 应该调用正确的 method 和传递正确的参数 */
28 | test('getBizTableData api should call postJSON with right params of fetcher', () => {
29 | /* 模拟参数 */
30 | const payload = { a: 1, b: 2 };
31 | api.getBizTableData(payload);
32 |
33 | /* 检查是否调用了工具库 */
34 | expect(fetcherStub.postJSON.callCount).toBe(1);
35 | /* 检查调用参数是否正确 */
36 | expect(fetcherStub.postJSON.lastCall.calledWith('/api/biz/get-table', payload)).toBe(true);
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/__tests__/store/sagas/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import { put } from 'redux-saga/effects';
2 | import * as saga from '@/store/sagas/bizToolbar';
3 | import * as bizTableActions from '@/store/actions/bizTable';
4 |
5 | /* 测试 bizToolbar saga */
6 | describe('bizToolbar saga', () => {
7 |
8 | /* 测试 bizToolbar 业务流入口 */
9 | test('should watch bizToolbar flow', () => {
10 | const gen = saga.watchBizToolbarFlow();
11 |
12 | expect(gen.next().value['ALL']).toHaveLength(2);
13 | expect(gen.next().done).toBe(true);
14 | });
15 |
16 | /* 测试点击刷新按钮时是否触发 bizTable 刷新 */
17 | test('when click reload button should reload bizTable', () => {
18 | const gen = saga.onReload();
19 |
20 | expect(gen.next().value).toEqual(put(bizTableActions.reloadBizTableData()));
21 | expect(gen.next().done).toBe(true);
22 | });
23 |
24 | /* 测试更新关键字后是否触发 bizTable 刷新 */
25 | test('when update keywords should reload bizTable', () => {
26 | const gen = saga.onUpdateKeywords();
27 |
28 | expect(gen.next().value).toEqual(put(bizTableActions.reloadBizTableData()));
29 | expect(gen.next().done).toBe(true);
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/__tests__/containers/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import configureStore from 'redux-mock-store';
4 | import BizToolbar from '@/containers/BizToolbar';
5 |
6 | /* 测试容器组件 BizToolbar */
7 | describe('BizToolbar container', () => {
8 |
9 | const initialState = {
10 | bizToolbar: {
11 | keywords: 'some keywords'
12 | }
13 | };
14 | const mockStore = configureStore();
15 | let store;
16 | let container;
17 |
18 | beforeEach(() => {
19 | store = mockStore(initialState);
20 | container = shallow();
21 | });
22 |
23 | /* 测试 state 到 props 的映射是否正确 */
24 | test('should pass state to props', () => {
25 | const props = container.props();
26 |
27 | expect(props).toHaveProperty('keywords', initialState.bizToolbar.keywords);
28 | });
29 |
30 | /* 测试 actions 到 props 的映射是否正确 */
31 | test('should pass actions to props', () => {
32 | const props = container.props();
33 |
34 | expect(props).toHaveProperty('reload', expect.any(Function));
35 | expect(props).toHaveProperty('updateKeywords', expect.any(Function));
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/client/store/reducers/bizTable.js:
--------------------------------------------------------------------------------
1 | import { handleActions } from 'redux-actions';
2 | import Immutable from 'seamless-immutable';
3 | import * as type from '../types/bizTable';
4 |
5 | /* 默认状态 */
6 | export const defaultState = Immutable({
7 | loading: false,
8 | pagination: {
9 | current: 1,
10 | pageSize: 15,
11 | total: 0
12 | },
13 | data: []
14 | });
15 |
16 | export default handleActions(
17 | {
18 | [type.BIZ_TABLE_GET_REQ]: (state) => state.set('loading', true),
19 |
20 | /* 处理获得数据成功 */
21 | [type.BIZ_TABLE_GET_RES_SUCCESS]: (state, { payload }) => {
22 | return state.merge(
23 | {
24 | loading: false,
25 | pagination: { total: payload.total },
26 | data: payload.items
27 | },
28 | { deep: true }
29 | );
30 | },
31 |
32 | [type.BIZ_TABLE_GET_RES_FAIL]: (state) => state.set('loading', false),
33 |
34 | [type.BIZ_TABLE_PARAMS_UPDATE]: (state, { payload: { paging } }) => {
35 | return state.merge(
36 | {
37 | pagination: { current: paging.current, pageSize: paging.pageSize }
38 | },
39 | { deep: true }
40 | );
41 | }
42 | },
43 | defaultState
44 | );
45 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ],
6 | "plugins": [
7 | [
8 | "import",
9 | {
10 | "libraryName": "antd",
11 | "style": "css"
12 | }
13 | ],
14 | "@babel/plugin-transform-runtime",
15 | "@babel/plugin-syntax-dynamic-import",
16 | "@babel/plugin-syntax-import-meta",
17 | "@babel/plugin-proposal-class-properties",
18 | "@babel/plugin-proposal-json-strings",
19 | [
20 | "@babel/plugin-proposal-decorators",
21 | {
22 | "legacy": true
23 | }
24 | ],
25 | "@babel/plugin-proposal-function-sent",
26 | "@babel/plugin-proposal-export-namespace-from",
27 | "@babel/plugin-proposal-numeric-separator",
28 | "@babel/plugin-proposal-throw-expressions",
29 | "@babel/plugin-proposal-export-default-from",
30 | "@babel/plugin-proposal-logical-assignment-operators",
31 | "@babel/plugin-proposal-optional-chaining",
32 | [
33 | "@babel/plugin-proposal-pipeline-operator",
34 | {
35 | "proposal": "minimal"
36 | }
37 | ],
38 | "@babel/plugin-proposal-nullish-coalescing-operator",
39 | "@babel/plugin-proposal-do-expressions",
40 | "@babel/plugin-proposal-function-bind"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/webpack.dev.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const rootDir = __dirname;
4 |
5 | module.exports = {
6 | context: rootDir,
7 | mode: 'development',
8 | devtool: 'cheap-module-source-map',
9 | entry: './src/client/app.js',
10 | output: {
11 | path: path.join(rootDir, 'dist'),
12 | filename: 'app.js',
13 | publicPath: 'http://localhost:7000/dist/'
14 | },
15 | resolve: {
16 | alias: {
17 | '@': path.join(rootDir, 'src/client')
18 | },
19 | extensions: ['.js', '.jsx', '.json']
20 | },
21 | devServer: {
22 | port: 7000,
23 | publicPath: '/dist/',
24 | headers: {
25 | 'Access-Control-Allow-Origin': '*',
26 | }
27 | },
28 | module: {
29 | rules: [
30 | {
31 | test: /\.css$/,
32 | use: [
33 | 'style-loader',
34 | 'css-loader',
35 | 'postcss-loader'
36 | ]
37 | },
38 | {
39 | test: /\.less$/,
40 | use: [
41 | 'style-loader',
42 | 'css-loader?modules&localIdentName=[name]--[local]-[hash:base64:5]',
43 | 'postcss-loader',
44 | 'less-loader'
45 | ]
46 | },
47 | {
48 | test: /\.jsx?$/,
49 | use: [
50 | {
51 | loader: 'babel-loader',
52 | options: {
53 | cacheDirectory: true
54 | }
55 | }
56 | ]
57 | }
58 | ]
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/__tests__/containers/biztable.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Immutable from 'seamless-immutable';
3 | import { shallow } from 'enzyme';
4 | import configureStore from 'redux-mock-store';
5 | import BizTable from '@/containers/BizTable';
6 |
7 | /* 测试容器组件 BizTable */
8 | describe('BizTable container', () => {
9 |
10 | const initialState = Immutable({
11 | bizTable: {
12 | loading: false,
13 | pagination: {
14 | current: 1,
15 | pageSize: 15,
16 | total: 0
17 | },
18 | data: []
19 | }
20 | });
21 | const mockStore = configureStore();
22 | let store;
23 | let container;
24 |
25 | beforeEach(() => {
26 | store = mockStore(initialState);
27 | container = shallow();
28 | });
29 |
30 | /* 测试 state 到 props 的映射是否正确 */
31 | test('should pass state to props', () => {
32 | const props = container.props();
33 | const { bizTable: bizTableState } = initialState;
34 |
35 | expect(props).toHaveProperty('loading', bizTableState.loading);
36 | expect(props).toHaveProperty('data', bizTableState.data);
37 | expect(props).toHaveProperty('pagination');
38 | expect(props['pagination']).toMatchObject(bizTableState.pagination);
39 | });
40 |
41 | /* 测试 actions 到 props 的映射是否正确 */
42 | test('should pass actions to props', () => {
43 | const props = container.props();
44 |
45 | expect(props).toHaveProperty('getData', expect.any(Function));
46 | expect(props).toHaveProperty('updateParams', expect.any(Function));
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/__tests__/store/selectors/biztable.test.js:
--------------------------------------------------------------------------------
1 | import Immutable from 'seamless-immutable';
2 | import { getBizTable } from '@/store/selectors';
3 | import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
4 |
5 | /* 测试 bizTable selector */
6 | describe('bizTable selector', () => {
7 |
8 | let state;
9 |
10 | beforeEach(() => {
11 | state = createState();
12 | /* 每个用例执行前重置缓存计算次数 */
13 | getBizTable.resetRecomputations();
14 | });
15 |
16 | function createState() {
17 | return Immutable({
18 | bizTable: {
19 | loading: false,
20 | pagination: {
21 | current: 1,
22 | pageSize: 15,
23 | total: 0
24 | },
25 | data: []
26 | }
27 | });
28 | }
29 |
30 | /* 测试返回正确的 bizTable state */
31 | test('should return bizTable state', () => {
32 | /* 业务状态 ok 的 */
33 | expect(getBizTable(state)).toMatchObject(state.bizTable);
34 |
35 | /* 分页默认参数设置 ok 的 */
36 | expect(getBizTable(state)).toMatchObject({
37 | pagination: defaultSettingsUtil.pagination
38 | });
39 | });
40 |
41 | /* 测试 selector 缓存是否有效 */
42 | test('check memoization', () => {
43 | getBizTable(state);
44 | /* 第一次计算,缓存计算次数为 1 */
45 | expect(getBizTable.recomputations()).toBe(1);
46 |
47 | getBizTable(state);
48 | /* 业务状态不变的情况下,缓存计算次数应该还是 1 */
49 | expect(getBizTable.recomputations()).toBe(1);
50 |
51 | const newState = state.setIn(['bizTable', 'loading'], true);
52 | getBizTable(newState);
53 | /* 业务状态改变了,缓存计算次数应该是 2 了 */
54 | expect(getBizTable.recomputations()).toBe(2);
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/client/store/sagas/bizTable.js:
--------------------------------------------------------------------------------
1 | import { all, takeLatest, put, select, call } from 'redux-saga/effects';
2 | import * as type from '../types/bizTable';
3 | import * as actions from '../actions/bizTable';
4 | import { getBizToolbar, getBizTable } from '../selectors';
5 | import * as api from '@/services/bizApi';
6 |
7 | export function* watchBizTableFlow() {
8 | yield all([
9 | takeLatest(type.BIZ_TABLE_GET_REQ, onGetBizTableData),
10 | takeLatest(type.BIZ_TABLE_RELOAD, onReloadBizTableData),
11 | takeLatest(type.BIZ_TABLE_PARAMS_UPDATE, onUpdateBizTableParams)
12 | ]);
13 | }
14 |
15 | export function* onGetBizTableData() {
16 | /* 先获取 api 调用需要的参数:关键字、分页信息等 */
17 | const { keywords } = yield select(getBizToolbar);
18 | const { pagination } = yield select(getBizTable);
19 |
20 | const payload = {
21 | keywords,
22 | paging: {
23 | skip: (pagination.current - 1) * pagination.pageSize, max: pagination.pageSize
24 | }
25 | };
26 |
27 | try {
28 | /* 调用 api */
29 | const result = yield call(api.getBizTableData, payload);
30 | /* 正常返回 */
31 | yield put(actions.putBizTableDataSuccessResult(result));
32 | } catch (err) {
33 | /* 错误返回 */
34 | yield put(actions.putBizTableDataFailResult());
35 | }
36 | }
37 |
38 | export function* onReloadBizTableData() {
39 | const { pagination } = yield select(getBizTable);
40 | yield put(actions.updateBizTableParams({
41 | paging: {
42 | current: 1,
43 | pageSize: pagination.pageSize
44 | }
45 | }));
46 | }
47 |
48 | export function* onUpdateBizTableParams() {
49 | yield put(actions.getBizTableData());
50 | }
51 |
--------------------------------------------------------------------------------
/src/client/components/BizToolbar.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { Row, Col, Button, Input } from 'antd';
3 | import styles from './styles/bizToolbar.less';
4 |
5 | const ButtonGroup = Button.Group;
6 | const Search = Input.Search;
7 |
8 | class BizToolbar extends PureComponent {
9 | constructor(props) {
10 | super(props);
11 | this.state = { keywords: props.keywords };
12 | }
13 |
14 | render() {
15 | const { keywords } = this.state;
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
30 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | handleReload = () => {
44 | const { reload } = this.props;
45 |
46 | /* istanbul ignore else */
47 | if (reload) {
48 | reload();
49 | }
50 | };
51 |
52 | handleChangeKeywords = e => {
53 | this.setState({ keywords: e.target.value });
54 | };
55 |
56 | handleSearchKeywords = value => {
57 | const { updateKeywords } = this.props;
58 |
59 | /* istanbul ignore else */
60 | if (updateKeywords) {
61 | updateKeywords(value);
62 | }
63 | };
64 | }
65 |
66 | export default BizToolbar;
67 |
--------------------------------------------------------------------------------
/__tests__/store/actions/biztable.test.js:
--------------------------------------------------------------------------------
1 | import * as type from '@/store/types/bizTable';
2 | import * as actions from '@/store/actions/bizTable';
3 |
4 | /* 测试 bizTable 相关 actions */
5 | describe('bizTable actions', () => {
6 |
7 | /* 测试获取数据请求 */
8 | test('should create an action for get data request', () => {
9 | const expectedAction = { type: type.BIZ_TABLE_GET_REQ };
10 |
11 | expect(actions.getBizTableData()).toEqual(expectedAction);
12 | });
13 |
14 | /* 测试返回正常数据结果 */
15 | test('should create an action for successful result of get data', () => {
16 | const payload = {
17 | items: [
18 | { id: 1, code: '1' },
19 | { id: 2, code: '2' }
20 | ],
21 | total: 2
22 | };
23 | const expectedAction = {
24 | type: type.BIZ_TABLE_GET_RES_SUCCESS,
25 | payload
26 | };
27 |
28 | expect(actions.putBizTableDataSuccessResult(payload)).toEqual(expectedAction);
29 | });
30 |
31 | /* 测试返回异常数据结果 */
32 | test('should create an action for failing result of get data', () => {
33 | const expectedAction = { type: type.BIZ_TABLE_GET_RES_FAIL };
34 |
35 | expect(actions.putBizTableDataFailResult()).toEqual(expectedAction);
36 | });
37 |
38 | /* 测试改变 table 相关参数(分页) */
39 | test('should create an action for update table params', () => {
40 | const payload = {
41 | paging: { current: 2, pageSize: 25 }
42 | };
43 | const expectedAction = {
44 | type: type.BIZ_TABLE_PARAMS_UPDATE,
45 | payload
46 | };
47 |
48 | expect(actions.updateBizTableParams(payload)).toEqual(expectedAction);
49 | });
50 |
51 | /* 测试刷新 */
52 | test('should create an action for reload', () => {
53 | const expectedAction = { type: type.BIZ_TABLE_RELOAD };
54 |
55 | expect(actions.reloadBizTableData()).toEqual(expectedAction);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/client/components/BizTable.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { Table } from 'antd';
3 |
4 | class BizTable extends PureComponent {
5 | constructor(props) {
6 | super(props);
7 | this.columns = this.createColumns();
8 | }
9 |
10 | createColumns() {
11 | return [
12 | {
13 | title: '编码',
14 | dataIndex: 'code',
15 | key: 'code'
16 | },
17 | {
18 | title: '名称',
19 | dataIndex: 'name',
20 | key: 'name'
21 | },
22 | {
23 | title: '完整名称',
24 | dataIndex: 'wholeName',
25 | key: 'wholeName'
26 | },
27 | {
28 | title: '备注',
29 | dataIndex: 'remark',
30 | key: 'remark'
31 | },
32 | {
33 | title: '最后修改时间',
34 | dataIndex: 'lastModificationTime',
35 | key: 'lastModificationTime'
36 | },
37 | {
38 | title: '最后修改人',
39 | dataIndex: 'lastModifierUserId',
40 | key: 'lastModifierUserId'
41 | }
42 | ];
43 | }
44 |
45 | render() {
46 | const { loading, pagination, data } = this.props;
47 |
48 | return (
49 |
58 | );
59 | }
60 |
61 | handleTableChange = (pagination) => {
62 | const { updateParams } = this.props;
63 |
64 | updateParams({
65 | paging: { current: pagination.current, pageSize: pagination.pageSize }
66 | });
67 | };
68 |
69 | componentDidMount() {
70 | this.initData();
71 | }
72 |
73 | initData() {
74 | const { data, getData } = this.props;
75 |
76 | if ((!data || data.length === 0) && getData) {
77 | getData();
78 | }
79 | }
80 | }
81 |
82 | export default BizTable;
83 |
--------------------------------------------------------------------------------
/__tests__/components/biztoolbar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import sinon from 'sinon';
4 | import { Col, Button, Input } from 'antd';
5 | import BizToolbar from '@/components/BizToolbar';
6 |
7 | /* 测试 UI 组件 BizToolbar */
8 | describe('BizToolbar component', () => {
9 |
10 | const props = {
11 | keywords: 'some keywords',
12 | reload: sinon.fake(),
13 | updateKeywords: sinon.fake()
14 | };
15 | let wrapper;
16 |
17 | beforeEach(() => {
18 | wrapper = mount();
19 | });
20 |
21 | afterEach(() => {
22 | props.reload.resetHistory();
23 | props.updateKeywords.resetHistory();
24 | });
25 |
26 | /* 测试是否渲染了正确的功能子组件 */
27 | test('should render reload button and search input', () => {
28 | expect(wrapper.find(Button).props()).toHaveProperty('title', '刷新');
29 | expect(wrapper.find(Input.Search).exists()).toBe(true);
30 | });
31 |
32 | /* 测试是否渲染了正确的样式 */
33 | test('should render right class', () => {
34 | expect(wrapper.childAt(0).hasClass('toolbarContainer')).toBe(true);
35 | expect(wrapper.find(Col).at(1).hasClass('searchCol')).toBe(true);
36 | });
37 |
38 | /* 测试刷新按钮点击 */
39 | test('simulates click button to reload', () => {
40 | wrapper.find(Button).first().simulate('click');
41 | expect(props.reload.calledOnce).toBe(true);
42 | });
43 |
44 | /* 测试搜索框改变值后内部 state 对应值是否正确更新 */
45 | test('when search input change value, state should change', () => {
46 | const input = wrapper.find(Input.Search);
47 | input.props().onChange({ target: { value: '123' } });
48 | expect(wrapper.state('keywords')).toBe('123');
49 | });
50 |
51 | /* 测试搜索框是否正确触发 updateKeywords */
52 | test('when click search or enter, should updateKeywords', () => {
53 | const input = wrapper.find(Input.Search);
54 | input.props().onSearch('456');
55 | expect(props.updateKeywords.lastCall.args[0]).toBe('456');
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/client/utils/fetcher.js:
--------------------------------------------------------------------------------
1 | import fetch from 'isomorphic-fetch';
2 | import qs from 'querystring';
3 |
4 | function FetchError(message, detail) {
5 | this.name = 'FetchError';
6 | this.message = message;
7 | this.detail = detail;
8 | this.stack = (new Error()).stack;
9 | }
10 |
11 | FetchError.prototype = Object.create(Error.prototype);
12 | FetchError.prototype.constructor = FetchError;
13 |
14 | function resHandlerForJSON(res) {
15 | if (res.status >= 400) {
16 | throw new FetchError(
17 | `fetch error. ${res.status} ${res.statusText}`,
18 | { status: res.status, statusText: res.statusText }
19 | );
20 | } else {
21 | return res.json();
22 | }
23 | }
24 |
25 | function defaultJSONHandler(json) {
26 | if (json.success) {
27 | return json.result;
28 | } else {
29 | throw new FetchError(
30 | `json result status error. ${json.error.message}`,
31 | {
32 | code: json.error.code,
33 | message: json.error.message,
34 | details: json.error.details
35 | }
36 | );
37 | }
38 | }
39 |
40 | const fetcher = {
41 | getJSON(url, params, customHeaders = undefined, credentials = 'include') {
42 | const headers = customHeaders ? new Headers(customHeaders) : new Headers();
43 | headers.append('Accept', 'application/json');
44 | const reqOpts = { method: 'GET', credentials, headers };
45 | const req = new Request(params ? `${url}?${qs.stringify(params)}` : url, reqOpts);
46 | return fetch(req).then(resHandlerForJSON).then(defaultJSONHandler);
47 | },
48 |
49 | postJSON(url, json = {}, customHeaders = undefined, credentials = 'include') {
50 | const headers = customHeaders ? new Headers(customHeaders) : new Headers();
51 | headers.append('Accept', 'application/json');
52 | headers.append('Content-Type', 'application/json;charset=utf-8');
53 | const reqOpts = { method: 'POST', credentials, headers, body: JSON.stringify(json) };
54 | const req = new Request(url, reqOpts);
55 | return fetch(req).then(resHandlerForJSON).then(defaultJSONHandler);
56 | }
57 | };
58 |
59 | export { FetchError, fetcher };
60 |
--------------------------------------------------------------------------------
/__tests__/store/reducers/biztable.test.js:
--------------------------------------------------------------------------------
1 | import * as type from '@/store/types/bizTable';
2 | import reducer, { defaultState } from '@/store/reducers/bizTable';
3 |
4 | /* 测试 bizTable reducer */
5 | describe('bizTable reducer', () => {
6 |
7 | /* 测试未指定 state 参数情况下返回当前缺省 state */
8 | test('should return the default state', () => {
9 | expect(reducer(undefined, { type: 'UNKNOWN' })).toEqual(defaultState);
10 | });
11 |
12 | /* 测试处理获取数据请求 */
13 | test('should handle get data request', () => {
14 | const expectedState = defaultState.set('loading', true);
15 |
16 | expect(
17 | reducer(defaultState, {
18 | type: type.BIZ_TABLE_GET_REQ
19 | })
20 | ).toEqual(expectedState);
21 | });
22 |
23 | /* 测试处理正常数据结果 */
24 | test('should handle successful data response', () => {
25 | /* 模拟返回数据结果 */
26 | const payload = {
27 | items: [
28 | { id: 1, code: '1' },
29 | { id: 2, code: '2' }
30 | ],
31 | total: 2
32 | };
33 | /* 期望返回的状态 */
34 | const expectedState = defaultState
35 | .setIn(['pagination', 'total'], payload.total)
36 | .set('data', payload.items)
37 | .set('loading', false);
38 |
39 | expect(
40 | reducer(defaultState, {
41 | type: type.BIZ_TABLE_GET_RES_SUCCESS,
42 | payload
43 | })
44 | ).toEqual(expectedState);
45 | });
46 |
47 | /* 测试处理异常数据结果 */
48 | test('should handle failing data response', () => {
49 | const expectedState = defaultState.set('loading', false);
50 |
51 | expect(
52 | reducer(defaultState, {
53 | type: type.BIZ_TABLE_GET_RES_FAIL
54 | })
55 | ).toEqual(expectedState);
56 | });
57 |
58 | /* 测试处理更新 table 参数(分页) */
59 | test('should handle updating of table params', () => {
60 | const payload = {
61 | paging: { current: 3, pageSize: 40 }
62 | };
63 | const expectedState = defaultState
64 | .setIn(['pagination', 'current'], payload.paging.current)
65 | .setIn(['pagination', 'pageSize'], payload.paging.pageSize);
66 |
67 | expect(
68 | reducer(defaultState, {
69 | type: type.BIZ_TABLE_PARAMS_UPDATE,
70 | payload
71 | })
72 | ).toEqual(expectedState);
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/__tests__/components/biztable.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import sinon from 'sinon';
4 | import { Table } from 'antd';
5 | import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
6 | import BizTable from '@/components/BizTable';
7 |
8 | /* 测试 UI 组件 BizTable */
9 | describe('BizTable component', () => {
10 |
11 | const defaultProps = {
12 | loading: false,
13 | pagination: Object.assign({}, {
14 | current: 1,
15 | pageSize: 15,
16 | total: 2
17 | }, defaultSettingsUtil.pagination),
18 | data: [{ id: 1 }, { id: 2 }],
19 | getData: sinon.fake(),
20 | updateParams: sinon.fake()
21 | };
22 | let defaultWrapper;
23 |
24 | beforeEach(() => {
25 | defaultWrapper = mount();
26 | });
27 |
28 | afterEach(() => {
29 | defaultProps.getData.resetHistory();
30 | defaultProps.updateParams.resetHistory();
31 | });
32 |
33 | /* 测试是否渲染了正确的功能子组件 */
34 | test('should render table and pagination', () => {
35 | /* 是否渲染了 Table 组件 */
36 | expect(defaultWrapper.find(Table).exists()).toBe(true);
37 | /* 是否渲染了 分页器 组件,样式是否正确(mini) */
38 | expect(defaultWrapper.find('.ant-table-pagination.mini').exists()).toBe(true);
39 | });
40 |
41 | /* 测试首次加载时数据列表为空是否发起加载数据请求 */
42 | test('when componentDidMount and data is empty, should getData', () => {
43 | sinon.spy(BizTable.prototype, 'componentDidMount');
44 | const props = Object.assign({}, defaultProps, {
45 | pagination: Object.assign({}, {
46 | current: 1,
47 | pageSize: 15,
48 | total: 0
49 | }, defaultSettingsUtil.pagination),
50 | data: []
51 | });
52 | const wrapper = mount();
53 |
54 | expect(BizTable.prototype.componentDidMount.calledOnce).toBe(true);
55 | expect(props.getData.calledOnce).toBe(true);
56 | BizTable.prototype.componentDidMount.restore();
57 | });
58 |
59 | /* 测试 table 翻页后是否正确触发 updateParams */
60 | test('when change pagination of table, should updateParams', () => {
61 | const table = defaultWrapper.find(Table);
62 | table.props().onChange({ current: 2, pageSize: 25 });
63 | expect(defaultProps.updateParams.lastCall.args[0])
64 | .toEqual({ paging: { current: 2, pageSize: 25 } });
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/server/index.js:
--------------------------------------------------------------------------------
1 | const Koa = require('koa');
2 | const session = require('koa-session');
3 | const mount = require('koa-mount');
4 | const serve = require('koa-static');
5 | const bodyParser = require('koa-bodyparser');
6 | const views = require('koa-views');
7 | const routeErrorHandler = require('./routes/routeErrorHandler');
8 | const rootRouter = require('./routes/rootRouter');
9 | const pageRouter = require('./routes/pageRouter');
10 | const path = require('path');
11 | const debug = require('debug');
12 | const responseUtil = require('./utils/responseUtil');
13 |
14 | const app = new Koa();
15 | const appDebug = debug('react-test-demo:server');
16 | const cwd = process.cwd();
17 | app.context.responseUtil = responseUtil;
18 |
19 | /**
20 | * set signed cookie keys.
21 | */
22 | app.keys = ['react-test-demo'];
23 |
24 | /**
25 | * set session
26 | */
27 | const sessionConfig = {
28 | key: 'biz:sid', /** (string) cookie key (default is koa:sess) */
29 | /** (number || 'session') maxAge in ms (default is 1 days) */
30 | /** 'session' will result in a cookie that expires when session/browser is closed */
31 | /** Warning: If a session cookie is stolen, this cookie will never expire */
32 | maxAge: 604800,
33 | overwrite: true, /** (boolean) can overwrite or not (default true) */
34 | httpOnly: true, /** (boolean) httpOnly or not (default true) */
35 | signed: true, /** (boolean) signed or not (default true) */
36 | rolling: true /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. default is false **/
37 | };
38 | app.use(session(sessionConfig, app));
39 |
40 | /**
41 | * static resources
42 | */
43 | app.use(mount('/dist', serve(path.join(cwd, 'dist'))));
44 |
45 | /**
46 | * A body parser for koa, base on co-body. support json, form and text type body.
47 | */
48 | app.use(bodyParser({formLimit: '10mb', jsonLimit: '10mb'}));
49 |
50 | /**
51 | * Template rendering middleware for koa.
52 | */
53 | app.use(views(path.join(cwd, 'src/server/views'), {extension: 'ejs'}));
54 |
55 | /**
56 | * biz router
57 | */
58 | app
59 | .use(routeErrorHandler)
60 | .use(rootRouter.routes())
61 | .use(rootRouter.allowedMethods());
62 |
63 | /**
64 | * page router
65 | */
66 | app
67 | .use(pageRouter.routes())
68 | .use(pageRouter.allowedMethods());
69 |
70 | app.on('error', (err, ctx) => {
71 | console.error('server error', err, ctx);
72 | });
73 |
74 | appDebug('current env: ' + app.env);
75 | app.listen(3000, () => console.log('Listening on port: 3000'));
76 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### macOS template
3 | *.DS_Store
4 | .AppleDouble
5 | .LSOverride
6 |
7 | # Icon must end with two \r
8 | Icon
9 |
10 |
11 | # Thumbnails
12 | ._*
13 |
14 | # Files that might appear in the root of a volume
15 | .DocumentRevisions-V100
16 | .fseventsd
17 | .Spotlight-V100
18 | .TemporaryItems
19 | .Trashes
20 | .VolumeIcon.icns
21 | .com.apple.timemachine.donotpresent
22 |
23 | # Directories potentially created on remote AFP share
24 | .AppleDB
25 | .AppleDesktop
26 | Network Trash Folder
27 | Temporary Items
28 | .apdisk
29 | ### Node template
30 | # Logs
31 | logs
32 | *.log
33 | npm-debug.log*
34 |
35 | # Runtime data
36 | pids
37 | *.pid
38 | *.seed
39 | *.pid.lock
40 |
41 | # Directory for instrumented libs generated by jscoverage/JSCover
42 | lib-cov
43 |
44 | # Coverage directory used by tools like istanbul
45 | coverage
46 |
47 | # nyc test coverage
48 | .nyc_output
49 |
50 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
51 | .grunt
52 |
53 | # node-waf configuration
54 | .lock-wscript
55 |
56 | # Compiled binary addons (http://nodejs.org/api/addons.html)
57 | build/Release
58 |
59 | # Dependency directories
60 | node_modules
61 | jspm_packages
62 |
63 | # Optional npm cache directory
64 | .npm
65 |
66 | # Optional eslint cache
67 | .eslintcache
68 |
69 | # Optional REPL history
70 | .node_repl_history
71 |
72 | # Output of 'npm pack'
73 | *.tgz
74 |
75 | # Yarn Integrity file
76 | .yarn-integrity
77 |
78 | ### JetBrains template
79 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
80 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
81 |
82 | # User-specific stuff:
83 | .idea/workspace.xml
84 | .idea/tasks.xml
85 |
86 | # Sensitive or high-churn files:
87 | .idea/dataSources/
88 | .idea/dataSources.ids
89 | .idea/dataSources.xml
90 | .idea/dataSources.local.xml
91 | .idea/sqlDataSources.xml
92 | .idea/dynamic.xml
93 | .idea/uiDesigner.xml
94 |
95 | # Gradle:
96 | .idea/gradle.xml
97 | .idea/libraries
98 |
99 | # Mongo Explorer plugin:
100 | .idea/mongoSettings.xml
101 |
102 | ## File-based project format:
103 | *.iws
104 |
105 | ## Plugin-specific files:
106 |
107 | # IntelliJ
108 | /out/
109 |
110 | # mpeltonen/sbt-idea plugin
111 | .idea_modules/
112 |
113 | # JIRA plugin
114 | atlassian-ide-plugin.xml
115 |
116 | # Crashlytics plugin (for Android Studio and IntelliJ)
117 | com_crashlytics_export_strings.xml
118 | crashlytics.properties
119 | crashlytics-build.properties
120 | fabric.properties
121 | ### Windows template
122 | # Windows image file caches
123 | Thumbs.db
124 | ehthumbs.db
125 |
126 | # Folder config file
127 | Desktop.ini
128 |
129 | # Recycle Bin used on file shares
130 | $RECYCLE.BIN/
131 |
132 | # Windows Installer files
133 | *.cab
134 | *.msi
135 | *.msm
136 | *.msp
137 |
138 | # Windows shortcuts
139 | *.lnk
140 |
141 | /.idea/
142 | /dist/
143 | /package-lock.json
144 |
--------------------------------------------------------------------------------
/__tests__/store/sagas/biztable.test.js:
--------------------------------------------------------------------------------
1 | import { put, select } from 'redux-saga/effects';
2 | import { cloneableGenerator } from 'redux-saga/utils';
3 | import * as saga from '@/store/sagas/bizTable';
4 | import * as actions from '@/store/actions/bizTable';
5 | import { getBizToolbar, getBizTable } from '@/store/selectors';
6 | import * as api from '@/services/bizApi';
7 |
8 | /* 测试 bizTable saga */
9 | describe('bizToolbar saga', () => {
10 |
11 | /* 测试 bizTable 业务流入口 */
12 | test('should watch bizToolbar flow', () => {
13 | const gen = saga.watchBizTableFlow();
14 |
15 | expect(gen.next().value['ALL']).toHaveLength(3);
16 | expect(gen.next().done).toBe(true);
17 | });
18 |
19 | /* 测试更新 table 参数(分页)时是否触发 bizTable 请求数据 */
20 | test('when update table params should request data', () => {
21 | const gen = saga.onUpdateBizTableParams();
22 |
23 | expect(gen.next().value).toEqual(put(actions.getBizTableData()));
24 | expect(gen.next().done).toBe(true);
25 | });
26 |
27 | /* 测试刷新 table 时是否正确发出请求参数(分页) */
28 | test('when reload table should request data with right params', () => {
29 | const state = {
30 | bizTable: {
31 | pagination: {
32 | current: 4,
33 | pageSize: 15,
34 | total: 100
35 | }
36 | }
37 | };
38 | const gen = saga.onReloadBizTableData();
39 |
40 | expect(gen.next().value).toEqual(select(getBizTable));
41 | expect(gen.next(state.bizTable).value)
42 | .toEqual(put(actions.updateBizTableParams({
43 | paging: { current: 1, pageSize: state.bizTable.pagination.pageSize }
44 | })));
45 | expect(gen.next().done).toBe(true);
46 | });
47 |
48 | /* 测试获取数据 */
49 | test('request data, check success and fail', () => {
50 | /* 当前的业务状态 */
51 | const state = {
52 | bizToolbar: {
53 | keywords: 'some keywords'
54 | },
55 | bizTable: {
56 | pagination: {
57 | current: 1,
58 | pageSize: 15
59 | }
60 | }
61 | };
62 | const gen = cloneableGenerator(saga.onGetBizTableData)();
63 |
64 | /* 1. 是否调用了正确的 selector 来获得请求时要发送的参数 */
65 | expect(gen.next().value).toEqual(select(getBizToolbar));
66 | expect(gen.next(state.bizToolbar).value).toEqual(select(getBizTable));
67 |
68 | /* 2. 是否调用了 api 层 */
69 | const callEffect = gen.next(state.bizTable).value;
70 | expect(callEffect['CALL'].fn).toBe(api.getBizTableData);
71 | /* 调用 api 层参数是否传递正确 */
72 | expect(callEffect['CALL'].args[0]).toEqual({
73 | keywords: 'some keywords',
74 | paging: { skip: 0, max: 15 }
75 | });
76 |
77 | /* 3. 模拟正确返回分支 */
78 | const successBranch = gen.clone();
79 | const successRes = {
80 | items: [
81 | { id: 1, code: '1' },
82 | { id: 2, code: '2' }
83 | ],
84 | total: 2
85 | };
86 | expect(successBranch.next(successRes).value).toEqual(
87 | put(actions.putBizTableDataSuccessResult(successRes)));
88 | expect(successBranch.next().done).toBe(true);
89 |
90 | /* 4. 模拟错误返回分支 */
91 | const failBranch = gen.clone();
92 | expect(failBranch.throw(new Error('模拟产生异常')).value).toEqual(
93 | put(actions.putBizTableDataFailResult()));
94 | expect(failBranch.next().done).toBe(true);
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/__tests__/utils/fetcher.test.js:
--------------------------------------------------------------------------------
1 | import nock from 'nock';
2 | import { fetcher, FetchError } from '@/utils/fetcher';
3 |
4 | /* 测试 fetcher */
5 | describe('fetcher', () => {
6 |
7 | afterEach(() => {
8 | nock.cleanAll();
9 | });
10 |
11 | afterAll(() => {
12 | nock.restore();
13 | });
14 |
15 | /* 测试 getJSON 获得正常数据 */
16 | test('should get success result', () => {
17 | nock('http://some')
18 | .get('/test')
19 | .reply(200, { success: true, result: 'hello, world' });
20 |
21 | return expect(fetcher.getJSON('http://some/test')).resolves.toMatch(/^hello.+$/);
22 | });
23 |
24 | /* 测试 getJSON 获得逻辑异常数据 */
25 | test('should get fail result', async () => {
26 | nock('http://some')
27 | .get('/test')
28 | .reply(200, { success: false, error: { code: 666, message: 'destroy the world' } });
29 |
30 | try {
31 | await fetcher.getJSON('http://some/test');
32 | } catch (error) {
33 | expect(error).toEqual(expect.any(FetchError));
34 | expect(error).toHaveProperty('detail');
35 | const { detail } = error;
36 | expect(detail.code).toBe(666);
37 | expect(detail.message).toMatch(/^destroy/);
38 | }
39 | });
40 |
41 | /* 测试 getJSON 捕获 server 大于 400 的异常状态 */
42 | test('should catch server status: 400+', (done) => {
43 | const status = 500;
44 | nock('http://some')
45 | .get('/test')
46 | .reply(status);
47 |
48 | fetcher.getJSON('http://some/test').catch((error) => {
49 | expect(error).toEqual(expect.any(FetchError));
50 | expect(error).toHaveProperty('detail');
51 | expect(error.detail.status).toBe(status);
52 | done();
53 | });
54 | });
55 |
56 | /* 测试 getJSON 传递正确的 headers 和 query strings */
57 | test('check headers and query string of getJSON()', () => {
58 | nock('http://some', {
59 | reqheaders: {
60 | 'Accept': 'application/json',
61 | 'authorization': 'Basic Auth'
62 | }
63 | })
64 | .get('/test')
65 | .query({ a: '123', b: 456 })
66 | .reply(200, { success: true, result: true });
67 |
68 | const headers = new Headers();
69 | headers.append('authorization', 'Basic Auth');
70 | return expect(fetcher.getJSON(
71 | 'http://some/test', { a: '123', b: 456 }, headers)).resolves.toBe(true);
72 | });
73 |
74 | /* 测试 postJSON,类似 getJSON 的方式将以上几种情况结合起来 */
75 | test('check postJSON()', async () => {
76 | nock('http://some', {
77 | reqheaders: {
78 | 'Accept': 'application/json',
79 | 'Content-Type': 'application/json;charset=utf-8',
80 | 'authorization': 'Basic Auth'
81 | }
82 | })
83 | .post('/test', { a: '123', b: 456 })
84 | .reply(200, { success: true, result: true })
85 | .post('/test/nobody')
86 | .reply(200, { success: true, result: true });
87 |
88 | expect.assertions(3);
89 | const headers = new Headers();
90 | headers.append('authorization', 'Basic Auth');
91 | await expect(fetcher.postJSON('http://some/test', {
92 | a: '123',
93 | b: 456
94 | }, headers)).resolves.toBe(true);
95 | await expect(fetcher.postJSON('http://some/test', {
96 | a: '123',
97 | b: 456
98 | })).rejects.toEqual(expect.any(Error));
99 | await expect(fetcher.postJSON('http://some/test/nobody', undefined, headers)).resolves.toBe(true);
100 | });
101 | });
102 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-test-demo",
3 | "version": "1.0.0",
4 | "author": "Xie Kai <6261625@qq.com>",
5 | "scripts": {
6 | "start-dev": "cross-env NODE_ENV=development webpack-dev-server --colors --config ./webpack.dev.config.js",
7 | "start-server": "cross-env NODE_ENV=development node ./bin/www.js",
8 | "test": "jest",
9 | "test-coverage": "jest --coverage"
10 | },
11 | "dependencies": {
12 | "@babel/polyfill": "^7.0.0",
13 | "@babel/runtime": "^7.4.2",
14 | "antd": "^3.15.2",
15 | "classnames": "^2.2.5",
16 | "cross-env": "^5.0.1",
17 | "debug": "^4.1.1",
18 | "ejs": "^2.6.1",
19 | "es6-promise": "^4.2.6",
20 | "flux-standard-action": "^2.0.4",
21 | "isomorphic-fetch": "^2.2.1",
22 | "koa": "^2.7.0",
23 | "koa-bodyparser": "^4.2.0",
24 | "koa-mount": "^3.0.0",
25 | "koa-router": "^7.4.0",
26 | "koa-session": "^5.10.1",
27 | "koa-static": "^4.0.2",
28 | "koa-views": "^6.1.5",
29 | "morgan": "^1.9.1",
30 | "prop-types": "^15.7.2",
31 | "querystring": "^0.2.0",
32 | "react": "^16.8.5",
33 | "react-dom": "^16.8.5",
34 | "react-redux": "^4.4.6",
35 | "redux": "^4.0.1",
36 | "redux-actions": "^2.6.5",
37 | "redux-saga": "^0.16.0",
38 | "reselect": "^3.0.1",
39 | "seamless-immutable": "^7.1.4",
40 | "serve-favicon": "^2.5.0",
41 | "urlencode": "^1.1.0"
42 | },
43 | "devDependencies": {
44 | "@babel/cli": "^7.0.0",
45 | "@babel/core": "^7.0.0",
46 | "@babel/plugin-proposal-class-properties": "^7.0.0",
47 | "@babel/plugin-proposal-decorators": "^7.0.0",
48 | "@babel/plugin-proposal-do-expressions": "^7.0.0",
49 | "@babel/plugin-proposal-export-default-from": "^7.0.0",
50 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0",
51 | "@babel/plugin-proposal-function-bind": "^7.0.0",
52 | "@babel/plugin-proposal-function-sent": "^7.0.0",
53 | "@babel/plugin-proposal-json-strings": "^7.0.0",
54 | "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0",
55 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0",
56 | "@babel/plugin-proposal-numeric-separator": "^7.0.0",
57 | "@babel/plugin-proposal-optional-chaining": "^7.0.0",
58 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0",
59 | "@babel/plugin-proposal-throw-expressions": "^7.0.0",
60 | "@babel/plugin-syntax-dynamic-import": "^7.0.0",
61 | "@babel/plugin-syntax-import-meta": "^7.0.0",
62 | "@babel/plugin-transform-runtime": "^7.0.0",
63 | "@babel/preset-env": "^7.4.2",
64 | "@babel/preset-react": "^7.0.0",
65 | "@babel/register": "^7.0.0",
66 | "@types/jest": "^23.1.6",
67 | "autoprefixer": "^7.1.1",
68 | "babel-core": "^7.0.0-bridge.0",
69 | "babel-jest": "^23.4.2",
70 | "babel-loader": "^8.0.0",
71 | "babel-plugin-add-module-exports": "^0.2.1",
72 | "babel-plugin-import": "^1.11.0",
73 | "babel-plugin-lodash": "^3.2.11",
74 | "babel-plugin-recharts": "^1.2.0",
75 | "concurrently": "^3.5.1",
76 | "css-loader": "^0.28.7",
77 | "enzyme": "^3.9.0",
78 | "enzyme-adapter-react-16": "^1.11.2",
79 | "identity-obj-proxy": "^3.0.0",
80 | "jest": "^23.1.0",
81 | "less": "^2.7.3",
82 | "less-loader": "^4.0.5",
83 | "nock": "^9.4.2",
84 | "postcss-loader": "^2.0.6",
85 | "redux-mock-store": "^1.5.3",
86 | "regenerator-runtime": "^0.12.0",
87 | "sinon": "^6.1.3",
88 | "style-loader": "^0.13.1",
89 | "webpack": "^4.29.6",
90 | "webpack-cli": "^3.3.0",
91 | "webpack-dev-server": "^3.2.1"
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Web 前端单元测试到底要怎么写?看这一篇就够了
2 |
3 | ```sh
4 | # 先说一下示例怎么运行,先确定本机安装好 node 环境
5 |
6 | # 安装项目依赖
7 | npm install
8 |
9 | # 首先启动 webpack-dev-server
10 | npm run start-dev
11 |
12 | # 上一个运行完毕后不要关闭,开一个新的命令行,启动 node server 服务
13 | npm run start-server
14 |
15 | #上述两个启动好后打开浏览器访问 http://localhost:3000 即可
16 |
17 | # 跑测试用例
18 | npm test
19 |
20 | # 生成测试覆盖报告,跑完后看 coverage 子目录下的内容
21 | npm run test-coverage
22 |
23 | # 以上脚本定义都在 package.json 中
24 | ```
25 |
26 |
27 |
28 | 随着 Web 应用的复杂程度越来越高,很多公司越来越重视前端单元测试。我们看到的大多数教程都会讲单元测试的重要性、一些有代表性的测试框架 api 怎么使用,但在实际项目中单元测试要怎么下手?测试用例应该包含哪些具体内容呢?
29 |
30 | 本文从一个真实的应用场景出发,从设计模式、代码结构来分析单元测试应该包含哪些内容,具体测试用例怎么写,希望看到的童鞋都能有所收获。
31 |
32 |
33 |
34 | ## 项目用到的技术框架
35 |
36 | 该项目采用 `react` 技术栈,用到的主要框架包括:`react`、`redux`、`react-redux`、`redux-actions`、`reselect`、`redux-saga`、`seamless-immutable`、`antd`。
37 |
38 |
39 |
40 | ## 应用场景介绍
41 |
42 | 
43 |
44 |
45 |
46 | 这个应用场景从 UI 层来讲主要由两个部分组成:
47 |
48 | - 工具栏,包含刷新按钮、关键字搜索框
49 | - 表格展示,采用分页的形式浏览
50 |
51 |
52 |
53 | 看到这里有的童鞋可能会说:切!这么简单的界面和业务逻辑,还是真实场景吗,还需要写神马单元测试吗?
54 |
55 | 别急,为了保证文章的阅读体验和长度适中,能讲清楚问题的简洁场景就是好场景不是吗?慢慢往下看。
56 |
57 |
58 |
59 | ## 设计模式与结构分析
60 |
61 | 在这个场景设计开发中,我们严格遵守 `redux` 单向数据流 与 `react-redux` 的最佳实践,并采用 `redux-saga` 来处理业务流,`reselect` 来处理状态缓存,通过 `fetch` 来调用后台接口,与真实的项目没有差异。
62 |
63 | 分层设计与代码组织如下所示:
64 |
65 | 
66 |
67 |
68 |
69 | 中间 `store` 中的内容都是 `redux` 相关的,看名称应该都能知道意思了。
70 |
71 | 具体的代码请看 [这里](https://github.com/deepfunc/react-test-demo)。
72 |
73 |
74 |
75 | ## 单元测试部分介绍
76 |
77 | 先讲一下用到了哪些测试框架和工具,主要内容包括:
78 |
79 | - `jest` ,测试框架
80 | - `enzyme` ,专测 react ui 层
81 | - `sinon` ,具有独立的 fakes、spies、stubs、mocks 功能库
82 | - `nock` ,模拟 HTTP Server
83 |
84 |
85 |
86 | 如果有童鞋对上面这些使用和配置不熟的话,直接看官方文档吧,比任何教程都写的好。
87 |
88 | 接下来,我们就开始编写具体的测试用例代码了,下面会针对每个层面给出代码片段和解析。那么我们先从 `actions` 开始吧。
89 |
90 | > 为使文章尽量简短、清晰,下面的代码片段不是每个文件的完整内容,完整内容在 [这里](https://github.com/deepfunc/react-test-demo) 。
91 |
92 |
93 |
94 | ## actions
95 |
96 | 业务里面我使用了 `redux-actions` 来产生 `action`,这里用工具栏做示例,先看一段业务代码:
97 |
98 | ```javascript
99 | import { createAction } from 'redux-actions';
100 | import * as type from '../types/bizToolbar';
101 |
102 | export const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE);
103 |
104 | // ...
105 | ```
106 |
107 |
108 |
109 | 对于 `actions` 测试,我们主要是验证产生的 `action` 对象是否正确:
110 |
111 | ```javascript
112 | import * as type from '@/store/types/bizToolbar';
113 | import * as actions from '@/store/actions/bizToolbar';
114 |
115 | /* 测试 bizToolbar 相关 actions */
116 | describe('bizToolbar actions', () => {
117 |
118 | /* 测试更新搜索关键字 */
119 | test('should create an action for update keywords', () => {
120 | // 构建目标 action
121 | const keywords = 'some keywords';
122 | const expectedAction = {
123 | type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,
124 | payload: keywords
125 | };
126 |
127 | // 断言 redux-actions 产生的 action 是否正确
128 | expect(actions.updateKeywords(keywords)).toEqual(expectedAction);
129 | });
130 |
131 | // ...
132 | });
133 | ```
134 |
135 |
136 |
137 | 这个测试用例的逻辑很简单,首先构建一个我们期望的结果,然后调用业务代码,最后验证业务代码的运行结果与期望是否一致。这就是写测试用例的基本套路。
138 |
139 | 我们在写测试用例时尽量保持用例的单一职责,不要覆盖太多不同的业务范围。测试用例数量可以有很多个,但每个都不应该很复杂。
140 |
141 |
142 |
143 | ## reducers
144 |
145 | 接着是 `reducers`,依然采用 `redux-actions` 的 `handleActions` 来编写 `reducer`,这里用表格的来做示例:
146 |
147 | ```javascript
148 | import { handleActions } from 'redux-actions';
149 | import Immutable from 'seamless-immutable';
150 | import * as type from '../types/bizTable';
151 |
152 | /* 默认状态 */
153 | export const defaultState = Immutable({
154 | loading: false,
155 | pagination: {
156 | current: 1,
157 | pageSize: 15,
158 | total: 0
159 | },
160 | data: []
161 | });
162 |
163 | export default handleActions(
164 | {
165 | // ...
166 |
167 | /* 处理获得数据成功 */
168 | [type.BIZ_TABLE_GET_RES_SUCCESS]: (state, {payload}) => {
169 | return state.merge(
170 | {
171 | loading: false,
172 | pagination: {total: payload.total},
173 | data: payload.items
174 | },
175 | {deep: true}
176 | );
177 | },
178 |
179 | // ...
180 | },
181 | defaultState
182 | );
183 | ```
184 |
185 | > 这里的状态对象使用了 `seamless-immutable`
186 |
187 |
188 |
189 | 对于 `reducer`,我们主要测试两个方面:
190 |
191 | 1. 对于未知的 `action.type` ,是否能返回当前状态。
192 | 2. 对于每个业务 type ,是否都返回了经过正确处理的状态。
193 |
194 |
195 |
196 | 下面是针对以上两点的测试代码:
197 |
198 | ```javascript
199 | import * as type from '@/store/types/bizTable';
200 | import reducer, { defaultState } from '@/store/reducers/bizTable';
201 |
202 | /* 测试 bizTable reducer */
203 | describe('bizTable reducer', () => {
204 |
205 | /* 测试未指定 state 参数情况下返回当前缺省 state */
206 | test('should return the default state', () => {
207 | expect(reducer(undefined, {type: 'UNKNOWN'})).toEqual(defaultState);
208 | });
209 |
210 | // ...
211 |
212 | /* 测试处理正常数据结果 */
213 | test('should handle successful data response', () => {
214 | /* 模拟返回数据结果 */
215 | const payload = {
216 | items: [
217 | {id: 1, code: '1'},
218 | {id: 2, code: '2'}
219 | ],
220 | total: 2
221 | };
222 | /* 期望返回的状态 */
223 | const expectedState = defaultState
224 | .setIn(['pagination', 'total'], payload.total)
225 | .set('data', payload.items)
226 | .set('loading', false);
227 |
228 | expect(
229 | reducer(defaultState, {
230 | type: type.BIZ_TABLE_GET_RES_SUCCESS,
231 | payload
232 | })
233 | ).toEqual(expectedState);
234 | });
235 |
236 | // ...
237 | });
238 | ```
239 |
240 |
241 |
242 | 这里的测试用例逻辑也很简单,依然是上面断言期望结果的套路。下面是 selectors 的部分。
243 |
244 |
245 |
246 | ## selectors
247 |
248 | `selector` 的作用是获取对应业务的状态,这里使用了 `reselect` 来做缓存,防止 `state` 未改变的情况下重新计算,先看一下表格的 selector 代码:
249 |
250 | ```javascript
251 | import { createSelector } from 'reselect';
252 | import * as defaultSettings from '@/utils/defaultSettingsUtil';
253 |
254 | // ...
255 |
256 | const getBizTableState = (state) => state.bizTable;
257 |
258 | export const getBizTable = createSelector(getBizTableState, (bizTable) => {
259 | return bizTable.merge({
260 | pagination: defaultSettings.pagination
261 | }, {deep: true});
262 | });
263 | ```
264 |
265 |
266 |
267 | 这里的分页器部分参数在项目中是统一设置,所以 reselect 很好的完成了这个工作:如果业务状态不变,直接返回上次的缓存。分页器默认设置如下:
268 |
269 |
270 |
271 | ```javascript
272 | export const pagination = {
273 | size: 'small',
274 | showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`,
275 | pageSizeOptions: ['15', '25', '40', '60'],
276 | showSizeChanger: true,
277 | showQuickJumper: true
278 | };
279 | ```
280 |
281 |
282 |
283 | 那么我们的测试也主要是两个方面:
284 |
285 | 1. 对于业务 selector ,是否返回了正确的内容。
286 | 2. 缓存功能是否正常。
287 |
288 |
289 |
290 | 测试代码如下:
291 |
292 | ```javascript
293 | import Immutable from 'seamless-immutable';
294 | import { getBizTable } from '@/store/selectors';
295 | import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
296 |
297 | /* 测试 bizTable selector */
298 | describe('bizTable selector', () => {
299 |
300 | let state;
301 |
302 | beforeEach(() => {
303 | state = createState();
304 | /* 每个用例执行前重置缓存计算次数 */
305 | getBizTable.resetRecomputations();
306 | });
307 |
308 | function createState() {
309 | return Immutable({
310 | bizTable: {
311 | loading: false,
312 | pagination: {
313 | current: 1,
314 | pageSize: 15,
315 | total: 0
316 | },
317 | data: []
318 | }
319 | });
320 | }
321 |
322 | /* 测试返回正确的 bizTable state */
323 | test('should return bizTable state', () => {
324 | /* 业务状态 ok 的 */
325 | expect(getBizTable(state)).toMatchObject(state.bizTable);
326 |
327 | /* 分页默认参数设置 ok 的 */
328 | expect(getBizTable(state)).toMatchObject({
329 | pagination: defaultSettingsUtil.pagination
330 | });
331 | });
332 |
333 | /* 测试 selector 缓存是否有效 */
334 | test('check memoization', () => {
335 | getBizTable(state);
336 | /* 第一次计算,缓存计算次数为 1 */
337 | expect(getBizTable.recomputations()).toBe(1);
338 |
339 | getBizTable(state);
340 | /* 业务状态不变的情况下,缓存计算次数应该还是 1 */
341 | expect(getBizTable.recomputations()).toBe(1);
342 |
343 | const newState = state.setIn(['bizTable', 'loading'], true);
344 | getBizTable(newState);
345 | /* 业务状态改变了,缓存计算次数应该是 2 了 */
346 | expect(getBizTable.recomputations()).toBe(2);
347 | });
348 | });
349 | ```
350 |
351 |
352 |
353 | 测试用例依然很简单有木有?保持这个节奏就对了。下面来讲下稍微有点复杂的地方,sagas 部分。
354 |
355 |
356 |
357 | ## sagas
358 |
359 | 这里我用了 `redux-saga` 处理业务流,这里具体也就是异步调用 api 请求数据,处理成功结果和错误结果等。
360 |
361 | 可能有的童鞋觉得搞这么复杂干嘛,异步请求用个 `redux-thunk` 不就完事了吗?别急,耐心看完你就明白了。
362 |
363 |
364 |
365 | 这里有必要大概介绍下 `redux-saga` 的工作方式。saga 是一种 `es6` 的生成器函数 - Generator ,我们利用他来产生各种声明式的 `effects` ,由 `redux-saga` 引擎来消化处理,推动业务进行。
366 |
367 |
368 |
369 | 这里我们来看看获取表格数据的业务代码:
370 |
371 | ```javascript
372 | import { all, takeLatest, put, select, call } from 'redux-saga/effects';
373 | import * as type from '../types/bizTable';
374 | import * as actions from '../actions/bizTable';
375 | import { getBizToolbar, getBizTable } from '../selectors';
376 | import * as api from '@/services/bizApi';
377 |
378 | // ...
379 |
380 | export function* onGetBizTableData() {
381 | /* 先获取 api 调用需要的参数:关键字、分页信息等 */
382 | const {keywords} = yield select(getBizToolbar);
383 | const {pagination} = yield select(getBizTable);
384 |
385 | const payload = {
386 | keywords,
387 | paging: {
388 | skip: (pagination.current - 1) * pagination.pageSize, max: pagination.pageSize
389 | }
390 | };
391 |
392 | try {
393 | /* 调用 api */
394 | const result = yield call(api.getBizTableData, payload);
395 | /* 正常返回 */
396 | yield put(actions.putBizTableDataSuccessResult(result));
397 | } catch (err) {
398 | /* 错误返回 */
399 | yield put(actions.putBizTableDataFailResult());
400 | }
401 | }
402 | ```
403 |
404 |
405 |
406 | 不熟悉 `redux-saga` 的童鞋也不要太在意代码的具体写法,看注释应该能了解这个业务的具体步骤:
407 |
408 | 1. 从对应的 `state` 里取到调用 api 时需要的参数部分(搜索关键字、分页),这里调用了刚才的 selector。
409 | 2. 组合好参数并调用对应的 api 层。
410 | 3. 如果正常返回结果,则发送成功 action 通知 reducer 更新状态。
411 | 4. 如果错误返回,则发送错误 action 通知 reducer。
412 |
413 |
414 |
415 | 那么具体的测试用例应该怎么写呢?我们都知道这种业务代码涉及到了 api 或其他层的调用,如果要写单元测试必须做一些 mock 之类来防止真正调用 api 层,下面我们来看一下 怎么针对这个 saga 来写测试用例:
416 |
417 | ```javascript
418 | import { put, select } from 'redux-saga/effects';
419 |
420 | // ...
421 |
422 | /* 测试获取数据 */
423 | test('request data, check success and fail', () => {
424 | /* 当前的业务状态 */
425 | const state = {
426 | bizToolbar: {
427 | keywords: 'some keywords'
428 | },
429 | bizTable: {
430 | pagination: {
431 | current: 1,
432 | pageSize: 15
433 | }
434 | }
435 | };
436 | const gen = cloneableGenerator(saga.onGetBizTableData)();
437 |
438 | /* 1. 是否调用了正确的 selector 来获得请求时要发送的参数 */
439 | expect(gen.next().value).toEqual(select(getBizToolbar));
440 | expect(gen.next(state.bizToolbar).value).toEqual(select(getBizTable));
441 |
442 | /* 2. 是否调用了 api 层 */
443 | const callEffect = gen.next(state.bizTable).value;
444 | expect(callEffect['CALL'].fn).toBe(api.getBizTableData);
445 | /* 调用 api 层参数是否传递正确 */
446 | expect(callEffect['CALL'].args[0]).toEqual({
447 | keywords: 'some keywords',
448 | paging: {skip: 0, max: 15}
449 | });
450 |
451 | /* 3. 模拟正确返回分支 */
452 | const successBranch = gen.clone();
453 | const successRes = {
454 | items: [
455 | {id: 1, code: '1'},
456 | {id: 2, code: '2'}
457 | ],
458 | total: 2
459 | };
460 | expect(successBranch.next(successRes).value).toEqual(
461 | put(actions.putBizTableDataSuccessResult(successRes)));
462 | expect(successBranch.next().done).toBe(true);
463 |
464 | /* 4. 模拟错误返回分支 */
465 | const failBranch = gen.clone();
466 | expect(failBranch.throw(new Error('模拟产生异常')).value).toEqual(
467 | put(actions.putBizTableDataFailResult()));
468 | expect(failBranch.next().done).toBe(true);
469 | });
470 | ```
471 |
472 |
473 |
474 | 这个测试用例相比前面的复杂了一些,我们先来说下测试 saga 的原理。前面说过 saga 实际上是返回各种声明式的 `effects` ,然后由引擎来真正执行。所以我们测试的目的就是要看 `effects` 的产生是否符合预期。那么`effect` 到底是个神马东西呢?其实就是字面量对象!
475 |
476 | 我们可以用在业务代码同样的方式来产生这些字面量对象,对于字面量对象的断言就非常简单了,并且没有直接调用 api 层,就用不着做 mock 咯!这个测试用例的步骤就是利用生成器函数一步步的产生下一个 `effect` ,然后断言比较。
477 |
478 | > 从上面的注释 3、4 可以看到,`redux-saga` 还提供了一些辅助函数来方便的处理分支断点。
479 |
480 |
481 |
482 | 这也是我选择 `redux-saga` 的原因:强大并且利于测试。
483 |
484 |
485 |
486 | ## api 和 fetch 工具库
487 |
488 | 接下来就是api 层相关的了。前面讲过调用后台请求是用的 `fetch` ,我封装了两个方法来简化调用和结果处理:`getJSON()` 、`postJSON()` ,分别对应 GET 、POST 请求。先来看看 api 层代码:
489 |
490 | ```javascript
491 | import { fetcher } from '@/utils/fetcher';
492 |
493 | export function getBizTableData(payload) {
494 | return fetcher.postJSON('/api/biz/get-table', payload);
495 | }
496 | ```
497 |
498 |
499 |
500 | 业务代码很简单,那么测试用例也很简单:
501 |
502 | ```javascript
503 | import sinon from 'sinon';
504 | import { fetcher } from '@/utils/fetcher';
505 | import * as api from '@/services/bizApi';
506 |
507 | /* 测试 bizApi */
508 | describe('bizApi', () => {
509 |
510 | let fetcherStub;
511 |
512 | beforeAll(() => {
513 | fetcherStub = sinon.stub(fetcher);
514 | });
515 |
516 | // ...
517 |
518 | /* getBizTableData api 应该调用正确的 method 和传递正确的参数 */
519 | test('getBizTableData api should call postJSON with right params of fetcher', () => {
520 | /* 模拟参数 */
521 | const payload = {a: 1, b: 2};
522 | api.getBizTableData(payload);
523 |
524 | /* 检查是否调用了工具库 */
525 | expect(fetcherStub.postJSON.callCount).toBe(1);
526 | /* 检查调用参数是否正确 */
527 | expect(fetcherStub.postJSON.lastCall.calledWith('/api/biz/get-table', payload)).toBe(true);
528 | });
529 | });
530 | ```
531 |
532 |
533 |
534 | 由于 api 层直接调用了工具库,所以这里用 `sinon.stub()` 来替换工具库达到测试目的。
535 |
536 |
537 |
538 | 接着就是测试自己封装的 fetch 工具库了,这里 fetch 我是用的 `isomorphic-fetch` ,所以选择了 `nock` 来模拟 Server 进行测试,主要是测试正常访问返回结果和模拟服务器异常等,示例片段如下:
539 |
540 | ```javascript
541 | import nock from 'nock';
542 | import { fetcher, FetchError } from '@/utils/fetcher';
543 |
544 | /* 测试 fetcher */
545 | describe('fetcher', () => {
546 |
547 | afterEach(() => {
548 | nock.cleanAll();
549 | });
550 |
551 | afterAll(() => {
552 | nock.restore();
553 | });
554 |
555 | /* 测试 getJSON 获得正常数据 */
556 | test('should get success result', () => {
557 | nock('http://some')
558 | .get('/test')
559 | .reply(200, {success: true, result: 'hello, world'});
560 |
561 | return expect(fetcher.getJSON('http://some/test')).resolves.toMatch(/^hello.+$/);
562 | });
563 |
564 | // ...
565 |
566 | /* 测试 getJSON 捕获 server 大于 400 的异常状态 */
567 | test('should catch server status: 400+', (done) => {
568 | const status = 500;
569 | nock('http://some')
570 | .get('/test')
571 | .reply(status);
572 |
573 | fetcher.getJSON('http://some/test').catch((error) => {
574 | expect(error).toEqual(expect.any(FetchError));
575 | expect(error).toHaveProperty('detail');
576 | expect(error.detail.status).toBe(status);
577 | done();
578 | });
579 | });
580 |
581 | /* 测试 getJSON 传递正确的 headers 和 query strings */
582 | test('check headers and query string of getJSON()', () => {
583 | nock('http://some', {
584 | reqheaders: {
585 | 'Accept': 'application/json',
586 | 'authorization': 'Basic Auth'
587 | }
588 | })
589 | .get('/test')
590 | .query({a: '123', b: 456})
591 | .reply(200, {success: true, result: true});
592 |
593 | const headers = new Headers();
594 | headers.append('authorization', 'Basic Auth');
595 | return expect(fetcher.getJSON(
596 | 'http://some/test', {a: '123', b: 456}, headers)).resolves.toBe(true);
597 | });
598 |
599 | // ...
600 | });
601 | ```
602 |
603 |
604 |
605 | 基本也没什么复杂的,主要注意 fetch 是 promise 返回,`jest` 的各种异步测试方案都能很好满足。
606 |
607 | 剩下的部分就是跟 UI 相关的了。
608 |
609 |
610 |
611 | ## 容器组件
612 |
613 | 容器组件的主要目的是传递 state 和 actions,看下工具栏的容器组件代码:
614 |
615 | ```javascript
616 | import { connect } from 'react-redux';
617 | import { getBizToolbar } from '@/store/selectors';
618 | import * as actions from '@/store/actions/bizToolbar';
619 | import BizToolbar from '@/components/BizToolbar';
620 |
621 | const mapStateToProps = (state) => ({
622 | ...getBizToolbar(state)
623 | });
624 |
625 | const mapDispatchToProps = {
626 | reload: actions.reload,
627 | updateKeywords: actions.updateKeywords
628 | };
629 |
630 | export default connect(mapStateToProps, mapDispatchToProps)(BizToolbar);
631 | ```
632 |
633 |
634 |
635 | 那么测试用例的目的也是检查这些,这里使用了 `redux-mock-store` 来模拟 redux 的 store :
636 |
637 | ```react
638 | import React from 'react';
639 | import { shallow } from 'enzyme';
640 | import configureStore from 'redux-mock-store';
641 | import BizToolbar from '@/containers/BizToolbar';
642 |
643 | /* 测试容器组件 BizToolbar */
644 | describe('BizToolbar container', () => {
645 |
646 | const initialState = {
647 | bizToolbar: {
648 | keywords: 'some keywords'
649 | }
650 | };
651 | const mockStore = configureStore();
652 | let store;
653 | let container;
654 |
655 | beforeEach(() => {
656 | store = mockStore(initialState);
657 | container = shallow();
658 | });
659 |
660 | /* 测试 state 到 props 的映射是否正确 */
661 | test('should pass state to props', () => {
662 | const props = container.props();
663 |
664 | expect(props).toHaveProperty('keywords', initialState.bizToolbar.keywords);
665 | });
666 |
667 | /* 测试 actions 到 props 的映射是否正确 */
668 | test('should pass actions to props', () => {
669 | const props = container.props();
670 |
671 | expect(props).toHaveProperty('reload', expect.any(Function));
672 | expect(props).toHaveProperty('updateKeywords', expect.any(Function));
673 | });
674 | });
675 | ```
676 |
677 |
678 |
679 | 很简单有木有,所以也没啥可说的了。
680 |
681 |
682 |
683 | ## UI 组件
684 |
685 | 这里以表格组件作为示例,我们将直接来看测试用例是怎么写。一般来说 UI 组件我们主要测试以下几个方面:
686 |
687 | - 是否渲染了正确的 DOM 结构
688 | - 样式是否正确
689 | - 业务逻辑触发是否正确
690 |
691 |
692 |
693 | 下面是测试用例代码:
694 |
695 | ```react
696 | import React from 'react';
697 | import { mount } from 'enzyme';
698 | import sinon from 'sinon';
699 | import { Table } from 'antd';
700 | import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
701 | import BizTable from '@/components/BizTable';
702 |
703 | /* 测试 UI 组件 BizTable */
704 | describe('BizTable component', () => {
705 |
706 | const defaultProps = {
707 | loading: false,
708 | pagination: Object.assign({}, {
709 | current: 1,
710 | pageSize: 15,
711 | total: 2
712 | }, defaultSettingsUtil.pagination),
713 | data: [{id: 1}, {id: 2}],
714 | getData: sinon.fake(),
715 | updateParams: sinon.fake()
716 | };
717 | let defaultWrapper;
718 |
719 | beforeEach(() => {
720 | defaultWrapper = mount();
721 | });
722 |
723 | // ...
724 |
725 | /* 测试是否渲染了正确的功能子组件 */
726 | test('should render table and pagination', () => {
727 | /* 是否渲染了 Table 组件 */
728 | expect(defaultWrapper.find(Table).exists()).toBe(true);
729 | /* 是否渲染了 分页器 组件,样式是否正确(mini) */
730 | expect(defaultWrapper.find('.ant-table-pagination.mini').exists()).toBe(true);
731 | });
732 |
733 | /* 测试首次加载时数据列表为空是否发起加载数据请求 */
734 | test('when componentDidMount and data is empty, should getData', () => {
735 | sinon.spy(BizTable.prototype, 'componentDidMount');
736 | const props = Object.assign({}, defaultProps, {
737 | pagination: Object.assign({}, {
738 | current: 1,
739 | pageSize: 15,
740 | total: 0
741 | }, defaultSettingsUtil.pagination),
742 | data: []
743 | });
744 | const wrapper = mount();
745 |
746 | expect(BizTable.prototype.componentDidMount.calledOnce).toBe(true);
747 | expect(props.getData.calledOnce).toBe(true);
748 | BizTable.prototype.componentDidMount.restore();
749 | });
750 |
751 | /* 测试 table 翻页后是否正确触发 updateParams */
752 | test('when change pagination of table, should updateParams', () => {
753 | const table = defaultWrapper.find(Table);
754 | table.props().onChange({current: 2, pageSize: 25});
755 | expect(defaultProps.updateParams.lastCall.args[0])
756 | .toEqual({paging: {current: 2, pageSize: 25}});
757 | });
758 | });
759 | ```
760 |
761 |
762 |
763 | 得益于设计分层的合理性,我们很容易利用构造 `props` 来达到测试目的,结合 `enzyme` 和 `sinon` ,测试用例依然保持简单的节奏。
764 |
765 |
766 |
767 | ## 总结
768 |
769 | 以上就是这个场景完整的测试用例编写思路和示例代码,文中提及的思路方法也完全可以用在 `Vue` 、`Angular` 项目上。完整的代码内容在 [这里](https://github.com/deepfunc/react-test-demo) (重要的事情多说几遍,各位童鞋觉得好帮忙去给个 :star: 哈)。
770 |
771 | 最后我们可以利用覆盖率来看下用例的覆盖程度是否足够(一般来说不用刻意追求 100%,根据实际情况来定):
772 |
773 | 
774 |
775 |
776 |
777 | 单元测试是 TDD 测试驱动开发的基础。从以上整个过程可以看出,好的设计分层是很容易编写测试用例的,单元测试不单单只是为了保证代码质量:他会逼着你思考代码设计的合理性,拒绝面条代码 :muscle:
778 |
779 |
780 |
781 | 借用 Clean Code 的结束语:
782 |
783 | > 2005 年,在参加于丹佛举行的敏捷大会时,Elisabeth Hedrickson 递给我一条类似 Lance Armstrong 热销的那种绿色腕带。这条腕带上面写着“沉迷测试”(Test Obsessed)的字样。我高兴地戴上,并自豪地一直系着。自从 1999 年从 Kent Beck 那儿学到 TDD 以来,我的确迷上了测试驱动开发。
784 | >
785 | > 不过跟着就发生了些奇事。我发现自己无法取下腕带。不仅是因为腕带很紧,而且那也是条精神上的紧箍咒。那腕带就是我职业道德的宣告,也是我承诺尽己所能写出最好代码的提示。取下它,仿佛就是违背了这些宣告和承诺似的。
786 | >
787 | >
788 | >
789 | > 所以它还在我的手腕上。在写代码时,我用余光瞟见它。它一直提醒我,我做了写出整洁代码的承诺。
790 |
791 |
--------------------------------------------------------------------------------