├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── __test__ ├── component │ ├── AddTodoView.spec.js │ ├── Todo.spec.js │ └── TodoList.spec.js └── reducers │ └── index.spec.js ├── package.json ├── public ├── favicon.ico └── index.html ├── screenshot ├── case1-test-result.png ├── case12-test-result.png ├── coverage-report.png └── todo.png ├── src ├── App.css ├── actions │ └── index.js ├── component │ ├── AddTodoView.js │ ├── App.js │ ├── Footer.js │ ├── Todo.js │ └── TodoList.js ├── constants │ ├── actionTypes.js │ └── filterTypes.js ├── containers │ ├── AddTodo.js │ ├── FilterLink.js │ └── VisibleTodoList.js ├── index.css ├── index.html ├── index.js ├── logo.svg ├── reducers │ └── index.js └── routes │ └── index.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "es2015", 5 | { 6 | "module": false 7 | } 8 | ], 9 | "react", 10 | "stage-2" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [package.json] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "env": { 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "plugins": [ 8 | "react", 9 | "babel" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 6, 13 | "sourceType": "module", 14 | "ecmaFeatures": { 15 | "jsx": true, 16 | "experimentalObjectRestSpread":true 17 | } 18 | }, 19 | "parser": "babel-eslint", 20 | "rules": { 21 | "camelcase": 2, 22 | "curly": 2, 23 | "eqeqeq": 2, 24 | "brace-style": [2, "1tbs"], 25 | "quotes": [2, "single"], 26 | "space-infix-ops": 2, 27 | "no-param-reassign": 0, 28 | "prefer-spread": 2 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Custom Cache 40 | .idea 41 | /assets/ 42 | /cache/ 43 | /lib/ 44 | .DS_Store 45 | /npm-debug.log.* 46 | 47 | .vscode/ 48 | 49 | # others 50 | question.md 51 | resources/ 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 基于 Jest + Enzyme 的 React 单元测试 2 | * Jest 和 Enzyme 的基本介绍 3 | * 测试环境搭建 4 | * 测试脚本编写 5 | * UI 组件测试 6 | * Reducer 测试 7 | * 运行并调试 8 | * 参考资料 9 | 10 | ## Jest、Enzyme 介绍 11 | Jest 是 Facebook 发布的一个开源的、基于 `Jasmine` 框架的 JavaScript 单元测试工具。提供了包括内置的测试环境 DOM API 支持、断言库、Mock 库等,还包含了 Spapshot Testing、 Instant Feedback 等特性。 12 | 13 | Airbnb开源的 React 测试类库 Enzyme 提供了一套简洁强大的 API,并通过 jQuery 风格的方式进行DOM 处理,开发体验十分友好。不仅在开源社区有超高人气,同时也获得了React 官方的推荐。 14 | 15 | ## 测试环境搭建 16 | 在开发 React 应用的基础上(默认你用的是 Webpack + Babel 来打包构建应用),你需要安装 `Jest` `Enzyme`,以及对应的 `babel-jest` 17 | ``` 18 | npm install jest enzyme babel-jest --save-dev 19 | ``` 20 | 下载 npm 依赖包之后,你需要在 `package.json` 中新增属性,配置 Jest: 21 | 22 | ```json 23 | "jest": { 24 | "moduleFileExtensions": [ 25 | "js", 26 | "jsx" 27 | ], 28 | "moduleNameMapper": { 29 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 30 | ".*\\.(css|less|scss)$": "/__mocks__/styleMock.js" 31 | }, 32 | "transform": { 33 | "^.+\\.js$": "babel-jest" 34 | } 35 | }, 36 | ``` 37 | 并新增`test scripts` 38 | ``` 39 | "scripts": { 40 | "dev": "NODE_ENV=development webpack-dev-server --inline --progress --colors --port 3000 --host 0.0.0.0 ", 41 | "test": "jest" 42 | } 43 | ``` 44 | 其中 : 45 | * `moduleFileExtensions`:代表支持加载的文件名,与 Webpack 中的 `resolve.extensions` 类似 46 | * `moduleNameMapper`:代表需要被 Mock 的资源名称。如果需要 Mock 静态资源(如less、scss等),则需要配置 Mock 的路径 `/__mocks__/yourMock.js` 47 | * `transform` 用于编译 ES6/ES7 语法,需配合 `babel-jest` 使用 48 | 49 | 上面三个是常用的配置,更多 Jest 配置见官方文档:[Jest Configuration](https://facebook.github.io/jest/docs/configuration.html) 50 | 51 | ## 测试脚本编写 52 | ### UI 组件测试 53 | 环境搭建好了,就可以开始动手写测试脚本了。在开始之前,先分析下 Todo 应用的组成部分。 54 | 55 | ![](https://github.com/superman66/react-test-demo/blob/master/screenshot/todo.png) 56 | 57 | 应用主体结构如下 `src/component/App.js`: 58 | ```javascript 59 | class App extends Component { 60 | render() { 61 | const { params } = this.props; 62 | return ( 63 |
64 |
65 | 66 | 67 |
68 |
69 |
70 | ) 71 | } 72 | } 73 | ``` 74 | 75 | 可以发现 整个应用可以分为三个组件: 76 | * 最外层的 `` 77 | * 中间的 Input 输入框 `` 78 | * 下面的 TODO 列表 `` 79 | 80 | 其中 `` 是 UI 组件,`` 和 `` 是智能组件,我们需要找到智能组件所对应的 UI 组件 `` 和 ``。 81 | 82 | 83 | `` 就是一个 `Input` 输入框,接受文字输入,敲下回车键,创建一个 Todo。代码如下 `src/component/AddTodoView.js`: 84 | 85 | ```javascript 86 | import React, { Component, PropTypes } from 'react' 87 | class AddTodoView extends Component { 88 | render() { 89 | return ( 90 |
91 |

todos

92 | this.handleClick(e)} 96 | placeholder="input todo item" 97 | ref='input' /> 98 |
99 | ) 100 | } 101 | 102 | handleClick(e) { 103 | if (e.keyCode === 13) { 104 | const node = this.refs.input; 105 | const text = node.value.trim(); 106 | text && this.props.onAddClick(text); 107 | node.value = ''; 108 | } 109 | } 110 | } 111 | ``` 112 | 了解了该组件的功能之后,我们首先需要明确该组件需要测试哪些点: 113 | * 组件是否正常渲染 114 | * 当用户输入内容敲下回车键时,是否能正常的调用 `props` 传递的 `onAddClick(text)` 方法 115 | * 创建完成后清除 Input 的值 116 | * 当用户没有输入任何值时,敲下回车时,应该不调用 `props` 传递的 `onAddClick(text)` 方法 117 | 118 | 经过上面的分析之后,我们就可以开始编写单元测试脚本了。 119 | 120 | #### 第一步:引入相关 lib 121 | 122 | ```javascript 123 | import React from 'react' 124 | import App from '../../src/component/App' 125 | import { shallow } from 'enzyme' 126 | ``` 127 | 在这里我们引入了 `shallow` 方法,它是 `Enzyme` 提供的 API 之一,可以实现**浅渲染**。其作用是仅仅渲染至虚拟节点,不会返回真实的节点,能极大提高测试性能。但是它不适合测试包含子组件、需要测试声明周期的组件。 128 | `Enzyme` 还提供了其他两个 API: 129 | * `mount`:Full Rendering,非常适用于存在于 DOM API 存在交互组件,或者需要测试组件完整的声明周期 130 | * `render`:Static Rendering,用于 将 React 组件渲染成静态的 HTML 并分析生成的 HTML 结构。`render` 返回的 `wrapper` 与其他两个 API 类似。不同的是 `render` 使用了第三方 HTML 解析器和 `Cheerio`。 131 | 132 | 一般情况下,`shallow` 就已经足够用了,偶尔情况下会用到 `mount`。 133 | 134 | #### 第二步:模拟 Props,渲染组件创建 Wrapper 135 | 这一步,我们可以创建一个 `setup` 函数来实现。 136 | 137 | ```javascript 138 | const setup = () => { 139 | // 模拟 props 140 | const props = { 141 | // Jest 提供的mock 函数 142 | onAddClick: jest.fn() 143 | } 144 | 145 | // 通过 enzyme 提供的 shallow(浅渲染) 创建组件 146 | const wrapper = shallow() 147 | return { 148 | props, 149 | wrapper 150 | } 151 | } 152 | ``` 153 | `Props` 中包含函数的时候,我们需要使用 Jest 提供的 [mockFunction](https://facebook.github.io/jest/docs/mock-function-api.html#content) 154 | 155 | #### 第四步:编写 Test Case 156 | 这里的 Case 根据我们前面分析需要测试的点编写。 157 | 158 | **Case1:测试组件是否正常渲染** 159 | 160 | 161 | ```javascript 162 | describe('AddTodoView', () => { 163 | const { wrapper, props } = setup(); 164 | 165 | // case1 166 | // 通过查找存在 Input,测试组件正常渲染 167 | it('AddTodoView Component should be render', () => { 168 | //.find(selector) 是 Enzyme shallow Rendering 提供的语法, 用于查找节点 169 | // 详细用法见 Enzyme 文档 http://airbnb.io/enzyme/docs/api/shallow.html 170 | expect(wrapper.find('input').exists()); 171 | }) 172 | }) 173 | ``` 174 | 写完第一个测试用例之后,我们可以运行看看测试的效果。在 Terminal 中输入 `npm run test`,效果如下: 175 | 176 | ![](https://github.com/superman66/react-test-demo/blob/master/screenshot/case1-test-result.png) 177 | 178 | **Case2: 输入内容并敲下回车键,测试组件调用props的方法** 179 | 180 | 181 | ```javascript 182 | it('When the Enter key was pressed, onAddClick() shoule be called', () => { 183 | // mock input 输入和 Enter事件 184 | const mockEvent = { 185 | keyCode: 13, // enter 事件 186 | target: { 187 | value: 'Test' 188 | } 189 | } 190 | // 通过 Enzyme 提供的 simulate api 模拟 DOM 事件 191 | wrapper.find('input').simulate('keyup',mockEvent) 192 | // 判断 props.onAddClick 是否被调用 193 | expect(props.onAddClick).toBeCalled() 194 | }) 195 | ``` 196 | 上面的代码与第一个 case 多了两点: 197 | * 增加了 `mockEvent`,用于模拟 DOM 事件 198 | * 使用 `Enzyme` 提供的 `.simulate(’keyup‘, mockEvent)` 来模拟点击事件,这里的 `keyup` 会自动转换成 React 组件中的 `onKeyUp` 并调用。 199 | 200 | 我们再运行 `npm run test` 看看测试效果: 201 | 202 | ![](https://github.com/superman66/react-test-demo/blob/master/screenshot/case12-test-result.png) 203 | 204 | 经过上面两个 Test Case 的分析,接下来的 Case3 和 Case4 思路也是一样,具体写法见代码: [__test__/component/AddTodoView.spec.js](https://github.com/superman66/react-test-demo/blob/master/__test__/component/AddTodoView.spec.js),这里就不一一讲解了。 205 | 206 | ### Reducer 测试 207 | 由于 Reducer 是纯函数,因此对 Reducer 的测试非常简单,Redux 官方文档也提供了测试的例子,代码如下: 208 | ```javascript 209 | import reducer from '../../reducers/todos' 210 | import * as types from '../../constants/ActionTypes' 211 | 212 | describe('todos reducer', () => { 213 | it('should return the initial state', () => { 214 | expect( 215 | reducer(undefined, {}) 216 | ).toEqual([ 217 | { 218 | text: 'Use Redux', 219 | completed: false, 220 | id: 0 221 | } 222 | ]) 223 | }) 224 | 225 | it('should handle ADD_TODO', () => { 226 | expect( 227 | reducer([], { 228 | type: types.ADD_TODO, 229 | text: 'Run the tests' 230 | }) 231 | ).toEqual( 232 | [ 233 | { 234 | text: 'Run the tests', 235 | completed: false, 236 | id: 0 237 | } 238 | ] 239 | ) 240 | 241 | expect( 242 | reducer( 243 | [ 244 | { 245 | text: 'Use Redux', 246 | completed: false, 247 | id: 0 248 | } 249 | ], 250 | { 251 | type: types.ADD_TODO, 252 | text: 'Run the tests' 253 | } 254 | ) 255 | ).toEqual( 256 | [ 257 | { 258 | text: 'Run the tests', 259 | completed: false, 260 | id: 1 261 | }, 262 | { 263 | text: 'Use Redux', 264 | completed: false, 265 | id: 0 266 | } 267 | ] 268 | ) 269 | }) 270 | }) 271 | 272 | ``` 273 | 更多关于 Redux 的测试可以看官网提供的例子:[编写测试-Redux文档](http://cn.redux.js.org/docs/recipes/WritingTests.html) 274 | 275 | ## 调试及测试覆盖率报告 276 | 在运行测试脚本过程,`Jest` 的错误提示信息友好,通过错误信息一般都能找到问题的所在。 277 | 同时 `Jest` 还提供了生成测试覆盖率报告的命令,只需要添加上 `--coverage` 这个参数既可生成。不仅会在终端中显示: 278 | 279 | ![](https://github.com/superman66/react-test-demo/blob/master/screenshot/coverage-report.png) 280 | 281 | 而且还会在项目中生成 `coverage` 文件夹,非常方便。 282 | # 资料 283 | * [聊一聊前端自动化测试](https://github.com/tmallfe/tmallfe.github.io/issues/37) 284 | * [Enzyme API](http://airbnb.io/enzyme/docs/api/index.html) 285 | * [Jest](https://facebook.github.io/jest/) 286 | 287 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/__mocks__/fileMock.js -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/__mocks__/styleMock.js -------------------------------------------------------------------------------- /__test__/component/AddTodoView.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AddTodoView from '../../src/component/AddTodoView' 3 | import { shallow } from 'enzyme' 4 | 5 | const setup = () => { 6 | // 模拟 props 7 | const props = { 8 | // Jest 提供的mock 函数 9 | onAddClick: jest.fn( (e) => { 10 | }) 11 | } 12 | 13 | // 通过 enzyme 提供的 shallow(浅渲染) 创建组件 14 | const wrapper = shallow() 15 | return { 16 | props, 17 | wrapper 18 | } 19 | } 20 | 21 | describe('AddTodoView', () => { 22 | const { wrapper, props } = setup(); 23 | 24 | // case1 25 | // 通过查找是否存在 Input,测试组件正常渲染 26 | it('AddTodoView Component should render', () => { 27 | //.find(selector) 是 Enzyme shallow Rendering 提供的语法, 用于查找节点 28 | // 详细用法见 Enzyme 文档 http://airbnb.io/enzyme/docs/api/shallow.html 29 | expect(wrapper.find('input').exists()); 30 | }) 31 | 32 | // case2 33 | // 输入内容并敲下回车键,测试组件调用props的方法 34 | /* it('When the Enter key was pressed, onAddClick() shoule be called', () => { 35 | // mock input 输入和 Enter事件 36 | const mockEvent = { 37 | keyCode: 13, // enter 事件 38 | target: { 39 | value: 'Test' 40 | } 41 | } 42 | // 通过 Enzyme 提供的 simulate api 模拟 DOM 事件 43 | wrapper.find('input').simulate('keyup',mockEvent) 44 | // 判断 props.onAddClick 是否被调用 45 | expect(props.onAddClick).toBeCalled() 46 | })*/ 47 | 48 | // case3 49 | // 没有输入内容并敲下回车键,测试组件没有调用props的方法 50 | it('When the Enter key was pressed without text, onAddClick() shoule not be called', () => { 51 | // mock input 输入和 Enter事件 52 | const mockEvent = { 53 | keyCode: 13, // enter 事件 54 | target: { 55 | value: undefined 56 | } 57 | } 58 | // 通过 Enzyme 提供的 simulate api 模拟 DOM 事件 59 | wrapper.find('input').simulate('keyup',mockEvent) 60 | // 判断 props.onAddClick 是否被调用 61 | expect(props.onAddClick).not.toBeCalled() 62 | }) 63 | 64 | // case4 65 | // 创建完成后,input框被晴空 66 | it('input value should be empty when todo is created', () => { 67 | const mockEvent = { 68 | keyCode: 13, // enter 事件 69 | target: { 70 | value: 'Test' 71 | } 72 | } 73 | // 通过 Enzyme 提供的 simulate api 模拟 DOM 事件 74 | wrapper.find('input').simulate('keyup',mockEvent) 75 | expect(mockEvent.target.value === '') 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /__test__/component/Todo.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Todo from '../../src/component/Todo.js' 3 | import { shallow } from 'enzyme' 4 | 5 | const setup = () => { 6 | const props = { 7 | text: 'todo-1', 8 | completed: false, 9 | onClick: jest.fn(), 10 | onRemoveTodoClick: jest.fn() 11 | } 12 | const wrapper = shallow() 13 | return { 14 | props, 15 | wrapper 16 | } 17 | } 18 | 19 | describe('Todo', () => { 20 | const { props, wrapper } = setup() 21 | 22 | // 通过 input 是否存在来判断 Todo组件是否被渲染 23 | it('Todo item should render', () => { 24 | expect(wrapper.find('input').length).toBe(1) 25 | }) 26 | 27 | // 当点击 单选按钮,onClick 方法应该被调用 28 | it('click checkbox input, onClick called', () => { 29 | const mockEvent = { 30 | key: 'Click', 31 | } 32 | wrapper.find('input').simulate('click', mockEvent) 33 | expect(props.onClick).toBeCalled() 34 | }) 35 | 36 | it('the item should remove when click remove button', () => { 37 | const mockEvent = { 38 | key: 'Click', 39 | } 40 | wrapper.find('button').simulate('click', mockEvent) 41 | expect(props.onRemoveTodoClick).toBeCalled() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /__test__/component/TodoList.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TodoList from '../../src/component/TodoList' 3 | import { render } from 'enzyme' 4 | 5 | const setup = () => { 6 | const props = { 7 | todos: [ 8 | { 9 | text: 'todo-1', 10 | completed: false 11 | }, 12 | { 13 | text: 'todo-2', 14 | completed: false 15 | }, 16 | { 17 | text: 'todo-3', 18 | completed: false 19 | } 20 | ], 21 | onTodoClick: jest.fn(), 22 | onRemoveTodoClick: jest.fn() 23 | } 24 | const wrapper = render() 25 | return { 26 | props, 27 | wrapper 28 | } 29 | } 30 | 31 | describe('TodoList', () => { 32 | const { props, wrapper } = setup() 33 | it('TodoList length should be 3', () => { 34 | expect(wrapper.find('li').length).toBe(3) 35 | }) 36 | }) 37 | 38 | -------------------------------------------------------------------------------- /__test__/reducers/index.spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../src/actions/index' 2 | import * as types from '../../src/constants/actionTypes' 3 | 4 | describe('actions', () => { 5 | it('should create an action to add a todo', () => { 6 | const text = 'Finish docs' 7 | const expectedAction = { 8 | type: types.ADD_TODO, 9 | text 10 | } 11 | expect(actions.addTodo(text)).toEqual(expectedAction) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-test-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "jest": { 6 | "moduleFileExtensions": [ 7 | "js", 8 | "jsx" 9 | ], 10 | "moduleNameMapper": { 11 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 12 | ".*\\.(css|less|scss)$": "/__mocks__/styleMock.js" 13 | }, 14 | "transform": { 15 | "^.+\\.js$": "babel-jest" 16 | } 17 | }, 18 | "dependencies": { 19 | "react": "^15.4.2", 20 | "react-dom": "^15.4.2", 21 | "react-redux": "^5.0.3", 22 | "react-router": "^3.0.2", 23 | "redux": "^3.6.0", 24 | "todomvc-app-css": "^2.1.0" 25 | }, 26 | "devDependencies": { 27 | "babel-cli": "^6.16.0", 28 | "babel-core": "^6.17.0", 29 | "babel-eslint": "^6.1.2", 30 | "babel-jest": "^19.0.0", 31 | "babel-loader": "^6.2.5", 32 | "babel-preset-es2015": "^6.6.0", 33 | "babel-preset-react": "^6.3.13", 34 | "babel-preset-stage-2": "^6.24.1", 35 | "babel-standalone": "^6.7.7", 36 | "css-loader": "^0.23.1", 37 | "enzyme": "^2.7.1", 38 | "eslint": "^2.8.0", 39 | "eslint-plugin-babel": "^3.2.0", 40 | "eslint-plugin-react": "^5.0.1", 41 | "extract-text-webpack-plugin": "^2.0.0", 42 | "html-webpack-plugin": "^2.24.0", 43 | "jest": "^19.0.2", 44 | "react-test-renderer": "^16.2.0", 45 | "style-loader": "^0.16.1", 46 | "webpack": "^2.2.0", 47 | "webpack-dev-server": "2.2.0" 48 | }, 49 | "scripts": { 50 | "dev": "NODE_ENV=development webpack-dev-server --inline --progress --colors --port 3000 --host 0.0.0.0 --devtool source-map", 51 | "test": "jest --colors", 52 | "coverage": "jest --colors --coverage" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /screenshot/case1-test-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/screenshot/case1-test-result.png -------------------------------------------------------------------------------- /screenshot/case12-test-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/screenshot/case12-test-result.png -------------------------------------------------------------------------------- /screenshot/coverage-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/screenshot/coverage-report.png -------------------------------------------------------------------------------- /screenshot/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superman66/react-test-demo/4489fed787957a0cd5f6e508f8d1feef65aa6a59/screenshot/todo.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-intro { 18 | font-size: large; 19 | } 20 | 21 | @keyframes App-logo-spin { 22 | from { transform: rotate(0deg); } 23 | to { transform: rotate(360deg); } 24 | } 25 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | import {ADD_TODO, TOGGLE_TODO, REMOVE_TODO, SET_VISIBILITY_FILTER} from '../constants/actionTypes' 5 | import {VisibilityFilters} from '../constants/filterTypes' 6 | 7 | export function addTodo(text) { 8 | return { 9 | type: ADD_TODO, 10 | text 11 | } 12 | } 13 | 14 | export function toggleTodo(id) { 15 | return { 16 | type: TOGGLE_TODO, 17 | id 18 | } 19 | } 20 | 21 | export function removeTodo(id){ 22 | return { 23 | type: REMOVE_TODO, 24 | id 25 | } 26 | } 27 | 28 | export function setVisibilityFilter(filter) { 29 | return { 30 | type: SET_VISIBILITY_FILTER, 31 | filter 32 | } 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/component/AddTodoView.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import React, { Component, PropTypes } from 'react' 6 | 7 | class AddTodoView extends Component { 8 | 9 | render() { 10 | return ( 11 |
12 |

todos

13 | 19 |
20 | ) 21 | } 22 | 23 | handleClick = (e) => { 24 | if (e.keyCode === 13) { 25 | const node = e.target; 26 | const text = node.value && node.value.trim(); 27 | text && this.props.onAddClick(text); 28 | node.value = ''; 29 | } 30 | } 31 | } 32 | 33 | AddTodoView.propTypes = { 34 | onAddClick: PropTypes.func.isRequired 35 | }; 36 | 37 | export default AddTodoView 38 | -------------------------------------------------------------------------------- /src/component/App.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import React, { Component, PropTypes } from 'react' 6 | import AddTodo from '../containers/AddTodo' 7 | import VisibleTodoList from '../containers/VisibleTodoList' 8 | import Footer from '../component/Footer' 9 | 10 | class App extends Component { 11 | render() { 12 | const { params } = this.props; 13 | return ( 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | 23 | ) 24 | } 25 | } 26 | 27 | export default App; -------------------------------------------------------------------------------- /src/component/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import React, {Component, PropTypes} from 'react' 6 | import {VisibilityFilters} from '../constants/filterTypes' 7 | import FilterLink from '../containers/FilterLink' 8 | 9 | class Footer extends Component { 10 | 11 | render() { 12 | return ( 13 |
14 |
    15 |
  • 16 | All 17 |
  • 18 |
  • 19 | Completed 20 |
  • 21 |
  • 22 | Active 23 |
  • 24 |
25 |
26 | ) 27 | } 28 | } 29 | 30 | export default Footer -------------------------------------------------------------------------------- /src/component/Todo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import React, { PropTypes } from 'react' 6 | 7 | function Todo({ onClick, onRemoveTodoClick, completed, text }) { 8 | 9 | return ( 10 |
  • 13 |
    14 | 15 | 16 | 17 |
    18 |
  • 19 | ) 20 | } 21 | 22 | Todo.propTypes = { 23 | onClick: PropTypes.func.isRequired, 24 | completed: PropTypes.bool.isRequired, 25 | text: PropTypes.string.isRequired 26 | }; 27 | 28 | export default Todo -------------------------------------------------------------------------------- /src/component/TodoList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import React, { Component, PropTypes } from 'react' 6 | import Todo from './Todo' 7 | 8 | class TodoList extends Component { 9 | render() { 10 | const { todos, onTodoClick, onRemoveTodoClick } = this.props; 11 | return ( 12 |
      13 | {todos.map((todo, index) => 14 | onTodoClick(index)} 17 | onRemoveTodoClick = { () => onRemoveTodoClick(index)} 18 | key={index} 19 | /> 20 | )} 21 |
    22 | ) 23 | } 24 | } 25 | TodoList.propTypes = { 26 | onRemoveTodoClick: PropTypes.func.isRequired, 27 | onTodoClick: PropTypes.func.isRequired, 28 | todos: PropTypes.arrayOf(PropTypes.shape({ 29 | completed: PropTypes.bool.isRequired, 30 | text: PropTypes.string.isRequired 31 | }).isRequired).isRequired 32 | }; 33 | 34 | export default TodoList -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | export const ADD_TODO = 'ADD_TODO'; 6 | export const TOGGLE_TODO = 'TOGGLE_TODO'; 7 | export const REMOVE_TODO = 'REMOVE_TODO'; 8 | export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER' 9 | -------------------------------------------------------------------------------- /src/constants/filterTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | export const VisibilityFilters = { 6 | SHOW_ALL: 'SHOW_ALL', 7 | SHOW_COMPLETED: 'SHOW_COMPLETED', 8 | SHOW_ACTIVE: 'SHOW_ACTIVE' 9 | }; -------------------------------------------------------------------------------- /src/containers/AddTodo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/21. 3 | */ 4 | 5 | import {connect} from 'react-redux' 6 | import {addTodo} from '../actions/index' 7 | import AddTodoView from '../component/AddTodoView' 8 | 9 | 10 | function mapState2Props(state) { 11 | return { 12 | 13 | } 14 | } 15 | 16 | function mapDispatch2Props(dispatch) { 17 | return { 18 | onAddClick: (text) => { 19 | dispatch(addTodo(text)) 20 | } 21 | } 22 | } 23 | 24 | export default connect( 25 | mapState2Props, 26 | mapDispatch2Props 27 | )(AddTodoView) -------------------------------------------------------------------------------- /src/containers/FilterLink.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/21. 3 | */ 4 | 5 | import React, {Component} from 'react' 6 | import {Link} from 'react-router' 7 | 8 | class FilterLink extends Component { 9 | 10 | render() { 11 | return ( 12 | 19 | {this.props.children} 20 | 21 | ) 22 | } 23 | } 24 | 25 | export default FilterLink -------------------------------------------------------------------------------- /src/containers/VisibleTodoList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/21. 3 | */ 4 | 5 | import { connect } from 'react-redux' 6 | import TodoList from '../component/TodoList' 7 | import { toggleTodo, removeTodo } from '../actions/index' 8 | import { VisibilityFilters } from '../constants/filterTypes' 9 | 10 | function visibleTodos(todos, filter) { 11 | switch (filter) { 12 | case 'all': 13 | return todos; 14 | case 'active': 15 | return todos.filter(todo => !todo.completed); 16 | case 'completed': 17 | return todos.filter(todo => todo.completed) 18 | default: 19 | return todos 20 | } 21 | } 22 | 23 | function mapState2Props(state, ownProps) { 24 | return { 25 | todos: visibleTodos(state.todos, ownProps.filter) 26 | } 27 | } 28 | 29 | function mapDispatch2Props(dispatch) { 30 | return { 31 | onTodoClick: (id) => { 32 | dispatch(toggleTodo(id)) 33 | }, 34 | onRemoveTodoClick: (id) => { 35 | dispatch(removeTodo(id)) 36 | } 37 | } 38 | } 39 | 40 | export default connect( 41 | mapState2Props, 42 | mapDispatch2Props 43 | )(TodoList) -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Test Demo 5 | 6 | 7 |
    8 |
    9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {createStore} from 'redux' 4 | import {Provider} from 'react-redux' 5 | import todoApp from './reducers/index' 6 | import Router from './routes/index' 7 | import './index.css'; 8 | import 'todomvc-app-css/index.css' 9 | 10 | ReactDOM.render( 11 | Router, 12 | document.getElementById('root') 13 | ) 14 | ; 15 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/20. 3 | */ 4 | 5 | import {combineReducers} from 'redux' 6 | import {ADD_TODO, TOGGLE_TODO, REMOVE_TODO, SET_VISIBILITY_FILTER} from '../constants/actionTypes' 7 | import {VisibilityFilters} from '../constants/filterTypes' 8 | 9 | 10 | // reducer 接收旧的state和action,返回新的state 11 | function todos(state = [], action) { 12 | switch (action.type) { 13 | case ADD_TODO: 14 | return [ 15 | ...state, 16 | { 17 | text: action.text, 18 | completed: false 19 | } 20 | ] 21 | case TOGGLE_TODO: 22 | return state.map((todo, index) => { 23 | if (index === action.id) { 24 | return { 25 | ...todo, 26 | completed: !todo.completed 27 | } 28 | } 29 | return todo; 30 | }) 31 | case REMOVE_TODO: 32 | return state.filter((todo, index) => { 33 | return index !== action.id 34 | }) 35 | default: 36 | return state 37 | } 38 | 39 | } 40 | 41 | 42 | function visibilityFilter(state = 'all', action) { 43 | switch (action.type) { 44 | case SET_VISIBILITY_FILTER: 45 | return action.filter 46 | default: 47 | return state 48 | } 49 | } 50 | 51 | 52 | const todoApp = combineReducers({ 53 | visibilityFilter, 54 | todos 55 | }) 56 | 57 | export default todoApp -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by superman on 2017/3/21. 3 | */ 4 | 5 | import React from 'react' 6 | import {createStore} from 'redux' 7 | import {Provider} from 'react-redux' 8 | import {Router, Route, hashHistory} from 'react-router' 9 | import todoApp from '../reducers/index' 10 | import App from '../component/App' 11 | 12 | let store = createStore(todoApp); 13 | export default ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlwebpackPlugin = require('html-webpack-plugin'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | const { NODE_ENV } = process.env; 6 | 7 | 8 | const extractLess = new ExtractTextPlugin({ 9 | filename: '[name].[contenthash].css', 10 | disable: NODE_ENV === 'development' 11 | }); 12 | 13 | const plugins = [ 14 | new webpack.HotModuleReplacementPlugin(), 15 | new webpack.NamedModulesPlugin(), 16 | new webpack.DefinePlugin({ 17 | 'NODE_ENV': JSON.stringify(NODE_ENV) 18 | }), 19 | extractLess, 20 | new HtmlwebpackPlugin({ 21 | title: 'react-test-demo', 22 | filename: 'index.html', 23 | template: 'src/index.html', 24 | inject: true, 25 | hash: true, 26 | }) 27 | ] 28 | 29 | if (process.env.NODE_ENV === 'production') { 30 | plugins.push(new webpack.optimize.UglifyJsPlugin()); 31 | plugins.push(new webpack.BannerPlugin({ banner: `Last update: ${new Date().toString()}` })); 32 | } 33 | 34 | const common = { 35 | entry: path.resolve(__dirname, 'src/'), 36 | devServer: { 37 | hot: true, 38 | contentBase: path.resolve(__dirname, 'src'), 39 | publicPath: '/build' 40 | }, 41 | output: { 42 | path: path.resolve(__dirname, 'build'), 43 | filename: 'bundle.js', 44 | publicPath: '/build', 45 | }, 46 | plugins, 47 | module: { 48 | rules: [{ 49 | test: /\.jsx?$/, 50 | use: [ 51 | 'babel-loader' 52 | ], 53 | exclude: /node_modules/ 54 | }, 55 | { 56 | test: /\.less$/, 57 | loader: extractLess.extract({ 58 | // use style-loader in development 59 | fallback: 'style-loader', 60 | use: [ 61 | 'css-loader', 62 | 'less-loader' 63 | ], 64 | }) 65 | }, 66 | { 67 | test: /\.css$/, 68 | loader: ExtractTextPlugin.extract({ 69 | // use style-loader in development 70 | fallback: 'style-loader', 71 | use: [ 72 | 'css-loader', 73 | ], 74 | }) 75 | }, 76 | { 77 | test: /\.md$/, 78 | use: [{ 79 | loader: 'html-loader' 80 | }, { 81 | loader: 'markdown-loader', 82 | } 83 | ] 84 | }, { 85 | test: /\.(woff|woff2|eot|ttf|svg)($|\?)/, 86 | use: [{ 87 | loader: 'url-loader?limit=1&hash=sha512&digest=hex&size=16&name=resources/[hash].[ext]' 88 | }] 89 | 90 | }] 91 | } 92 | }; 93 | 94 | module.exports = (env = {}) => { 95 | return Object.assign({}, common, { 96 | entry: [ 97 | path.resolve(__dirname, 'src/index') 98 | ] 99 | }); 100 | }; 101 | --------------------------------------------------------------------------------