美文网首页Des's iOS iOS CollectioniOS进阶
没有单元测试,何谈重构

没有单元测试,何谈重构

作者: 溪石iOS | 来源:发表于2016-11-15 14:54 被阅读4810次

最近科技公司流年不利,那边与整个硅谷唱反调的川普逆袭上台了,这边特斯拉被评为美国最不可靠汽车品牌,据报道是因为特斯拉为Model X增加了过于复杂的功能(高科技多也怪我咯),如前门采用电动开启方式,中排座椅实现了电动移动,所有这些功能整合在一个平台上,导致可靠性下滑。通俗解释下就是电动门有个小bug,电动座椅又有个小bug,一堆小bug最终导致的大bug,人命关天了,本篇就来谈谈软件开发中避免小bug的技术:单元测试。

本文将介绍以下内容:

  1. iOS开发中添加单元测试的方法。
  2. 如何写单元测试用例及用例组。
  3. 介绍单元测试的一些基础概念。

本篇作为重构的例子(想了解重构是什么,另参见他们总在说重构,不过是重写 ),假设了一个视频网站的电影点播系统,每次点击播放就会收取费用,按电影种类不同,时段不同,则收费不同,最终计算出顾客的总消费,并计算积分。这个例子的类关系比较清晰易懂,用OC语言实现,iOS开发的童鞋看起来会比较亲切,心急的童鞋可以跳过源码部分,先看后面添加单元测试的部分准备测试工具,需要了解细节时再回头看源码。

系统包含一个<u>电影类</u>,<u>顾客类</u>,及<u>点播类</u>,类关系如下图所示:


类关系图.png

<u>电影类</u>

//
//  Movie.h
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

typedef NS_ENUM(NSUInteger, MovieEnum) {
    MovieEnumChildrens = 2,
    MovieEnumRegular = 0,
    MovieEnumNewRelease = 1
};

@class Movie;
@interface Movie : NSObject
@property(nonatomic, copy) NSString *title;
@property(nonatomic) int priceCode;

- (id)initWithTitle:(NSString *)title
          priceCode:(int)priceCode;
@end
//
//  Movie.m
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

#import "Movie.h"

@implementation Movie
- (id)initWithTitle:(NSString *)title
            priceCode:(int)priceCode {
    self = [super init];
    if (self) {
        _title = title;
        _priceCode = priceCode;
    }
    return self;
}
@end

<u>点播类</u>:
点播类定义了点播行为,关心点播了什么电影,及点播的时段,这些都影响最终收取的费用。

//
//  Demand.h
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

#import <Foundation/Foundation.h>
typedef NS_ENUM(NSUInteger, TimePeriodEnum) {
    TimePeriodEnumWorkDaytime = 1,
    TimePeriodEnumWorkNight = 2,
    TimePeriodEnumWeekend = 3
};

@class Movie;
@interface Demand : NSObject
@property(nonatomic) Movie *movie;
@property(nonatomic, assign) int timePeriod;

- (id)initWithMovie:(Movie *)movie
         timePeriod:(TimePeriodEnum)timePeriod;
@end
//
//  Demand.m
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

#import "Demand.h"
#import "Movie.h"

@implementation Demand
- (id)initWithMovie:(Movie *)movie
         timePeriod:(TimePeriodEnum)timePeriod {
    self = [super init];
    if (self) {
        _movie = movie;
        _timePeriod = timePeriod;
    }
    return self;
}
@end

<u>顾客类</u>

//
//  Customer.h
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

#import <Foundation/Foundation.h>

@class Demand;
@interface Customer : NSObject
- (id)initCustomerWithName:(NSString *)name;
- (void)addDemand:(Demand *)demand;
- (NSString *)statement;
@end
//
//  Customer.m
//  RefactorDemo
//
//  Created by xishi on 16/10/29.
//  Copyright © 2016年 xs. All rights reserved.
//

#import "Customer.h"
#import "Demand.h"
#import "Movie.h"
@interface Customer () {
    NSString *_name;
    NSMutableArray *_demands;
}
@end
@implementation Customer
- (id)initCustomerWithName:(NSString *)name {
    self = [super init];
    if (self) {
        _name = name;
    }
    return self;
}

