记于 2020 年 10 月
单元测试是我在接手 APP 组后尝试推动的事情.
当时刚转型 RN, 期望借助前端成熟的工具, 弥补一直以来开发中缺少测试导致的问题(好的代码应该是先写测试的)
因为没有现成的 RN 写单元测试模板, 我尝试自己分析 RN 的元素, 找到适合我们的单元测试方案.
但最终, 单元测试推动了一年后,我评价成果是 失败 的.
我于 2018 年 5 月写下这篇文章, 之后开始实施和推动, 最后于 2019 年 3 月左右放弃.
这篇文章所记载的是我当初分析 RN + Redux 框架下 APP 中存在元素.
尝试从这些元素及其状态改变中, 推论出来的内部需要测试点. 总共分为:
UI Component, Action, Reducer, Container 四个部分(还有功能模块测试不算在内)
文章的结构是: 1.元素中有什么; 2. 这里面可以测试的点; 3. 怎么测
因为最终结果是失败的, 就不总结太多细节. 这是开始推的第一版, 后续改进没再记录, 文章在下面 ↓
失败的原因在于:
- 我期望用单元测试测的东西太多. 下图、下文我罗列出了我想到的程序所有路径. 并且我期望将所有步骤都做测试
- 基于上面的策略, 测试代码的撰写早期占到我们一半以上的时间
- 同时, APP 小需求变化发生的实在太快, 我们没有享受到单元测试带来的收益, 反而不断的需要修改测试代码
- 我得出一个结论 业务层本身可能不适合写单元测试
- 即使推了大半年的单元测试, 我们也没有养成 测试驱动开发的代码书写方式, 这是单元测试的核心目的.
- 对于检测代码问题, 由测试(保证功能质量)和 code review(保证代码质量) 配合. 更适合小公司的资源情况
- 最后一个原因, 当时 rn 的测试不成熟, 我们遇到太多被测试工具卡住的情况. 最主要的还是, 没有带来收益的同时不断出问题
单元测试要测什么这个话题,要跟项目的架构体块看: APP 整体结构
最末尾留了点做为失败的纪念
单元测试要测什么?

