如果你继续用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类。