- (void)addDemand:(Demand *)demand {
    if (!_demands) {
        _demands = [[NSMutableArray alloc] init];
    }
    [_demands addObject:demand];
}

- (NSString *)statement {
    double totalAmount = 0;
    int frequentDemandPotnts = 0;
    NSMutableString *result = [NSMutableString stringWithFormat:@"%@的点播清单\\\\n", _name];
    for (Demand *aDemand in _demands) {
        double thisAmount = 0;
        
        // 根据不同电影定价:
        switch (aDemand.movie.priceCode) {
            case MovieEnumRegular:
                thisAmount += 2; // 普通电影2元一次
                break;
                
            case MovieEnumNewRelease:
                thisAmount += 3; // 新电影3元一次
                break;
                
            case MovieEnumChildrens:
                thisAmount += 1.5; // 儿童电影1.5元一次
        }
        
        // 根据不同时段定价:
        if (aDemand.timePeriod == TimePeriodEnumWorkDaytime)
            thisAmount *= 1.0; // 工作日全价
        else
            if (aDemand.timePeriod == TimePeriodEnumWeekend) {
                thisAmount *= 0.5; // 周末半价
            }
            else
                if (aDemand.timePeriod == TimePeriodEnumWorkNight){
                    thisAmount *= 1.5; // 下班1.5倍
                }
        
        frequentDemandPotnts++;
        // 周末点播新片积分翻倍:
        if ((aDemand.movie.priceCode == MovieEnumNewRelease) &&
            aDemand.timePeriod == TimePeriodEnumWeekend) {
            frequentDemandPotnts++;
        }
        
        [result appendFormat:@"\\\\t%@\\\\t%@ 元\\\\n", aDemand.movie.title, @(thisAmount)];
        totalAmount += thisAmount;
    }
    
    [result appendFormat:@"费用总计 %@ 元\\\\n", @(totalAmount).stringValue];
    [result appendFormat:@"获得积分 %@", @(frequentDemandPotnts).stringValue];
    
    return result;
}
@end

<p id="jump"></p>

准备测试工具


这里选用的是XCTest,它是Xcode8中内置的测试框架,使用起来非常简单,分以下两种情况为项目添加测试:

1. 新建工程时添加单元测试:

新建时添加单元测试

2.为已有工程添加单元测试

Xcode8中添加的步骤与前几代有所不同:


添加Target 用关键词test快速找到Unit Testing bundle
添加好单元测试后的工程结构

添加第一个测试


第一个测试是很重要的,它决定了我们后面测试的思路和方向,这里以需要什么测什么为指导原则,从结果出发,所以先来看下基本的点播需求:

工作日点播一部普通影片,收费2元,积一分。

根据以上需求描述,我们在RefactorDemoTests.m添加测试方法:

- (void)testStatement_Regular {
    Movie *matrixMovie1 = [[Movie alloc] initWithTitle:@"黑客帝国1"
                                             priceCode:MovieEnumRegular];
    Demand *aDemand1 = [[Demand alloc] initWithMovie:matrixMovie1
                                          timePeriod:TimePeriodEnumWorkDaytime];
    
    // 顾客租赁一部:
    Customer *aCustomer = [[Customer alloc] initCustomerWithName:@"溪石"];
    [aCustomer addDemand:aDemand1];
    
    XCTAssertTrue([@"溪石的点播清单\\\\n"
                   @"\\\\t黑客帝国1\\\\t2 元\\\\n"
                   @"费用总计 2 元\\\\n"
                   @"获得积分 1"
                   isEqualToString:[aCustomer statement]],
                   @"测试点播一部普通电影");
    
}

这个测试用例中,顾客“溪石”点播了一部老片《黑客帝国1》,由于是工作日,因此按原价收取,并积1分,详细细节看Cutomer类源码中的方法statement()。
按快捷键⌘U,运行测试,发现测试报错了:

第一次运行测试报错了

仔细检查发现,statment()的实现中,总价与单位没有空一格,斟酌后觉得还是空一格比较清晰,于是修改后,再次按快捷键⌘U运行测试,测试通过:

