《深入浅出React和Redux》(4) - 服务器通信、单元测试
与服务器通信
与服务器通信的时长不可控,需要采用异步的形式,可以使用js的fetch函数来调用api。
fetch函数
fetch函数的基本使用形式为:
fetch(apiUrl).then((response) => {
if (response.status !== 200) {
throw new Error('Fail to get response with status ' + response.status);
}
response.json().then((responseJson) => {
this.setState(...);
}).catch((error) => {
this.setState(...);
});
}).catch((error) => {
this.setState(...);
});
以纯react(没有引入redux)的代码为例,fetch函数执行时会立即返回一个Promise类型的对象,所以要接then和catch,只要服务器返回的是合法的HTTP响应(包括500、400),都会触发then调用,所以在then回调函数中还需要判断status是否为200。
此外,即使在response.status为200时,也不能直接读取response中的内容,因为fetch在接收到HTTP响应的报头部分就会调用then,而不是等到整个HTTP响应完成。所以为了获取到body,还需要继续调用json()并针对其返回的Promise提供回调函数。
在终于成功获取到服务器返回的内容后,通过触发状态的变化引发页面的重新渲染。
redux-thunk中间件
redux的单向数据流是同步操作,如何实现调用服务器这样的异步操作呢?可以使用redux-thunk中间件。
npm install --save redux-thunk
在Redux架构下,一个action对象在通过store.dispatch派发,在调用reducer函数之前,会先经过一个中间件的环节,这就是产生异步操作的机会。
要产生异步操作要发送异步action对象,与普通的action对象不同,它并没有type字段,而且它是一个函数。而redux-thunk的工作是检查action对象是不是函数,如果不是函数就放行,完成普通action对象的生命周期,而如果发现action对象是函数,那就执行这个函数,并把Store的dispatch函数和getState函数作为参数传递到函数中去,处理过程到此为止,不会让这个异步action对象继续往前派发到reducer函数。
createStore时,将redux-thunk中间件作为storeEnhancer之一传入:
const middlewares = [thunkMiddleware];
const storeEnhancers = compose(
applyMiddleware(...middlewares),
(win && win.devToolsExtension) ? win.devToolsExtension() : (f) => f,
);
export default createStore(reducer, {}, storeEnhancers);
异步操作有固定的模式,首先定义三种action类型,分别表示异步操作开始、成功、失败:
export const FETCH_STARTED = 'WEATHER/FENTCH_STARTED';
export const FETCH_SUCCESS = 'WEATHER/FENTCH_SUCCESS';
export const FETCH_FAILURE = 'WEATHER/FENTCH_FAILURE';
然后定义生成异步action的函数,这个函数会被redux-thunk截获并调用,调用时传递的参数是dispatch函数和getState函数:
export const sampleAsyncAction = ()=>{
return (dispatch, getState) => {
// 在这里dispatch FETCH_STARTED action
return fetch(apiUrl).then((response) => {
if (response.status !== 200) {
throw new Error('Fail to get response with status ' + response.status);
}
response.json().then((responseJson) => {
// 在这里dispatch FETCH_SUCCESS action
}).catch((error) => {
});
}).catch((error) => {
// 在这里dispatch FETCH_FAILURE action
});
}
}
终止异步操作
在页面与服务端交互过程中,往往会有一次请求还没结束就发出下一次请求的情况,比如在选择一个下拉项后调用API加载数据,如果数据还没加载完成,就切换到别的下拉项,会发生什么,这取决于两次请求的返回顺序,如果第一次请求先返回,那么页面会先显示第一次的响应结果,再刷新为第二次的,整体来说问题不大;但是如果第二次的请求先于第一次的返回,那么页面显示的最终结果就与下拉项不匹配了。
对于这种场景,简单点可以通过加载数据时禁用下拉框来解决,但这种做法的用户体验较差,如果服务端一直没有响应,下拉框就一直处于禁用状态;还有更合理的一种做法时,在发出下一次API请求时,终止上一次的API请求。
可惜ES6的标准中,Promise对象是无法中断的,为此可以通过应用层的修改来丢弃上一次的请求。以fetchWeather异步action举例:
export const fetchWeather = (cityCode) => {
return (dispatch) => {
const seqId=++nextSeqId;
const dispatchIfValid=(action)=>{
if(seqId===nextSeqId){
return dispatch(action);
}
}
dispatchIfValid(fetchWeatherStarted());
const apiUrl = `/data/cityinfo/${cityCode}.html`;
// dispatch(fetchWeatherStarted());
return fetch(apiUrl).then((response) => {
if (response.status !== 200) {
throw new Error('Fail to get response with status ' + response)
}
response.json().then((response) => {
dispatchIfValid(fetchWeatherSuccess(response.weatherinfo));
}).catch((error) => {
throw new Error('Invalid js on response: ' + error);
});
}).catch((error) => {
dispatchIfValid(fetchWeatherFailure(error));
});
}
};
在action构造函数文件中定义一个文件模块级的nextSeqId变量,这是一个递增的整数数字,给每一个访问API的请求做序列编号。在fetchWeather返回的函数中,fetch开始一个异步请求之前,先给nextSeqId自增加一,然后自增的结果赋值给一个局部变量seqId,这个seqId的值就是这一次异步请求的编号,如果随后还有fetchWeather构造器被调用,那么nextSeqId也会自增,新的异步请求会分配为新的seqId。
然后,action构造函数中所有的dispatch函数都被替换为一个新定义的函数dispatchIfValid,这个dispatchIfValid函数会检查当前环境的seqId是否等同于全局的nextSeqId。如果相同,说明fetchWeather没有被再次调用,就继续使用dispatch函数。如果不相同,说明这期间有新的fetchWeather被调用,也就是有新的访问服务器的请求被发出去了,这时候当前seqId代表的请求就已经过时了,直接丢弃掉,不需要dispatch任何action。
单元测试
关于单元测试框架的选择,由于在create-react-app创建的应用中已经自带了Jest库,所以就直接使用Jest。
Jest会自动在当前目录下寻找文件名以.test.js为后缀的文件和存放在__test__目录下的代码文件,来执行单元测试。
单元测试代码的组织方式,通常有两种模式:
- 把全部测试代码放在与src平行的test目录,在test目录下建立和src对应子目录结构,每个单元测试文件都加上test.js后缀,这种方法可以保持src目录的整洁,但缺点是单元测试中引用功能代码的路径会比较长;
- 在src的子目录下创建__test__目录,用于存放对应这个目录的单元测试,这种方法的优缺点与第一种相反。
React & Redux 应用的测试对象主要有action构造函数、reducer、view,其中reducer、普通的action构造函数都是纯函数,非常便于测试。
但异步action的构造函数和view的测试相对比较复杂。
异步action的构造函数的测试
一个异步action对象就是一个函数,需要结合redux-thunk之类的中间件才能发挥作用,异步action被派发之后,会连续派发另外两个action对象代表fetch开始和fetch结束,单元测试要做的就是验证这样的行为。
中间件的应用和action的dispatch都涉及到Redux Store,但单元测试中并不需要创建一个完整功能的Store,也不应该进行真实的网络访问。所以需要一些测试辅助工具。
其中可以使用redux-mock-store来创建一个mock store:
npm install -save-dev redux-mock-store
使用sinon来“篡改”fetch函数的行为,使其不会发出真实的网络请求:
npm install -save-dev sinon
然后就可以开始测试了,首先需要做一些准备工作:
Create Mock Store
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
const middlewares = [thunk];
const createMockStore = configureStore(middlewares);
...
const store = createMockStore();
Create Mock Store后,就可以像真实store一样在其上dispatch action了。
“篡改”fetch函数的行为
import { stub } from 'sinon';
...
let stubbedFetch;
beforeEach(() => {
stubbedFetch = stub(global, 'fetch');
});
afterEach(() => {
stubbedFetch.restore();
});
const mockResponse = Promise.resolve({
status: 200,
json: () => Promise.resolve({
weatherinfo: {}
})
});
stubbedFetch.returns(mockResponse);
使用sinon的stub函数来覆盖fetch的返回结果,单元测试用例之间应该互不影响,所以stubbedFetch应该在beforeEach中执行,并在测试用例跑完时执行afterEach时恢复stub行为。
React组件的测试
测试React组件,测试的是渲染结果、事件处理。
但并不是所有的测试过程都需要把React组件的DOM树都渲染出来,尤其对于包含复杂子组件的React组件,如果深入渲染整个DOM树,那就要渲染所有子组件,可是子组件可能会有其他依赖关系,比如依赖于某个React Context值,为了渲染这样的子组件需要耗费很多精力准备测试环境,这种情况下针对目标组件的测试只要让它渲染顶层组件就好了,不需要测试子组件。
测试React组件可以借助Enzyme,它由AirBnb开源,enzyme依赖react-addons-test-utils,要一起安装:
npm install -save-dev enzyme react-addons-test-utils
Enzyme支持三种渲染方法:
- shallow,只渲染顶层React组件,不渲染子组件,适合只测试React组件的渲染行为;
- mount,渲染完整的React组件包括子组件,借助模拟的浏览器环境完成事件处理功能;
- render,渲染完整的React组件,但是只产生HTML,并不进行事件处理。
无状态React组件的测试,可以使用shallow方法只渲染一层,忽略子组件是为了简化测试过程。举例:
const wrapper = shallow(<Filter />);
expect(wrapper.contains(<Link filter={FilterTypes.ALL}> {FilterTypes.ALL} </Link>)).toBe(true);
被连接的React组件的测试,被连接的React组件是指状态保存在Redux的Store上,并通过connect函数产生的组件,这种组件使用时需要包裹在Provider中,测试的时候也一样,而且还会测试事件处理、action dispatch后引发视图的变化,所以这里需要使用真实的store。
import { Provider } from 'react-redux';
...
const subject = (
<Provider store={store}>
<待测组件 />
</Provider>);
参考书籍
《深入浅出React和Redux》 程墨