单向数据流角度解决测试难题

达芬奇密码2018-07-25 09:29

首先我们来明确一下,测试中最核心的东西是什么。当然是数据,我们永远是围绕着数据来的,那么之前一些架构的问题是什么。无论哪个框架,数据的流通都是双向的,当数据流通成为单向了会怎么样呢?

in data ==> Module ==> out data

这样我们伪造数据进行测试就会非常方便了。按照这个思想就有了数据单向流通的架构。

数据单向流通的实现

这个概念最早是在web中提出的,应用在React里,官方的方案是Redux。现在swift也提出了一种实现ReSwift

我在之前写React的时候使用过这种方案,从开发角度来说,这种方案会大大增加开发难度,代码量也会大量增加,而且开发思路也需要从以前的思考方式转换过来。但是如果我们把这个思路转换过来,其实对整个流程是更加简化和分离的。

从测试角度看,我觉得无疑是我知道的最可测的一种框架,甚至可以测试部分视图的逻辑。

那么总的来说,很难说这种结构的好坏,就算不考虑增加的开发时间,也是一种难以给以一种评价的方案。

(Redux/ReSwift)框架介绍

方案的几个核心是:

  • 数据的单向流通
  • 每个视图都可以看做一个状态机
  • pure function

关于pure function,我就不做太多介绍了,简单的说,就是同一输入必定会有相同的输出,是非常容易测试的一种函数。

首先,我们来看一下官方的架构图。

可以看到,数据流动方向都是朝一个方向进行的。那么下面从每个模块来介绍下,还是以star button为例子。

State

视图状态机,也是所有会更新界面数据保存的地方,可以认为相当于ViewModel。

首先我们star会有以下几种视觉样式

enum StarButtonState {
    case star
    case staring
    case unstar
    case unstaring
}

所以State可以定义为

struct StarState: StateType {
    var state: StarButtonState
    var starCount
}

Action

首先我们定义几种状态机转换的Action类型

struct StarAction: Action { }
struct StaringAction: Action { }
struct UnstarAction: Action { }
struct UnstaringAction: Action { }

以及相应的功能以及状态变更,这里异步请求采用延迟来代表。

func star(id: String) -> Store<StarState>.ActionCreator {
    return { state, store in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            store.dispatch(StarAction())
        }
        return StaringAction()
    }
}
func unstar(id: String) -> Store<StarState>.ActionCreator {
    return { state, store in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            store.dispatch(UnstarAction())
        }
        return UnstaringAction()
    }
}

View

视图层其实很简单,只需要根据State的不同来更新就可以了。注意的是,更新都是无状态的,和上一个状态无关,所以view层是个无状态层。

class StarButton: UIButton, StoreSubscriber {

    let store = Store<StarState>(reducer: starReducer, state: nil)

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.store.subscribe(self)
    }

    func newState(state: StarState) {
        // update UI
    }
}

Reducer

状态转换器,唯一可以更新State的地方。

func starReducer(action: Action, state: StarState?) -> StarState {
    var state = state ?? StarState(state: .star, starCount: 0)

    switch action {
    case _ as StarAction:
        state.state = .star
        state.starCount += 1

    case _ as StaringAction:
        state.state = .staring

    case _ as UnstarAction:
        state.state = .unstar
        state.starCount -= 1

    case _ as UnstaringAction:
        state.state = .unstaring

    default:
        break
    }

    return state
}


数据传递

那么最重要的就是数据如何传递的了。首先要明确的是每个模块能够修改的,或者说是传递的,只能是下个模块。

比如,用户star button触发了一个事件:

func onButton(sender: StarButton) {
    if (store.state.state == .unstar) {
        store.dispatch(star(id: id))
    }
    else if (store.state.state == .star) {
        store.dispatch(unstar(id: id))
    }
}

此时会创建Action,也就是将view事件转换为Action。然后会传递到store中,store会调用Reducer进行处理。Reducer更新state之后又会触发store的subscribe事件,回到view的func newState(state: StarState)

View (User Event)
==(create)==> ActionCreator/Action
==(dispatch)==> Store <--(Update State)--> Reducer
                  \==(subscribe)==> View (newState)

大概的一个流程就是这样了。

接下来说说这样做的模块化的优势。

模块化和测试性

首先,我们需要有函数式编程的概念,函数也是一等公民,所以ActionCreatorReducer都是独立的模块。

作为使用者,我们在不需要像MVC一样知道这些api所代表的操作功能,相对应的,我们需要去了解一个模块的动作(Action),比如以上例子就是

func star(id: String)
func unstar(id: String)

这样的划分比MVC要友好的多,真正的把逻辑功能从原本的C中分离开。需要触发这个行为也非常简单store.dispatch(star(id: id))。相比MVP,行为更加的独立,每个行为之间完全没有联系,也不会产生干扰影响。同时因为每个行为的独立性,可复用程度也就越高。

Reducer则代表了view层的更新,也可以非常明确的知道每个状态的变更发生了什么。相比其他模式,将界面更新完全交给view或者Controller,Reducer是最明确也是最清晰的。同时Reducer也是独立的,可以替换的。

对于UIKit层面我们无法单元测试,所以测试的主要部分是ActionReducer。这两个模块可以说都是pure function或者在某些条件下是pure function的,所以测试也非常的简单。

对比

和这个模式比较像的有状态机模式和Reactive。

状态机模式也是实现对应功能,以及对应状态,然后通过子类化的方式去实现Reducer的功能。

Reactive则比较像ActionCreator,只是Reactive返回的是信号量。

使用场景

从上面可以看出这是一套非常优秀的模块划分方案,但同时也会大大增加代码量,而且需要改变以前的思维模式。而对于目前国内的现状来看,很难有这么多时间和精力让整个项目都使用这种模式。

但是这种模式的特点也非常的明显,在处理比较复杂的交互行为,并且存在较多的视图状态的时候,会是一种比较好的方案。比如视频播放界面。

所以个人认为,在一些简单的场景下并不需要使用该方案,但是在一些复杂的交互页面,而且又非常想要引入单元测试的场景,可以酌情考虑下这种方案。这种方案要求人们的思维方式的改变,需要有一定的函数式编程的概念。

虽然不一定会直接使用ReSwift,但是这种思想有很多值得借鉴的地方,利用这种思想做出类似的效果,以便达到可以容易进行白盒测试的目的。

最后

以上虽然说不会全部使用该方案,但也可以部分使用。比如独立的小模块,亦或是app层面的一些东西。下次可以讨论下app层面如何来利用单向数据流来简化流程。


本文来自网易实践者社区,经作者段家顺授权发布。