讀古今文學網 > Java程序員修煉之道 > 11.2 測試替身 >

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類。