如果你继续用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
获得最初的价格,要调用HttpPrice
的getInitialPrice
方法。
因此每次调用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)标准。简而言之,你可以调用简单的
save
、load
、update
,还有很多其他的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<Ticket> 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类。