如果你繼續用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類。