测试通过了

在单元测试中,绿色表示测试通过,红色表示测试失败,已经成为业界标准,XCTest遵循了这一规则。

测试用例组


通过第一个例子,我们知道了测试用例总是以test开头,作为约定俗成,凡是test开头的方法,都会被XCTest框架自动运行,下面我们添加对周末点播优惠的测试:

- (void)testStatement_Weekend {
    Movie *matrixMovie2 = [[Movie alloc] initWithTitle:@"黑客帝国2-重装上阵"
                                             priceCode:MovieEnumRegular];
    Demand *aDemand2 = [[Demand alloc] initWithMovie:matrixMovie2
                                          timePeriod:TimePeriodEnumWeekend];
    
    Customer *aCustomer = [[Customer alloc] initCustomerWithName:@"溪石"];
    [aCustomer addDemand:aDemand2];
    XCTAssertTrue([@"溪石的点播清单\\\\n"
                   @"\\\\t黑客帝国2-重装上阵\\\\t1 元\\\\n"
                   @"费用总计 1 元\\\\n"
                   @"获得积分 1"
                   isEqualToString:[aCustomer statement]],
                  @"测试点播一部普通电影,周末半价");
}

这个测试用例除了电影名称不一样外,只是将点播时段由工作日改为了周末,以此判断计算规则是否正确。
这时,我们已经有两个测试用例了,为了加快测试速度,打开Xcode左侧第5项的测试导航面板,可以单独指定一个用例运行,注意图中标记处的图标变化:

单独运行一个测试用例

如此,我们可以将statement需要考虑的返回情况都写成一个个都测试用例(这里就不一一列举了,童鞋们可以自行实现,有问题可以评论中提出,虽然我不一定会回答),可以确保报表算法满足全部需求。

单元测试和功能测试的差别


功能测试的目的是保证整个软件包能正常工作,它面向的对象是客户,保障软件功能符合客户的要求的质量,当然这类工作应该交由喜爱找bug的专业测试部门去处理,他们会用与开发截然不同的工具,并且不关心实现的细节(这就是你与测试人员老是话不投机的原因)。
单元测试关注实现的细节,它的目标对象是一个类,一个方法,是我们开发人员用来验证代码是否有实现异常的工具,因此写单元测试时总是寻找那些可能未处理的边界。

测试循环

从上面的简单用例中,我们能明显看到以下通用步骤:

  1. 准备测试数据。
  2. 调用目标API
  3. 验证输出和行为
测试循环

小结

本文通过一个电影点播系统的例子,演示了以下内容:

  1. iOS开发中添加单元测试框架XCTest。
  2. 用test方法组织单元测试用例及用例组,即可统一运行,也可单独运行。
  3. 介绍单元测试的一些基础概念,了解单元测试的目标,及测试循环。

这些是将来进一步的重构的基础和前提,限于篇幅,仿造对象等单元测试技术还未提及,欢迎关注溪石,且听下回分解。

相关文章

  • 没有单元测试,何谈重构

    最近科技公司流年不利,那边与整个硅谷唱反调的川普逆袭上台了,这边特斯拉被评为美国最不可靠汽车品牌,据报道是因为特斯...

  • iOS文章精选 - 收藏集 - 掘金

    没有单元测试,何谈重构 - iOS - 掘金最近科技公司流年不利,那边与整个硅谷唱反调的川普逆袭上台了,这边特斯拉...

  • APP重构之路(三) 引入单元测试

    APP重构之路(一) 网络请求框架 APP重构之路(二) Model的设计 APP重构之路(三) 引入单元测试 重...

  • Objective-C:写一份可测试的代码

    APP重构之路(一) 网络请求框架 APP重构之路(二) Model的设计 APP重构之路(三) 引入单元测试Ob...

  • Android单元测试--基础

    为什么要使用单元测试 使用单元测试我们可以很容易的发现代码的缺陷同时在你重构代码的时候可以很方便的帮你验证重构是否...

  • 单元测试,是程序员的基本功

    10-11月份,我花了很多时间在项目D的代码重构和单元测试上,这期间重读了《重构》、《单元测试的艺术》和《Mast...

  • Android测试

    标签(空格分隔): Android 单元测试的好处:Martin Fowler在《重构》里面还解释了为什么单元测试...

  • 聊聊单元测试

    本篇主要是聊一聊以下几个方面的内容: 为什么要单元测试 单元测试框架 单元测试的好处 单元测试与重构 1. 为什么...

  • 重构三部曲(一):思想准备篇

    一、概述 重构三部曲为:思想准备,单元测试,重构 思想准备的目的是明确:为什么要重构,重构的理论支撑是什么 单元测...

  • Android单元测试(一)-基础

    一、什么是单元测试 单元测试是测试某个类的某个方法能否正常工作的一种手段。 二、单元测试目的 验收(改动和重构) ...

网友评论

  • leftwater:可以不错不错
  • MacLeon:这是作者的demo,我只是copy了下代码,亲测可用:https://github.com/CoderLeonidas/LeonidasDemos/tree/master/UnitTest:blush:
  • MacLeon:thx very much! 从没接触过单元测试,你的文章是个很好的入门!希望有更多好文章分享:smile:
    溪石iOS:@MacLeon 很高兴能帮到你:stuck_out_tongue_winking_eye:
  • 遇见猫的大鱼:你好,我之前也有写单元测试,但是苦于代码设计不好,很多地方覆盖不到,更别谈足够的用例,尤其是有tableview这些的代理方法里面的部分实现测试不到,还有就是网络请求这种异步的,测试不到里面去,最近找了些第三方框架准备重拾测试,能分享一下这方面经验吗?
    溪石iOS:@遇见猫的大鱼 网络这块可以看我另一篇文章http://www.jianshu.com/p/5358f9c93b71 希望对你有帮助
    遇见猫的大鱼:我找到你的文字了,现在学习一下,说来遗憾,我在这家公司IOS开发一年了,来来去去同事过10多人,但是大家都没有测试的概念,今年准备跳槽,现在好好提升一下
  • 戒惜舍得:感谢大神的给力之作。
    戒惜舍得:@溪石iOS 大神有没有github 中的单元测试的demo ,star 一下~!!
    溪石iOS:@戒惜舍得 不敢当,不敢当
  • CrazySteven:加油,关注后文,测试一直都我的弱项。。。
    溪石iOS:@CrazySteven 从最简单的地方,先用起来:up:
  • 溪石iOS:用起来的小伙伴可以谈谈感想,或者踩到什么坑了吗?
  • 恋猫月亮:我们的重构,一般都是因为换了产品经理🙂
    溪石iOS:@恋猫月亮 请把这篇转给他http://www.jianshu.com/p/bce0fe294655
    恋猫月亮:@溪石 😂产品经理会和我们说,整个app的风格都会不一样,你们直接重构下代码吧!
    溪石iOS:@恋猫月亮 那不是重构
  • 溪石iOS:单元测试算坑:scream:
  • 鲁鲁妹:清晰易懂,非常受益
    溪石iOS:@鲁鲁妹 很高兴你喜欢
  • dongwenbo:不错,学以致用
    溪石iOS:@dongwenbo 用起来,有什么心得欢迎交流
  • a8304ea8f57e:直接复制到新工程可行吗?
    溪石iOS:@林同 有问题来问我
  • 望了不忘:mark,一直想学,但是又觉得很麻烦,看了很受益。
    溪石iOS:@望了不忘 很高兴对你有帮助:kissing_heart:
  • 春泥Fu:给力了,单元测试一直都没怎么用,感觉就是很有用但又用不到,这下学习了,希望继续啊 :+1:
    溪石iOS:@春泥Fu 单元测试,写起来
  • ee240adbbd88:非常不错,求作者继续更
    溪石iOS:http://www.jianshu.com/p/36c5682da0f7 是续篇哦,介绍了仿造对象,欢迎赏阅
  • 折光:有Demo吗?理论上是可行的
    戒惜舍得:@溪石iOS 来个github 众人给你 星星啊!
    溪石iOS:@折光 文中源码均可运行:relaxed:

本文标题:没有单元测试,何谈重构

本文链接:https://www.haomeiwen.com/subject/kzcduttx.html