我们当前的 APP 由这几种类型的代码组成: function(功能性函数), Component, Action, Reducer, State, Container
对以上的内容,我们需要知道每种具体的代码需要测试什么.
UI Component 要测试什么
component 内部可能发生事情:
- 外界 赋值/更新 props 会导致
- component 内部需要根据 props 渲染界面
- componnet 内部需要根据新的 props 更新 state 导致
- state 的更新触发 component 渲染界面
- component 内部的交互事件被触发
- 交互事件被触发会导致 props 中回调被执行
- 交互事件被触发会导致 state 被修改
- state 的更新触发 component 渲染界面
上述过程中的测试点:
- component 根据不同的 props 和 state 是否渲染了正确的内容(涵盖所有可能的组合)
- props 的更新是否引起 state 的正确更新
- 内部事件交互事件是否内部正确的触发
- 内部事件被触发是否能引起 state 的正确更新
- 内部事件被触发是否能保证 props 的回调被执行
怎么测:
- 不同组合的 props 和 state 是否渲染了正确内容:
describe("Other components should render correctly", () => {
type ComponentsResult = {
'thumbnail': 0 | 1,
'loading': 0 | 1,
'controlButton': 0 | 1,
'muteButton': 0 | 1,
'duration': 0 | 1
}
// 对于不同的 video 状态 component 中所有不同的组件的被查找时的期望值
let expectResult = {
[VideoState.idle]: {'thumbnail': 1, 'loading': 0, 'controlButton': 1, 'muteButton': 0,'duration': 0},
[VideoState.loading]: {'thumbnail': 1, 'loading': 1, 'controlButton': 0, 'muteButton': 0,'duration': 0},
[VideoState.ready]: {'thumbnail': 1, 'loading': 0, 'controlButton': 1, 'muteButton': 0,'duration': 0},
[VideoState.playing] : {'thumbnail': 0, 'loading': 0, 'controlButton': 1, 'muteButton': 1,'duration': 1},
[VideoState.paused]: {'thumbnail': 0, 'loading': 0, 'controlButton': 1, 'muteButton': 1,'duration': 1},
[VideoState.buffering]: {'thumbnail': 0, 'loading': 1, 'controlButton': 0, 'muteButton': 1,'duration': 1},
} as {[key: string]: ComponentsResult}
// 遍历所有 video 的状态
Object.keys(expectResult).forEach(state =>{
describe(` when videostate = ${state}`, ()=>{
let results: {[key: string]: number} = expectResult[state]
// 遍历所有 Component
Object.keys(results).forEach(component => {
// 测试这些 component 在对应的 videostate 下是否被正确渲染了
test(`component = ${component} expectResult = ${results[component]}`, () =>{
wrapper.setState({videoState: state as VideoState})
// 测试方式: 看是否能查到这个 component
expect(wrapper.find({testID:component}).length).toBe(results[component])
expect(wrapper).toMatchSnapshot()
})
})
})
})
})
2.内部事件被触发是否能引起 state 的正确更新
test("DiscoverVideo state should correct when onLoadStart", () => {
let wrapper = setup()
let defaultState = wrapper.state()
// 初始化要测试的 Component state
let initState: States = {...defaultState, videoControl: VideoControl.play}
wrapper.setState(initState)
const loadStartState = VideoState.loading
// mock 一个内部函数被调用了
wrapper.find({testID: 'video'}).simulate('loadStart')
// 判断需要变化的 state 是否按预想的变化了
expect(wrapper.state().videoState).toEqual(loadStartState)
// 进一步判断是否有意料之外的其他state修改
expect(wrapper.state()).toEqual({...initState, videoState:loadStartState})
})
- 内部事件被触发是否能保证 props 的回调被执行
test("Update muted should be called When onMutePress", () =>{
let wrapper = setup()
wrapper.find({testID: 'muteButton'}).simulate('press')
expect(wrapper.prop("didMutedUpdated")).toBeCalled()
})
Action 测试相关
Action 的使用流程:
- 使用 ActionCreatore 创建一个 Action
- Action 受中间件影响,进了 store 后 payload 被改变(比如 promise)
测试点:
- ActionCreator 能创建正确的 Action
- 中间件能返回正确的 payload (比较难测)
怎么测:
- ActionCreator 能创建正确的 Action
it('should create an Action AddHighPriorityAlert', () => {
let actions = [{title:"button", action:()=>{}}]
let title = "title"
let message = "message"
// 将参数写入
expect(ActionCreator.addHighPriorityAlert(actions, title,message)).toEqual({
type: ActionType.AddHighPriorityAlert,
payload: {
type: AlertType.normal,
title: title,
message: message,
actions: actions
}
} as Action.AddHighPriorityAlert)
})
- 异步 Action 能有正确的返回值
describe('ActionCreator: getDiscoverDatas', () => {
// Mock 一个 store
const middlewares = [promiseMiddleware]
const mockStore = configureMockStore(middlewares)
const store = mockStore({
discover: discoverInitialState
})
// mock 指定的请求,并返回我们期望返回的内容
fetchMock.get('www.getHover.com', new Response(JSON.stringify(mockListDatas)))
it('should create an Action FetchDiscoverList', () => {
let {skip, take} = {skip: 0, take: 10}
// 判断 getDiscoverDatas Action 被 dispatch 入 store 后是否能拿到期望的返回值
store.dispatch(ActionCreator.getDiscoverDatas(skip,take)).then(()=>{
expect(store.getActions()[0]).toEqual(mockListDatas)
})
})
})
问题点:
- actionCreator 很简单, 很多时候就是将进来的参数包装成一个 Action 对象扔出去,测试的意义不大.而扔出去的Action 类型是否是期望的类型,在 TypeScript 中可以直接被编译器识别出来.这导致测试很多时候是多余的.
reducer 测试相关
Reducer 使用的流程是:
- 外界传入一个当前的 state 和 action
- reducer 对收到的 action 返回正确的值
需要测试的内容:
- 对进来的所有 state 和所有 action 都能有正确的返回值
- 对本 reducer 能处理的 state 和 action 有期望的返回值
- 能对 action 所有可能的数据形式都做出正确响应(比如 promise 的 error 啥的)
- …(还有东西可以细化,但暂时想不到, 同一个 action 包含的数据就可能有很多种)
- 对本 reducer 不能处理的 state 和 action 能不引起异常
- 不传 state 和 action
- …(也是有很多可以细化的,异常这个词太大了)
- 对本 reducer 能处理的 state 和 action 有期望的返回值
怎么测试:
- 测试正确的 action 有正确的 state 被返回
it('should handle ADD_HIGH_PRIORITY_ALERT', () => {
let actions = [{title:"button", action:()=>{}}]
let title = "title"
let message = "message"
// 创建一个正确的 Action
let action: Action.AddHighPriorityAlert = ActionCreator.addHighPriorityAlert(actions, title,message)
let expectAlertQueue = alertInitialState.alertQueue.slice()
expectAlertQueue.unshift(action.payload)
// 判断这个 Action 被传入 reducer 后是否有正确的返回值
expect(alertReducers(alertInitialState, action)).toEqual({
...alertInitialState,
alertQueue: expectAlertQueue
} as AlertState)
)
- 测试能 hold 住异常的 action
it('should return the initial state', () => {
expect(alertReducers(alertInitialState,{})).toEqual(alertInitialState)
})
Container 测试相关
一个 Container 内部可能发生的事情是:
Store.state被mapStatesToProps映射到Container的Props上- 外界(用户等)或内部状态改变 触发了一个内部函数被调用, 引起
this.props.function()被执行 this.props.function()被mapDispatchToProps映射成了dispatch(SomeAction)- 此处的 SomeAction 可能是由某个 ActionCreator 创建的
dispatch(SomeAction)之后被 stroe 中的 reducer 接受,并处理为一个新的 state- 新的 state 被
mapStatesToProps映射到this.props.someProps上
上述流程中需要所包含的所有需要测试的内容(括号中是测试应该归属于哪一个测试):
- (Container)
mapStatesToProps是否将正确的 state 映射到 正确的 props 上 - (Component) 外界事件或内部的函数是否正确的执行了
this.props.function() - (Container)
this.props.function()是否映射到了正确的dispatch(SomeAction)- (ActionCreator)
dispatch时使用的ActionCreator是否是正确的并且是否创建了正确的 Action
- (ActionCreator)
- (Reducer)
reducer接受到这个Action后是否处理成了正确的 state - (store) 这个
state是否被正确放进了store/store.getState()是否是处理后的 State - (Container) 这个
state是否被映射到了正确的this.props.someProps上
其中 1, 3/6 两项测试是 Container 需要负责的
怎么测试:
1 state 正确的映射到了 props 上:
test("connect is correctly", () =>{
// mock 一个 store
let store = mockStore({
alert: {
isShowAlert: true,
alertQueue: [{title: "测试弹窗", message: "测试消息", actions:[{title:"测试按钮", action:()=>{}}]}],
showingAlert: {title: "测试弹窗", message: "测试消息", actions:[{title:"测试按钮", action:()=>{}}]}
} as AlertState
})
// mock 一个 container 并为其赋值 mockstore
let alertWrapper = shallow(<AlertContainer store={store}/>)
let alertState = store.getState().alert
// 测试 container 的 props 对应了正确的 state
expect(alertWrapper.props()).toEqual(expect.objectContaining({
isShowAlert: alertState.isShowAlert,
alertQueue: alertState.alertQueue,
showingAlert: alertState.showingAlert,
showAlert: expect.any(Function),
hideAlert: expect.any(Function)
}))
})
2 dispatch(Action) 正确映射到了 props 上:
test("props.hideAlert should dispatch HideAlertAction", () =>{
// 触发 container.props.function()
alertWrapper.props().hideAlert()
// 有一个正确的 action 被 dispatch 了
expect(store.dispatch).toHaveBeenCalledWith({
type: ActionType.HideAlert
})
})
测试两个方面
上述的 container 测试是在测试代码是否书写正确(映射到了正确的值)
但除此之外应该还要测代码的实现是否符合需求,那么测试能否被归为两个方面:
- 代码是否能实现预期的功能
-
预期的功能是否满足了需求
- 这里的定义很模糊,
实现预期的功能不就是在满足需求吗- 但是一个函数能在列表滚动的时候做出响应(比如让列表正中间的进行播放), 和一个功能能在列表滚动到中间时播放应该是两个不同的概念
- reducer 能对 异常的 State 和 Action 做出正确的处理, 和 reducer 能对列表滚动这个 action 给予正确的返回值应该是两个不同的概念
- 会有这个疑虑的原因在于,保证 container props 的映射正确, 和满足需求没有任何关系. 但我们的代码同时又需要满足需求.所以测试应该要同时满足两者.
注意事项:
- 每个测试都会改变 mock 的数据, 所以在另一个测试开始前要保证 mock 的数据还是自己想要的
- 比如: 数组很容易不小心改变而不自知
- shllow 无法渲染出 ref Refs not working in component being shallow rendered · Issue #316 · airbnb/enzyme · GitHub
- 以下代码是异步的,所以对对象值的改变要放在代码块里面
test(`***`, ()=>{
mockData.contentWidth = testCase.input.width
mockData.contentHeight = testCase.input.height
expect(discoverList.calculateCellHeight(mockData)).toBe(expectResults[caseName].expect)
})
其他问题:
- 什么东西会引起 app 状态改变
- 用户点击等用户行为
- 视频图片加载中_加载结束_进度回调等 app 内部异步行为
- 定时器等
- 蓝牙 wifi 改变等 外界 行为
参考文章:
- Unit Testing Redux Containers : 提供了如何测试 Container 的方式
留作纪念
代码: CameraControlContainer.tsx
项目留图:
