首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》11.2 测试替身

关灯直达底部

如果你继续用TDD风格编码,很快就会遇到需要引用(经常是第三方的)依赖项或子系统的情况。在这种情况下,你肯定想把测试代码跟依赖项隔离开,以保证测试代码仅仅针对于实际构建的代码。你肯定还想让测试代码尽可能快速运行。而调用第三方依赖项或子系统(比如数据库)可能会花很长时间,也就是说会丧失TDD快速响应的优势(在单元测试层面尤其如此)。测试替身(test double)就是为解决这个问题而生的。

你在这一节将学会如何用测试替身有效隔离依赖项和子系统,看到使用四种测试替身(虚设、伪装、存根和模拟)的例子。

在最复杂的情况下,也就是测试有外部依赖项(比如分布式服务或网络服务)的代码时,依赖注入技术(见第3章)会和测试替身联手来拯救你,即便是看上去大得吓人的系统,它们也能保你安全无虞。

为什么不用Guice?

如果对第3章还记忆犹新,你应该不会忘了Guice——Java DI框架的参考实现。阅读这一节时你很可能边看边想:“他们怎么不用Guice呢?“简言之,对于这些代码,即便引入像Guice这样简单的框架都显得过于复杂。记住,DI是一项技术。不要纯粹为了使用框架而使用它。

Gerard Meszaros在他的xUnit Test Patterns1(Addison-Wesley Professional,2007)一书中给出了测试替身的简单解释,我们很荣幸能在这里引用他的说法:“测试替身(想一想特技演员)泛指任何出于测试目的替换真实对象的假冒对象。”

1 本书中文版《xUnit测试模式:测试码重构》已由清华大学出版社于2009年出版。——译者注

Meszaros接着定义了四种测试替身,如表11-3所示。

表11-3 四种测试替身

类型描述虚设替身只传递不使用的对象。一般用于填充方法的参数列表存根替身总是返回相同预设响应的对象,其中可能也有些虚设状态伪装替身可以取代真实版本的可用版本(当然在品质和配置上达不到生产环境要求的标准)模拟替身可以表示一系列期望值的对象,并且可以提供预设响应

虽然看起来很抽象,但见到例子你就知道了,它们非常容易理解。让我们先从虚设对象开始讲起。

11.2.1 虚设对象

在这四种测试替身里,虚设对象用起来最容易。记住,它是用来填充参数列表,或者填补那些总也不会用的必填域。大多数情况下,你甚至可以传入一个空对象或null

我们回到剧院门票那个例子中。能估算出一个售票亭带来的收入非常好,但剧院老板考虑得更长远。售出门票和预期收入的模型要做得更好,并且你还听到有人抱怨:随着需求增多,系统越来越复杂了。

你接到一项任务,要对售出票进行跟踪,并且某些票可以打9折。看起来你需要一个带有价格打折方法的Ticket类。你又从TDD循环的失败测试开始了,测试重点是新的getDiscountPrice方法。你知道还需要两个构造方法:一个用于常规价格的门票,一个用于可能会打折的门票。Ticket对象最终需要两个参数:

  • 客户姓名,测试中绝不会用到的String
  • 正常价格,测试中会用到的BigDecimal

你非常确定getDiscountPrice方法肯定不会引用客户姓名,也就是说可以给构造方法传入一个虚设对象(我们用的是固定字符串"Riley"),如代码清单11-8所示。

代码清单11-8 用虚设对象实现的TicketTest

