测试驱动开发
1.什么是测试驱动开发?
基本思想就是在开发功能代码之前,先编写测试代码,然后只编写使测试通过的功能代码,从而以测试来驱动整个开发过程的进行。这有助于编写简洁可用和高质量的代码,有很高的灵活性和健壮性,能快速响应变化,并加速开发过程。
我并不是说以后写业务,都要先写单元测试,然后写完单元测试后开始写业务。这会拉长开发周期,再说老板也不答应呀。但我一直认为高度可测试的代码的可维护性更强。所以可以在平时写代码前,多想想为自己的代码添加单元测试麻烦吗,是不是还有可测试性更高的写法。
但往往我们在给自己的App添加单元测式,如果在写代码的时候没有过多的思考,写了很多胶水代码。编写写单元测试会变得很困难。无论是UI测试还是功能测试。如果改UI的代码散落各处,一个方法受状态的影响太多。
而且状态可能来自服务器(http请求的回调),用户操作(Target - Action),一个方法依赖的外部状态太多了,要写单元测试得用一些测试库如ocmork,来模拟一些具体的依赖类,模拟相应或者返回,而且还的配合用户的点击状态。那么写一个函数的测试变的很复杂。而且一个VC如果很多都由这种代码组成,重构困难,加功能困难,需要改时候容易牵一发动全身,这个类也变得难以维护。
2.设计阶段多思考可测试性
如果一个模块(大模块或者一个MVC模块)变得容易测试,覆盖范围广,可维护性一定强。对于一个函数或者方法,怎么样才比较容易测试,测试无非就是模拟输入,验证输出。如果一个方法和输入和输出明确,类似于y= f(x),那么我们只要模拟x,验证y。就可以完成这个函数的单元测试。而这个f(x)的内部实现不会依赖于函数外部的参数,也就是说这个f(x)是独立的,没有副作用的函数。这个理想的函数有纯函数的一些特性。那么什么是纯函数?
3.纯函数
指一个函数如果有相同的输入,则它产生相同的输出,一旦输入给定,那么输出则唯一确定,没有负作用。比如y = sin(X)。
像我们平时写的[someInstance method];这样的函数由于不存在输入和输出,如果内部实现还对外部的结构参数做一些改变,就很难直接测试这个函数。函数内部如果对参数意外的变量或者状态进行改变,这个就是这个函数的副作用(side effect),也可以说这个函数对其他变量的依赖性强,而且单看这个函数本身,还预见不了这个改变,依赖被函数所屏蔽了。这是一个容易引起bug的潜在因素。
4.纯函数特点
先偏离App,纯函数编程有以下特点:
4.1.函数可以被当成参数和output。
4.2.函数的结果只受函数参数影响,不依赖于定义在函数外的变量,对于特定输入有特定输出
4.3.依赖于不可变数据结构
4.4.由于计算是透明的,任何时候执行产生相同结果,可以推迟计算,知道需要的时候。lazy load 或者 swift的copy on write
4.5利用范型进行高度抽象成功能性程序。
4.6.函数为一等公民,可以作为参数和返回值。而且还可以延迟执行。
其实Swift是一个很好实践函数式编程的一门得力语言,但我们今天不讲swift的函数式编程,如果对Swift的函数式编程思想感兴趣,可以参考objc.io上的advanced-swift 和functional-swift,我这里有中文的译本。
5.实践
5.1 如果你们和我一样现在还在用OC,其实作为一个纯面向对象的编程语言,但编程的时候以函数式思想编程还是有借鉴意义的。可以在这里找到具体例子demo。在介绍具体的代码前,我想先介绍以下Redux:
Redux由Dan Abramov在2015年创建。是受2014年Facebook的Flux架构以及函数式编程语言Elm启发。Redux因其简单易学体积小在短时间内成为最热门的前端架构。
它有以下几个核心概念:
Action:简单地,Actions就是事件。用户的操作,网络的回调,Actions传递来自(用户接口,内部事件比如API调用和表单提交)的数据给store。store只获取来自Actions的信息。
Store:Store对象保存应用的状态并提供一些帮助方法来存取状态,分发状态以及注册监听。全部state由一个store来表示。任何action通过reducer返回一个新的状态对象。这就使得Redux非常简单以及可预测。
Reducer:在Redux中,reducer就是获得这个应用的当前状态和事件然后返回一个新状态的函数。(Action,State) ---> State
State:应用的状态,决定着应用的行为和输出。
对于 app 而言,我们总是会和一定的用户输入打交道,也必然会需要按照用户的输入和已知状态来更新 UI 作为“输出”。这个状态我们可以抽象成State,用户的输入或者其他能够改变状态的行为我们抽象为Action,那我们需要写自己的Reduce函数。设计Reduce函数的时候最好输入给一个state,输出一个全新的newState,它们是不同的对象,而不仅仅只是在同一个对象的基础上进行改变,这样在订阅方才可以明确知道state是否发生改变。
reducer(Action,State) -> State
我们还需要一个State来驱动变化,所以我认为State结构内部可以引用一些驱动用户行为或者UI变化的数据源,最好确保 State 中每个节点都是 Immutable 的,这样将确保 State 的消费者在判断数据是否变化时,只要简单地进行引用比较即可。
我们需要Store来存储State,订阅观察者,给State派发Action,所以一个Store可能抽象成这样
@interface Store : NSObject
//当前状态
@property (nonatomic, strong,readonly) id<StateType> state;
//初始化方法,接受一个reducer和初始状态
- (instancetype)initWithReducer:(Reducer )reducer
initialState:(id<StateType>)state;
//订阅一个观察者,State发生改变通知观察者
- (void)subscribeNext:(SubscribeBlock)subscriber;
//取消订阅
- (void)unsubscribe;
//给State 派发Action
- (void)dispatch:(id<ActionType>)action;
@end
对于Action,生产者给Store dispatch Action。我们将Action分为同步的或者异步的,同步的Action通过Reduce产生新的State驱动subscriber,异步的Action通过Reduce,这时候并不产生新的state,而是在回调中再向Store dispatch newAction ,再产生新的State后才驱动subsriber。
数据的流动就变成这样:
解释:Store会持有State和reducer,外界如果想要触发新的State只有通过向Store派发Action,Store拿到Action和当前的State,会尝试通过Reudcer产生一个新的State,如果这个Action是同步的,那么reduce可以立即产生新的有效的State,然后通知订阅者,订阅者根据最新的State来决定UI的样式。如果Action是异步的,reduce不会立即产生一个newState,而是在异步操作的回调中给Store派发一个新的同步的Action。外界任何其他角色不直接改变UI,UI是由唯一的State所决定。这样要测试这部分的业务,我们只要在给Store派发可预见的Action,然后在Subscriber中检测输出。这套逻辑本身没有依赖其他任何的UI状态,所以单元测试变得简单。
看了这么多抽象的逻辑我们看具体的demo
这是一个查询省的一个demo,跳转后,会用coreData记录下查询记录,搜索部分输入省名还可以进行查询。
我们看下Store类的结构,Store初始化时候需要intialState和Reducer函数,reducer要从外面传入的原因是,reducer要操作具体的State,这个State必定和业务绑定。为了解耦。值得一提的就是这个dispatch方法,store将订阅者放到一个array容器里,接受到异步action的时候reducer会返回nil,我们就不通知订阅者,否则执行array的blocks
@interface Store : NSObject
@property (nonatomic, strong,readonly) id<StateType> state;
- (instancetype)initWithReducer:(Reducer )reducer
initialState:(id<StateType>)state;
- (void)subscribeNext:(SubscribeBlock)subscriber;
- (void)unsubscribe;
- (void)dispatch:(id<ActionType>)action;
@end
@implementation Store
...
- (void)dispatch:(id<ActionType>)action {
id<StateType> previousState = _state;
id<StateType> nextState = self.reducer(previousState,action);
if (nextState) {
self.state = nextState;
if (self.subscribers.count > 0) {
__weak __typeof(self)weakSelf = self;
[self.subscribers enumerateObjectsUsingBlock:
^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
@synchronized (weakSelf) {
SubscribeBlock block = (SubscribeBlock)obj;
block(previousState,nextState);
}
}];
}
}
}
@end
...
在ViewController里面我们定义了State和Action的数据结构,当然也可以将这两部分抽出来放在另一个service类中,但我这里就这么做了。
@interface State :NSObject<StateType,NSCopying>
@property (nonatomic, copy) NSArray *cities;
@property (nonatomic, copy) NSString *text;
@property (nonatomic, copy) NSArray *histories;
@end
@implementation State
- (id)copyWithZone:(NSZone *)zone {
State *copy = [[[self class] allocWithZone:zone] init];
copy.cities = self.cities;
copy.text = self.text;
copy.histories = self.histories;
return copy;
}
@end
我认为State就管理着数据源,因为State决定着程序的行为和UI的样式,而一般这些都是一些特定的数据所驱动的。这里整个demo的数据源有,所有省份(citites),搜索的文字(text)和历史记录(history)三部分组成,注意这里用的都是不可变得数据结构,state遵循了NSCopying协议,因为经过reducer的后的state和之前的state需要是两个不同的State。
对于Action,区分了异步和同步的Action,它们在Reducer中的处理不一样。
typedef NS_ENUM(NSUInteger, Action_Type) {
UpdateText_Action,
AddCities_Action,
AddHistories_Action,
//异步command
FetchCities_Action,
FetchHistories_Action,
FetchAssociate_Action,
ClearHistory_Action,
};
@interface Action :NSObject<ActionType>
@property (nonatomic, assign) Action_Type actionType;
@property (nonatomic, strong) id associateValues;
+ (instancetype)actionWithActionType:(Action_Type) type values:(id)associateValues;
@end
我们看这个重要的Reducer是如何被定义的,对于收到同步的AddHistories_Action
,我们设置属性后返回新的State,对于异步的FetchHistories_Action
,我们在回调中再发起一个新的同步的Action。
- (Reducer )reducer {
__weak __typeof(self)weakSelf = self;
Reducer reducer = ^(id<StateType> state, id<ActionType>action){
State *previousState = (State *)state;
State *currentState = [previousState copy];
switch (action.actionType) {
.....省略一些代码
case AddHistories_Action:
{
id associateValue = action.associateValues;
currentState.histories = associateValue;
break;
}
case FetchHistories_Action: {
[FetchData fetchHistories:^(NSArray *data, NSError *error) {
Action *action = [Action new];
action.actionType = AddHistories_Action;
action.associateValues = data;
[weakSelf.store dispatch:action];//2
}];
currentState = nil;
break;
}
default:
break;
}
return currentState;
};
return reducer;
}
我们在viewDidload中我们1.初始化State,2.初始化Store,3.绑定订阅者,4.发起查询省的一个Action
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"clearHistory"
style:UIBarButtonItemStylePlain
target:self
action:@selector(clearHistory)];
[self.textField addTarget:self
action:@selector(changed:)
forControlEvents:UIControlEventEditingChanged];
//1.初始化State
State *initialState = [[State alloc] init];
//2.初始化Store
_store = [[Store alloc] initWithReducer:self.reducer initialState:initialState];
__weak __typeof(self)weakSelf = self;
[_store subscribeNext:^(State *old ,State *new) {
//3.绑定订阅者
[weakSelf stateDidChangeWithNew:new old:old];
}];
//4.发起查询省的一个Action
Action *fetchCitiesAction = [Action actionWithActionType:FetchCities_Action values:nil];
[_store dispatch:fetchCitiesAction];//3
}
那么在订阅者受到通知后,我们就可以根据newState和oldState来决定UI样式了,这样我们就把对UI的管理集中在了一处,外界只有通过向Store发送Action的形式才能改变State,而State又是唯一决定UI的元素。那么代码的逻辑是不是看起来就比较清晰了。
- (void)stateDidChangeWithNew:(State *)new old:(State *)old{
NSLog(@"old = %@,new = %@",old.description,new.description);
if (old.cities == nil || new.cities != old.cities) {
//这里比较指针就好,因为经过reduce的是两个不同的state,而且属性都是不可变的。
NSIndexSet *set = [[NSIndexSet alloc] initWithIndex:CitiesSection];
[self.tableView reloadSections:set withRowAnimation:UITableViewRowAnimationFade];
}
if (old.histories == nil || new.histories != old.histories) {
//这里比较指针就好,因为经过reduce的是两个不同的state,而且属性都是不可变的。
NSIndexSet *set = [[NSIndexSet alloc] initWithIndex:HistorySection];
[self.tableView reloadSections:set withRowAnimation:UITableViewRowAnimationFade];
}
// [self.tableView reloadData];
//update title
if (new.text == nil) {
self.title = @"省";
} else {
self.title = new.text;
}
}
总结
其实这一套理论和语言本身无关,和UI也没有关系,更像是一种设计思想,我们可以把它用在任何无关UI的地方,有点向游戏设计中的状态机的设计思路。但无论怎样,这样的代码确实比习惯的胶水代码可测试性更强。如果在Demo中我们需要添加一个新的逻辑,我们只要在Action中添加一个新类型,State里面加一个新的数据源,在reduce里面处理,然后在stateDidChangeWithNew:old:
处理,代码的逻辑依然清晰可见。如果喜欢请点个赞,或者star,Have Fun!😄😄😄