Pyon Pyon Today

测试 Redux 应用中使用 window.fetch 的 API 请求

2016-02-16 Web

近来写博客程序的时候需做到请求文章的功能,用的是新的 Fetch API 做的请求,用了好几个工具要么不能用要么尚只支持 XMLHttpRequest (´_`),如果你还不知道 Fetch API,可以通过这篇 Introduction to fetch() 品尝一下 (っ ╹ ◡ ╹ )っ

测试环境是 Karma/Jasmine 的组合,尝试了如下几种工具/方法:

当然,不能因为这点问题就放弃治疗 ~(>_<~),后来翻到 RJ Zaworski 的Testing API requests from window.fetch 这篇文章,直接用 sinon.stub 来吃掉 window.fetch,还是蛮好用的。RJ Zaworski 已经介绍了最小化的测试写法,下面就搭着 Redux 一起上啦。

首先,就着 Redux 文档对异步 Action 的介绍写获取文章的异步 Action Creator:

actions.js

// 引入 Fetch API 的 polyfill,
// 确保在遇到不支持的浏览器上一切正常运行。
// https://github.com/matthew-andrews/isomorphic-fetch/
import 'isomorphic-fetch'
 
export const types = {
    FETCH_POST_REQUEST: 'FETCH_POST_REQUEST',
    FETCH_POST_SUCCESS: 'FETCH_POST_SUCCESS',
    FETCH_POST_FAILURE: 'FETCH_POST_FAILURE',
}
 
// 发起文章请求的 action,
// 包含一个 postId 标识准备请求哪篇文章,
// 可用于在 store 中标识拉取状态。
const fetchPostRequest = (postId) => ({
    type: types.FETCH_POST_REQUEST,
    payload: postId,
})
 
// 成功接收文章的 action,
// 包含一个 post 属性,里面存储了接收的文章内容以及 postId。
const fetchPostSuccess = ({ id, content }) => ({
    type: types.FETCH_POST_SUCCESS,
    payload: { id, content },
})
 
const fetchPostFailure = ({ id, failedResponse }) => ({
    type: types.FETCH_POST_FAILURE,
    payload: { id, failedResponse },
})
 
// 用来发请求的 action creator 返回一个函数,
// 称之为 thunk action,会被 Redux Thunk 中间件接手
// 使用的时候和普通 action 一样:
// store.dispatch(fetchPost(postId))
export const fetchPost = (postId) => (dispatch) => {
    // 这个返回的函数会被 Redux Thunk 中间件执行,
    // 同时会在第一个参数接收到 redux store 的 `dispatch` 方法,
    // 从而可以在这里面自己触发 action。
 
    // 先触发一个准备发请求的 action,
    dispatch(fetchPostRequest(postId))
 
    return fetch(`/posts/${postId}.md`)
        .then((response) => response.text())
        .then((content) => fetchPostSuccess({ id: postId, content }))
        .catch((failedResponse) => fetchPostFailure({ id: postId, failedResponse }))
}

Action Creator 有了之后,就可以来写 Jasmine 中的测试了:

actionTest.js

// 用 redux-mock-store 来模拟一个 store 并检查是否触发了预期的 action
// https://github.com/arnaudbenard/redux-mock-store
import configureMockStore from 'redux-mock-store'
 
// 用来处理我们的 thunk action 的中间件
// https://github.com/gaearon/redux-thunk
import thunk from 'redux-thunk'
 
// 用 sinon 来伪造 fetch API。
// 如果在 Karma 配置中设定了 frameworks: [... ,'sinon'],
// 则不需要再写这句 import 了。
// http://sinonjs.org/
import sinon from 'sinon'
 
// 上面写的 actions.js
import { types, fetchPost } from '../actions'
 
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
 
describe('Post fetching', () => {
    // 用作测试的文章 id 及其内容
    const testPostId = 223
    const testPostContent = 'Gochuumon wa usagi desu ka ???'
 
    describe('succeed', () => {
        // 创建一个包含文章内容的成功响应
        const res = new window.Response(testPostContent, {
            status: 200,
            headers: { 'Content-type': 'text/plain' },
        })
 
        beforeAll(() => {
            // 把原来的 fetch 包成 stub,
            // 这样做原来的 window.fetch 就不会被调用了
            sinon.stub(window, 'fetch')
 
            // 这里调用的 window.fetch 已经是 sinon 的 stub function 了,
            // 可以通过 withArgs 与 returns 方法
            // 来指定函数在接受到什么参数的时候返回什么。
            // 这里指定请求正确的文章 URL 的时候
            // 返回对应的请求成功的响应
            window.fetch.withArgs(`/posts/${testPostId}.md`).returns(Promise.resolve(res))
        })
 
        afterAll(() => {
            // 执行完这组用例后恢复原来的 window.fetch 函数。
            window.fetch.restore()
        })
 
        it('should FETCH_POST_SUCCESS with post content', (done) => {
            // 期望的发起请求的 action
            const actRequest = {
                type: types.FETCH_POST_REQUEST,
                payload: testPostId,
            }
 
            // 期望的请求成功的 action
            const actSuccess = {
                type: types.FETCH_POST_SUCCESS,
                payload: { id: testPostId, content: testPostContent },
            }
 
            const expectedActions = [actRequest, actSuccess]
 
            // store 的初始状态
            const initialState = {}
 
            // 如果触发了期望的 action 的话,
            // done 会被调用,表明这个测试用例通过了。
            const store = mockStore(initialState)
 
            // 准备好模拟的 store 后,
            // 触发请求文章的 action ~
            store
                .dispatch(fetchPost(testPostId))
                .then(() => {
                    expect(store.getActions()).toEqual(expectedActions)
                })
                .then(done)
                .catch(done.fail)
        })
    })
 
    describe('failed', () => {
        // 和请求成功的情况类似,先创建一个失败的响应比如 404。
        const res = new window.Response('', { status: 404 })
 
        beforeAll(() => {
            sinon.stub(window, 'fetch')
            window.fetch.withArgs(`/posts/${testPostId}.md`).returns(Promise.reject(res)) // 失败的请求应该用 Promise.reject()
        })
 
        afterAll(() => {
            window.fetch.restore()
        })
 
        it('should FETCH_POST_FAILURE with errored response', (done) => {
            const actRequest = {
                type: types.FETCH_POST_REQUEST,
                payload: testPostId,
            }
 
            const actFailure = {
                type: types.FETCH_POST_FAILURE,
                payload: { id: testPostId, failedResponse: res },
            }
 
            const expectedActions = [actRequest, actFailure]
 
            const store = mockStore({})
            store
                .dispatch(fetchPost(testPostId))
                .then(() => {
                    expect(store.getActions()).toEqual(expectedActions)
                })
                .then(done)
                .catch(done.fail)
        })
    })
})

就酱 ~(>_<~)