import org.junit.Test;import java.math.BigDecimal;import static org.junit.Assert.*;public class TicketTest {  @Test  public void tenPercentDiscount {    String dummyName = "Riley"; //创建虚设对象    Ticket ticket = new Ticket(dummyName,                               new       BigDecimal("10")); //传入虚设对象    assertEquals(new BigDecimal("9.0"), ticket.getDiscountPrice);  }}  

看到了吧,虚设对象的概念很平常。

为了让你彻底明白这个概念,我们在代码清单11-9中给出了部分实现的Ticket类。

代码清单11-9 用虚设对象测试Ticket

import java.math.BigDecimal;public class Ticket {  public static final int BASIC_TICKET_PRICE = 30; //默认价格  private static final BigDecimal DISCOUNT_RATE =                                 new BigDecimal("0.9"); //默认折扣  private final BigDecimal price;  private final String clientName;  public Ticket(String clientName) {        this.clientName = clientName;        price = new BigDecimal(BASIC_TICKET_PRICE);    }  public Ticket(String clientName, BigDecimal price) {    this.clientName = clientName;    this.price = price;  }  public BigDecimal getPrice {    return price;  }  public BigDecimal getDiscountPrice {    return price.multiply(DISCOUNT_RATE);  }}  

有些开发人员会被虚设对象搞糊涂——他们预期的复杂度并不存在。虚设对象非常直接,它们就是过去为了避免出现NullPointerException的古老对象,只是为了让代码能跑起来。

我们转入下一个测试替身的讨论吧。存根对象(从复杂度来讲)向前迈出了一步。

11.2.2 存根对象

在使用能够做出相同响应的对象代替真实实现的情况下,就会用到存根对象。让我们回到剧院门票价格的例子中,看一下实际应用。

写完Ticket类后,领导给你放了个假。你度完假刚回来,打开邮箱就看到一个bug单,报告说代码清单11-8中的tenPercentDiscount测试时好时坏。你一检查代码库,发现tenPercentDiscount已经被改掉了。现在新写了一个Price接口,而Ticket实例是由该接口的实现类HttpPrice创建的。

经过调查,你又发现一些变化,为了从一个外部网站上的第三方类HttpPricingService获得最初的价格,要调用HttpPricegetInitialPrice方法。

因此每次调用getInitialPrice都会返回不同的价格。此外,它时好时坏还有几个原因,有时是公司防火墙规则变了,有时是第三方网站无法访问了。

所以测试就失败了,测试的目的也不幸受到了污染。记住,你所要的单元测试只是针对打9折的价格。

注意 涉及第三方价格网站调用的情景肯定超出了测试的责任范围。但你可以考虑做一个单独覆盖HttpPrice类和第三方的HttpPricingService的系统集成测试。

在用存根替换HttpPrice类之前,先看一下代码的当前状态,如下面三段代码(代码清单11-10至代码清单11-12)。除了跟Price接口有关的修改,剧院老板的想法也变了,觉得没必要记录是谁买了票,代码如下所示。

代码清单11-10 实现了新需求的TicketTest

import org.junit.Test;import java.math.BigDecimal;import static org.junit.Assert.*;public class TicketTest { //实现了Price的HttpPrice     @Test    public void tenPercentDiscount {      Price price = new HttpPrice;      Ticket ticket = new Ticket(price); //创建Ticket      assertEquals(new BigDecimal("9.0"),                   ticket.getDiscountPrice); //测试可能会失败    }}  

下面是新的Ticket,现在它包括了一个私有类FixedPrice,用来处理价格已知并固定的情况,即不需要从外部源中获取这些信息。

代码清单11-11 实现了新需求的Ticket

import java.math.BigDecimal;public class Ticket {  public static final int BASIC_TICKET_PRICE = 30;  private final Price priceSource;  private BigDecimal faceValue = null;  private final BigDecimal discountRate;  private final class FixedPrice implements Price {    public BigDecimal getInitialPrice {      return new BigDecimal(BASIC_TICKET_PRICE);    }  }  public Ticket {    priceSource = new FixedPrice;    discountRate = new BigDecimal("1.0");  } //修改过的构造方法  public Ticket(Price price) {    priceSource = price;    discountRate = new BigDecimal("1.0");  }  public Ticket(Price price,               BigDecimal specialDiscountRate) { //修改过的构造方法    priceSource = price;    discountRate = specialDiscountRate;  }  public BigDecimal getDiscountPrice {    if (faceValue == null) {     faceValue = priceSource.getInitialPrice; //新的getInitialPrice方法调用    }    return faceValue.multiply(discountRate); //计算没变化  }}  

代码清单11-12 Price接口及其实现HttpPrice

import java.math.BigDecimal;public interface Price {  BigDecimal getInitialPrice;}public class HttpPrice implements Price {  @Override  public BigDecimal getInitialPrice {    return HttpPricingService.getInitialPrice; //返回结果随机  }}  

那么,怎么才能做出跟HttpPricingService一样的响应?关键是想清楚测试的真实意图是什么?在这个例子中,你要测的是Ticket类中getDiscountPrice方法所做的乘法跟预期一致。

因此你可以用总是返回同一价格的存根StubPrice换掉HttpPrice类,以调用getInitialPrice。这样就可以把价格经常变化且时好时坏的HttpPrice类从测试中隔离出去了。使用代码清单11-13中的实现,测试就可以通过了。

代码清单11-13 使用存根对象的TicketTest实现

import org.junit.Test;import java.math.BigDecimal;import static org.junit.Assert.*;public class TicketTest {  @Test  public void tenPercentDiscount {    Price price = new StubPrice; //StubPrice存根    Ticket ticket = new Ticket(price); //创建Ticket    assertEquals(9.0,                 ticket.getDiscountPrice.doubleValue,                 0.0001); //检查价格  }}  

StubPrice是个简单的小类,返回的初始价格总是10,如代码清单11-14所示。

代码清单11-14 存根StubPrice

import java.math.BigDecimal;public class StubPrice implements Price {  @Override  public BigDecimal getInitialPrice {    return new BigDecimal("10"); > //返回同一价格  }}  

咻!现在测试又能通过了,重要的是你又可以毫不畏惧地重构剩下的实现细节了。

存根是种挺实用的测试替身,但有时候我们会希望存根所做的工作可以尽可能地接近生产系统,这时可以用伪装替身。

11.2.3 伪装替身

伪装对象可以看做是存根的升级,它所做的工作几乎和生产代码一样,但为了满足测试需求会走些捷径。如果你想让代码的运行时环境非常接近生产环境(连接真实的第三方子系统或依赖项),伪装替身特别有用。

大部分Java开发人员迟早都要编写跟数据库交互的代码,特别是在Java对象上执行CRUD操作。在DAO(Data Access Object,数据访问对象)代码跟生产数据库连接之前,证明其可用的工作通常会留到系统集成测试阶段,或者根本就不做检查!如果能在单元测试或集成测试阶段对DAO代码进行检查,那将会有很多好处,最重要的是你能快速响应。

在这种情况下可以用伪装对象:用来代表跟你交互的数据库。但自己写一个代表数据库的伪装对象相当困难!好在经过数年的演进,内存数据库的轻巧易用已经足以胜任这一工作。HSQLDB(www.hsqldb.org)是广泛用于这一用途的内存数据库。

剧院门票应用进展良好,下一阶段的工作就是把门票保存在数据库中,以便后期获取。Java中最常用的数据库持久化框架是Hibernate(www.hibernate.org)。

Hibernate与HSQLDB

如果你不了解Hibernate或HSQLDB,请不要惊慌!Hibernate是一个对象关系映射(ORM)框架,实现了Java持久化API(JPA)标准。简而言之,你可以调用简单的saveloadupdate,还有很多其他的Java方法来执行CRUD操作。这和用原始的SQL和JDBC不同,并且它经过抽象隔离了特定数据库的语法和语义。

HSQLDB只是个Java内存数据库。只要把hsqldb.jar放到你的CLASSPATH下就可以用了。尽管在关闭之后数据会全部丢失,但它的表现跟一般的RDBMS很像。(其实数据是可以保存下来的,请访问HSQLDB的网站了解更多细节。)

虽然我们可能又扔给你两项新技术,但随书源码中的构建脚本会帮你把正确的JAR依赖项和配置文件放到正确的地方。

首先,你需要一个Hibernate配置文件来定义到HSQLDB数据库的连接,如代码清单11-15所示。

代码清单11-15 用于HSQLDB的Hibernate配置文件

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd"><hibernate-configuration>   <session-factory>      <property name="hibernate.dialect">        org.hibernate.dialect.HSQLDialect //设置方言        </property>      <property name="hibernate.connection.driver_class">        org.hsqldb.jdbcDriver      </property>      <property name="hibernate.connection.url">        jdbc:hsqldb:mem:wgjd //指定要连接的URL      </property>      <property name="hibernate.connection.username">sa</property>      <property name="hibernate.connection.password" />      <property name="hibernate.connection.autocommit">true</property>      <property name="hibernate.hbm2ddl.auto">        create      </property> //自动创建数据表      <property name="hibernate.show_sql">true</property>      <mapping resource="Ticket.hbm.xml" /> //映射Ticket类❶   </session-factory></hibernate-configuration>  

你应该注意到了,清单中的最后一行语句引用了Ticket类的映射资源(<mapping resource="Ticket.hbm.xml"/>)❶。这个资源会告诉Hibernate怎么把Java文件映射到数据库列。在Hibernate配置文件里,除了方言(HSQLDB),还有所有Hibernate需要用来在幕后自动构建SQL的信息。

尽管Hibernate允许你在Java类里直接用注解添加映射信息,但我们还是更喜欢下面这种XML映射方式,如代码清单11-16所示。

警告 注解跟XML映射之间的选择之战在邮件列表中已经打了很久了,所以你最好选个自己喜欢的,然后就由它去吧。

代码清单11-16 用于Ticket的Hibernate映射文件

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd"><hibernate-mapping>   <class name="com.java7developer.chapter11.listing_11_18.Ticket"> //标出要映射的类      <id name="ticketId"           type="long"           column="ID" /> //指定ticketId为关键字      <property name="faceValue"                 type="java.math.BigDecimal"                 column="FACE_VALUE"                 not-null="false" /> //faceValue映射      <property name="discountRate"                 type="java.math.BigDecimal"                 column="DISCOUNT_RATE"                 not-null="true" /> //discountRate映射   </class></hibernate-mapping>  

弄完配置文件,该想想测什么了。用唯一ID获取Ticket是业务需要。为了满足这一业务(和Hibernate映射)要求,必须将Ticket类改成代码清单11-17这样。

代码清单11-17 带有ID的Ticket

import java.math.BigDecimal;public class Ticket {  public static final int BASIC_TICKET_PRICE = 30;  private long ticketId; //加上ID  private final Price priceSource;  private BigDecimal faceValue = null;  private BigDecimal discountRate;  private final class FixedPrice implements Price {    public BigDecimal getInitialPrice {       return new BigDecimal(BASIC_TICKET_PRICE);    }  }  public Ticket(long id) {    ticketId = id;    priceSource = new FixedPrice;    discountRate = new BigDecimal("1.0");  }  public void setTicketId(long ticketId) {    this.ticketId = ticketId;  }  public long getTicketId {    return ticketId;  }  public void setFaceValue(BigDecimal faceValue) {    this.faceValue = faceValue;  }  public BigDecimal getFaceValue {    return faceValue;  }  public void setDiscountRate(BigDecimal discountRate) {    this.discountRate = discountRate;  }  public BigDecimal getDiscountRate {    return discountRate;  }  public BigDecimal getDiscountPrice {    if (faceValue == null) faceValue = priceSource.getInitialPrice;    return faceValue.multiply(discountRate);  }}  

现在Ticket的映射有了,Ticket类也改过了,可以调用TicketHibernateDao里的findTicketById方法进行测试了。哦,还要写JUnit测试设置的准备代码,如代码清单11-18所示:

代码清单11-18 TicketHibernateDaoTest测试类

import java.math.BigDecimal;import org.hibernate.cfg.Configuration;import org.hibernate.SessionFactory;import org.junit.*;import static org.junit.Assert.*;public class TicketHibernateDaoTest {  private static SessionFactory factory;  private static TicketHibernateDao ticketDao;  private Ticket ticket;  private Ticket ticket2;  @BeforeClass  public static void baseSetUp {    factory =      new Configuration.          configure.buildSessionFactory;    ticketDao = new TicketHibernateDao(factory);   } //❶使用Hibernate配置  @Before  public void setUpTest  {    ticket = new Ticket(1);    ticketDao.save(ticket);    ticket2 = new Ticket(2);    ticketDao.save(ticket2);  } //❷设置测试`Ticket`的数据  @Test  public void findTicketByIdHappyPath throws Exception {    Ticket ticket = ticketDao.findTicketById(1);    assertEquals(new BigDecimal("30.0"),    ticket.getDiscountPrice);  } //❸找到Ticket   @After  public static void tearDown {    ticketDao.delete(ticket);    ticketDao.delete(ticket2);  } //清除数据  @AfterClass  public static void baseTearDown {    factory.close; //关闭  }}  

在运行任何测试之前,先用Hibernate的配置创建所要测试的DAO❶。然后,在每个测试运行之前,都在HSQLDB数据库里存两条门票的记录(作为测试数据)❷。运行测试,测试DAO的 findTicketById方法❸。

因为你还没写TicketHibernateDao类及其方法,所以测试一开始会失败。使用Hibernate框架不需要SQL,也不需要提及用的是HSQLDB数据库。因此,DAO的实现应该和代码清单11-19类似。

代码清单11-19 TicketHibernateDao

import java.util.List;import org.hibernate.Criteria;import org.hibernate.Session;import org.hibernate.SessionFactory;import org.hibernate.criterion.Restrictions;public class TicketHibernateDao {  private static SessionFactory factory;  private static Session session;  public TicketHibernateDao(SessionFactory factory)  {    TicketHibernateDao.factory = factory;    TicketHibernateDao.session = getSession;  } //设置工厂和会话   public void save(Ticket ticket)  {    session.save(ticket);    session.flush;  } //❶保存Ticket  public Ticket findTicketById(long ticketId)  {    Criteria criteria =    session.createCriteria(Ticket.class);    criteria.add(Restrictions.eq("ticketId", ticketId));    List&lt;Ticket&gt; tickets = criteria.list;    return tickets.get(0);  } //❷使用ID查找Ticket  public void delete(Ticket ticket) {    session.delete(ticket);    session.flush;  }    private static synchronized Session getSession {    return factory.openSession;  }}  

DAO的save方法特别不起眼,就是调用Hibernate的save方法,然后用flush确保对象能存到HSQLDB数据库中❶。要取出Ticket,可以用Hibernate的Criteria(相当于SQL里的WHERE从句)❷。

写完DAO之后,测试就能通过了。你可能已经注意到了,save方法也已经被部分测试到了。你可以继续写更加完整的测试,比如检查一下从数据库中取回的票是否带有正确的discountRate。现在可以提前测试数据库访问代码了,所以数据库访问层也得到了TDD方式的所有好处。

我们接着讨论下一个测试替身:模拟对象。

11.2.4 模拟对象

模拟对象跟前面提过的存根对象是亲戚,但存根对象一般都特别呆。比如在调用存根时它们通常总是返回相同的结果。所以不能模拟任何与状态相关的行为。

看个例子:假设你想用TDD方式写一个文本分析系统。其中一个单元测试要求文本分析类对某篇博文中出现的“Java 7”进行计数。但这篇博文是第三方资源,所以很多失败都跟你写的计数算法没太大关系。换句话说,测试代码不是孤立的,并且获取第三方资源可能很费时间。下面是一些很常见的失败:

  • 由于防火墙限制,你的代码可能无法访问互联网上的这篇博文;
  • 这篇博文可能被挪走了,而链接没有重定向;
  • 博文可能被编辑过,“Java 7”出现的次数可能增加了,也可能减少了。

用存根几乎不可能把这个测试写出来,即便能写也极其繁琐,模拟对象此时登场。这是一种特殊的测试替身,你可以把它当做可以预编程的存根或超级存根。使用模拟对象非常简单:在准备要用的模拟对象时,告诉它预计会有哪些调用,以及每个调用该如何响应。模拟会跟DI结合得很好,你可以用它注入一个虚拟的对象,这个对象将完全按照已知方式行动。

让我们看一个剧院门票的例子。我们会用一个流行的模拟类库Mockito(http://mockito.org/),请看代码清单11-20。

代码清单11-20 用于剧院门票的模拟对象

import static org.mockito.Mockito.*;import static org.junit.Assert.*;import java.math.BigDecimal;import org.junit.Test;public class TicketTest {  @Test  public void tenPercentDiscount {    Price price = mock(Price.class); //❶创建模拟对象    when(price.getInitialPrice). thenReturn(new BigDecimal("10")); //❷对模拟对象编程以便进行测试    Ticket ticket = new Ticket(price, new BigDecimal("0.9"));    assertEquals(9.0, ticket.getDiscountPrice.doubleValue, 0.000001);    verify(price).getInitialPrice;  }}   

创建模拟对象需要调用静态的mock方法❶,并将模拟目标类型的class对象作为参数传给它。然后要把模拟对象需要表现出来的行为记录下来,通过调用when方法表明要记录哪些方法的行为,然后用thenReturn指定所期望的结果是什么❷。最后要证实在模拟对象上调用了预期的方法。这是为了确保你的正确结果不是经由不正确的路径得到的。

你可以像使用常规对象那样使用模拟对象,并且无需任何其他步骤就可以把它传给你调用的Ticket构造方法。这使得模拟对象成为了TDD的得力工具,有些从业者实际上更喜欢所有事情都用模拟对象来做,完全放弃了其他测试替身。

不管你是不是选择这种“最模拟”的TDD风格,完整的测试替身(需要的话加上一点DI)知识会让你毫不畏惧地进行重构和编码,即便面对复杂的依赖和第三方子系统也不怕。

Java开发人员会发现TDD的工作方式非常容易上手。但Java经常伴随着一个反复出现的问题——有些繁琐。在纯粹的Java项目中用TDD会导致大量的套路化代码。好在现在你已经学了一些其他的JVM语言,能用它们做出更精炼的TDD。实际上,从测试开始将非Java语言带入项目中是推动多语言项目的经典方式之一。

在下一节中,我们会讨论ScalaTest,这个测试框架具有广泛的测试用途。我们会从介绍ScalaTest开始,并会向你展示如何用它运行JUnit测试来测试Java类。