【工程专题】推行一年最终失败的单元测试

记于 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 渲染界面

上述过程中的测试点:

  1. component 根据不同的 props 和 state 是否渲染了正确的内容(涵盖所有可能的组合)
  2. props 的更新是否引起 state 的正确更新
  3. 内部事件交互事件是否内部正确的触发
  4. 内部事件被触发是否能引起 state 的正确更新
  5. 内部事件被触发是否能保证 props 的回调被执行

怎么测:

  1. 不同组合的 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})  
  })  
  1. 内部事件被触发是否能保证 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 使用的流程是:

  1. 外界传入一个当前的 state 和 action
  2. reducer 对收到的 action 返回正确的值

需要测试的内容:

  • 对进来的所有 state 和所有 action 都能有正确的返回值
    1. 对本 reducer 能处理的 state 和 action 有期望的返回值
      • 能对 action 所有可能的数据形式都做出正确响应(比如 promise 的 error 啥的)
      • …(还有东西可以细化,但暂时想不到, 同一个 action 包含的数据就可能有很多种)
    2. 对本 reducer 不能处理的 state 和 action 能不引起异常
      • 不传 state 和 action
      • …(也是有很多可以细化的,异常这个词太大了)

怎么测试:

  1. 测试正确的 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)  
  )  
  1. 测试能 hold 住异常的 action
it('should return the initial state', () => {  
    expect(alertReducers(alertInitialState,{})).toEqual(alertInitialState)  
})  

Container 测试相关

一个 Container 内部可能发生的事情是:

  1. Store.statemapStatesToProps 映射到 ContainerProps
  2. 外界(用户等)或内部状态改变 触发了一个内部函数被调用, 引起 this.props.function() 被执行
  3. this.props.function()mapDispatchToProps 映射成了 dispatch(SomeAction)
    • 此处的 SomeAction 可能是由某个 ActionCreator 创建的
  4. dispatch(SomeAction) 之后被 stroe 中的 reducer 接受,并处理为一个新的 state
  5. 新的 state 被 mapStatesToProps 映射到 this.props.someProps

上述流程中需要所包含的所有需要测试的内容(括号中是测试应该归属于哪一个测试):

  1. (Container) mapStatesToProps 是否将正确的 state 映射到 正确的 props 上
  2. (Component) 外界事件或内部的函数是否正确的执行了 this.props.function()
  3. (Container) this.props.function() 是否映射到了正确的 dispatch(SomeAction)
    • (ActionCreator) dispatch 时使用的 ActionCreator 是否是正确的并且是否创建了正确的 Action
  4. (Reducer) reducer 接受到这个 Action 后是否处理成了正确的 state
  5. (store) 这个 state 是否被正确放进了 store / store.getState() 是否是处理后的 State
  6. (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 的映射正确, 和满足需求没有任何关系. 但我们的代码同时又需要满足需求.所以测试应该要同时满足两者.

注意事项:

test(`***`, ()=>{  
	mockData.contentWidth = testCase.input.width  
	mockData.contentHeight = testCase.input.height  
	expect(discoverList.calculateCellHeight(mockData)).toBe(expectResults[caseName].expect)  
})  

其他问题:

  • 什么东西会引起 app 状态改变
    • 用户点击等用户行为
    • 视频图片加载中_加载结束_进度回调等 app 内部异步行为
    • 定时器等
    • 蓝牙 wifi 改变等 外界 行为

参考文章:


留作纪念

代码: CameraControlContainer.tsx

项目留图: