没有单元测试,何谈重构

ddyb7943 7年前
   <p>最近科技公司流年不利,那边与整个硅谷唱反调的川普逆袭上台了,这边特斯拉被评为美国最不可靠汽车品牌,据报道是因为特斯拉为Model X增加了过于复杂的功能(高科技多也怪我咯),如前门采用电动开启方式,中排座椅实现了电动移动,所有这些功能整合在一个平台上,导致可靠性下滑。通俗解释下就是电动门有个小bug,电动座椅又有个小bug,一堆小bug最终导致的大bug,人命关天了,本篇就来谈谈软件开发中避免小bug的技术:单元测试。</p>    <p>本文将介绍以下内容:</p>    <ol>     <li>iOS开发中添加单元测试的方法。</li>     <li>如何写单元测试用例及用例组。</li>     <li>介绍单元测试的一些基础概念。</li>    </ol>    <p>本篇作为重构的例子,假设了一个视频网站的电影点播系统,每次点击播放就会收取费用,按电影种类不同,时段不同,则收费不同,最终计算出顾客的总消费,并计算积分。这个例子的类关系比较清晰易懂,用OC语言实现,iOS开发的童鞋看起来会比较亲切,心急的童鞋可以跳过源码部分,先看后面添加单元测试的部分,需要了解细节时再回头看源码。</p>    <p>系统包含一个 <u>电影类</u> , <u>顾客类</u> ,及 <u>点播类</u> ,类关系如下图所示:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/65af5c0944abd3705f01915669e80dd9.png"></p>    <p>电影类</p>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <p><u>点播类</u> :</p>    <p>点播类定义了点播行为,关心点播了什么电影,及点播的时段,这些都影响最终收取的费用。</p>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <p>顾客类</p>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <pre>  <code class="language-objectivec">//  //  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</code></pre>    <h2><strong>准备测试工具</strong></h2>    <p>这里选用的是XCTest,它是Xcode8中内置的测试框架,使用起来非常简单,分以下两种情况为项目添加测试:</p>    <h3><strong>1. 新建工程时添加单元测试:</strong></h3>    <p style="text-align:center"><img src="https://simg.open-open.com/show/8a155f3b4100aae6122077fe4e6db644.png"></p>    <p>新建时添加单元测试</p>    <h3><strong>2.为已有工程添加单元测试</strong></h3>    <p>Xcode8中添加的步骤与前几代有所不同:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/9c0a56e65e9f2ee94f88fa13035d51ae.png"></p>    <p>添加Target</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/72ea8b45ea4a22e3a36b63cef84d6de5.png"></p>    <p>用关键词test快速找到Unit Testing bundle</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fbae5c9abdb49ceb86a8f19dfcc8dce5.png"></p>    <p>添加好单元测试后的工程结构</p>    <h2><strong>添加第一个测试</strong></h2>    <p>第一个测试是很重要的,它决定了我们后面测试的思路和方向,这里以 <strong>需要什么测什么</strong> 为指导原则,从结果出发,所以先来看下基本的点播需求:</p>    <p>工作日点播一部普通影片,收费2元,积一分。</p>    <p>根据以上需求描述,我们在 RefactorDemoTests.m 添加测试方法:</p>    <pre>  <code class="language-objectivec">- (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]],                     @"测试点播一部普通电影");    }</code></pre>    <p>这个测试用例中,顾客“溪石”点播了一部老片《黑客帝国1》,由于是工作日,因此按原价收取,并积1分,详细细节看Cutomer类源码中的方法statement()。</p>    <p>按快捷键 ⌘U ,运行测试,发现测试报错了:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4558929a76fe58934ce37e2ddb1de447.png"></p>    <p>第一次运行测试报错了</p>    <p>仔细检查发现,statment()的实现中,总价与单位没有空一格,斟酌后觉得还是空一格比较清晰,于是修改后,再次按快捷键 ⌘U 运行测试,测试通过:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/78ccc724f5f441516a697f559b3b4af4.png"></p>    <p>测试通过了</p>    <p>在单元测试中,绿色表示测试通过,红色表示测试失败,已经成为业界标准,XCTest遵循了这一规则。</p>    <h2><strong>测试用例组</strong></h2>    <p>通过第一个例子,我们知道了测试用例总是以 test 开头,作为约定俗成,凡是test开头的方法,都会被XCTest框架自动运行,下面我们添加对周末点播优惠的测试:</p>    <pre>  <code class="language-objectivec">- (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]],                    @"测试点播一部普通电影,周末半价");  }</code></pre>    <p>这个测试用例除了电影名称不一样外,只是将点播时段由工作日改为了周末,以此判断计算规则是否正确。</p>    <p>这时,我们已经有两个测试用例了,为了加快测试速度,打开Xcode左侧第5项的测试导航面板,可以单独指定一个用例运行,注意图中标记处的图标变化:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d059dcc3fffe00d68bd17b8aa629c993.png"></p>    <p>单独运行一个测试用例</p>    <p>如此,我们可以将statement需要考虑的返回情况都写成一个个都测试用例(这里就不一一列举了,童鞋们可以自行实现,有问题可以评论中提出,虽然我不一定会回答),可以确保报表算法满足全部需求。</p>    <h2><strong>单元测试和功能测试的差别</strong></h2>    <p>功能测试的目的是保证整个软件包能正常工作,它面向的对象是客户,保障软件功能符合客户的要求的质量,当然这类工作应该交由喜爱找bug的专业测试部门去处理,他们会用与开发截然不同的工具,并且不关心实现的细节(这就是你与测试人员老是话不投机的原因)。</p>    <p>而 <strong>单元测试</strong> 关注实现的细节,它的目标对象是一个类,一个方法,是我们开发人员用来验证代码是否有实现异常的工具,因此写单元测试时总是寻找那些可能未处理的边界。</p>    <h2><strong>测试循环</strong></h2>    <p>从上面的简单用例中,我们能明显看到以下通用步骤:</p>    <ol>     <li>准备测试数据。</li>     <li>调用目标API</li>     <li>验证输出和行为</li>    </ol>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d88ace4f7154876f473e4f6271e23b3d.png"></p>    <p>测试循环</p>    <h2><strong>小结</strong></h2>    <p>本文通过一个电影点播系统的例子,演示了以下内容:</p>    <ol>     <li>iOS开发中添加单元测试框架XCTest。</li>     <li>用test方法组织单元测试用例及用例组,即可统一运行,也可单独运行。</li>     <li>介绍单元测试的一些基础概念,了解单元测试的目标,及测试循环。</li>    </ol>    <p>这些是将来进一步的重构的基础和前提,限于篇幅,仿造对象等单元测试技术还未提及,欢迎关注溪石,且听下回分解。</p>    <p> </p>    <p>来自:http://www.jianshu.com/p/0b4fd636ad2c</p>    <p> </p>