閱讀955 返回首頁    go 阿裏雲 go 技術社區[雲棲]


寫有價值的單元測試

這是寫給開發同學係列文檔中的一篇,主要講單元測試。

寫這個係列的原因是發現開發同學,尤其是偏業務的開發同學對於軟件開發中的很多實踐和理論理解的不夠清楚。比如設計文檔,代碼評審,單元測試,集成測試和自動化測試,持續集成和持續發布這樣一些耳熟能詳的概念,說起來每個開發同學都聽過,但很多人並沒有深入考慮過為什麼要引入這些實踐,實踐需要哪些手段,要達到什麼目的,要堅持什麼原則?所以這些實踐落地的過程也是千差萬別,效果往往也不甚理想。

通過這一係列文檔,我會把我所了解的每個實踐的來源、適用範圍和價值用最簡明的方式寫出來,並結合具體的開發環境提供一些具體的操作手段,幫助同學們按照正確的路徑快速了解和上手。


理論基礎

大數據就像青少年談性,每個人都在說,但不知道誰做了。每個人認為另外人在做,所以每個人都聲稱自己在做。

這是大數據火的時候調侃大數據的段子,套在單元測試上也一樣適用。說起來單元測試是開發應該了解的基礎概念,然而實際並非如此。

我在若幹團隊(不限於淘寶)作過以下的調查,結果基本類似:

  • 有多少人寫過單元測試? 60% ~ 80%,視團隊而定
  • 每個單元測試裏麵都有至少一個assert語句,而非使用System.out.println? 20% ~ 30%
  • 單元測試的行覆蓋率超過50%? < 10%
  • 每個單元測試平均每天會被執行10次以上? < 5%
  • 所有單元測試**一定不會因為斷網失敗**? < 1%

大多數開發都認為自己寫了單元測試,但大家寫的"單元測試"都不相同。實際上,能達到最後一點要求的單元測試才是 有價值的單元測試 ,其它所謂的"單元測試"最多隻能算是"測試代碼"甚至"代碼片段"。

"單元測試"和"測試代碼"的區別就像鑽石和煤炭,雖然都由碳元素(程序代碼)構成,但前者價值遠遠大於後者。隨著時間的流逝,"鑽石恒久遠,一顆永流傳",而煤炭隻會被燒掉,變成二氧化碳;同樣,隨著項目的不斷演進,"單元測試"能一直存在並發揮價值,而"測試代碼"隻會被一行行注釋掉,直到某個接手的同學把一個全被注釋掉的測試文件從項目中刪除。

要理解這一切發生的原因,首先我們需要了解軟件開發中的幾個定律:

錯誤率恒定定律

程序員的錯誤產出比是個常數

對某一個程序員來說,實現相同功能會犯的錯誤(BUG)是固定的,不受程序員自身意願影響,不受績效影響,也不受項目緊急程度影響。不考慮程序員水平的成長,錯誤產出比在很長一段時間(每個項目的間隔)內可以認為是個常數。

這個定律告訴我們,如果一個程序員感覺今天狀態很好,比昨天多寫了一倍代碼並向QA聲稱bug數肯定比昨天還少,而QA測試的結果居然真的如此,那很大可能是QA測的不夠仔細,而不是程序員的代碼水平一夜之間突飛勐進。

這個定律還告訴我們,TL開會時要求程序員寫代碼盡量不要犯錯沒有任何意義,這種要求就像要求程序員明天長高5公分一樣,不具有可操作性。

錯誤率恒定定律決定了錯誤數是一定的,但並不意味著這些錯誤產生的影響是一定的。恰恰相反,不同開發方式中,這些錯誤帶來的影響差別非常巨大。菜鳥和老手完成相同功能發生的錯誤可能隻差5倍,在具體項目實踐中帶來的影響卻可能差了20倍、50倍甚至更多。原因就是以下的規模代價平方律

規模代價平方定律

定位並修複一個BUG所需的代價正比於目標代碼規模的平方

如果一個20行的函數剛寫完時作者就能發現有BUG,找到並修複這個BUG可能隻需要1分鍾,並且不擔心影響其它使用者。如果是在200行的一個類中,別人調用時發現有BUG,閱讀代碼並定位問題可能就需要一個小時,對這個問題的修複重新代碼評審又要花一個小時。如果在係統和係統聯調的時候才發現這個問題,前麵扯皮就要半天,改完了重新回歸又是半天。如果改這個BUG的人已經不是原作者的話,往往擔心改了這個BUG又引入其它問題,於是修改方案就要拖一群人討論半天,最終改完了要求QA作大範圍的回歸,結果還是還不放心。

規模代價平方律是很多軟件工程實踐的核心思想。根據平方律,為了減少錯誤修複的成本,要盡可能早的發現錯誤,在盡量小的範圍內定位並修複錯誤。由於這是一個平方律而非線性率,所有這方麵的努力都是非常劃算的。比如以下實踐很大程度上就是為了盡早發現錯誤,以後有機會我再逐一介紹這些實踐。

  • 設計評審
  • 代碼評審
  • 單元測試
  • 自動化測試
  • 結對編程
  • (Scrum)小迭代,迭代後期的成果演示

規模代價平方律對程序員的重要性可以和牛頓三定律在初等物理中的地位媲美 。遺憾的是很多程序員寫了很多年都不知道這個定律律,往往低估了錯誤修複的時間。所以業界對程序員的自我評估有如下經典的吐槽

當一個程序員宣稱他已經完成了90%的工作時,他的意思是還需要相同的時間來完成剩下10%的工作

單元測試的目的

錯誤率恒定律告訴我們錯誤是不可避免的,而規模代價平方律告訴我們要盡早發現錯誤。單元測試作為一個行之有效的工程實踐,目的隻有一個

單元測試的目的是盡早在盡量小的範圍內暴露錯誤

不同項目的單元測試方案各有不同,各種方案的選型往往也會有爭議。這時候一定要記住單元測試的目的。凡是利於此目的,即使複雜一些的方案或有一定學習成本也可以采用,凡是不利於此目的的方案,即使看起來很美也沒有采用的必要(本文最後有幾個單測的誤區具體說明這點)

單測的要求

為了達到 盡早盡量小的範圍 以及 暴露錯誤,對單測有以下要求。 實踐證明,隻有滿足這些要求的單測才能實現單測的目的。

單測要能報錯

有些同學不喜歡用Assert,而喜歡在test case中寫個System.out.println,人肉觀察一下結果,確定結果是否正確。這種寫法根本不是單測,原因是即使當時被測試代碼是正確的,後續這些代碼還有可能被修改,而一旦這些代碼被改錯了。println根本不會報錯,測試正常通過隻會帶來虛假的自信心,這種所謂的"單測"連暴露錯誤的作用都起不到,根本就不應該存在。

單測要有強度

有些同學寫的測試裏麵會有Assert,但用的很少,往往隻是在最後用一個assertNotNull(result),這樣的測試強度是不夠的。舉個例子,假設有以下的待測方法

        public class User{
              public int id;
              public String name;
        }

        //待測試的方法,位於UserDAO中

        /**
        * 根據用戶名模煳查找用戶,雙向模煳
        **/
        public List<User> findUserByFuzzyName(String fuzzyName){
              //實現
        }

以下的測試用例強度就太差了,這個用例雖然也用了Assert,但對測試的結果校驗很弱,即沒有校驗結果中有多少User,也沒有校驗雙向模煳邏輯是否正確實現了。實際上即使查詢結果是空,返回的也是個empty list,測試用例還是不會報錯。


        // 數據準備,假設測試庫中已經有了 tom tommy jerry 三個用戶

        @Test
        public void testFindUserByFuzzyName(){
            List<User> users= userDAO.findUserByFuzzyName("tom");
            Assert.assertNotNull(users);
        }


單測要能反應函數的明確需求才算有強度。這樣以後 函數的實現一旦被改錯了單測才能盡快報錯, 針對以上這個例子,單測至少要達到以下強度


        // 數據準備,假設測試庫中已經有了 tom tommy jerry 三個用戶

        @Test
        public void testFindUserByFuzzyName(){
            //左模煳
            List<User> users= userDAO.findUserByFuzzyName("tom");
            Assert.assertEquals(2,users.size());
            Assert.assertEquals("tom", users.get(0).name);
            Assert.assertEquals("tommy", users.get(1).name);

            //右模煳
            List<User> users= userDAO.findUserByFuzzyName("y");
            Assert.assertEquals(2,users.size());
            Assert.assertEquals("tommy", users.get(0).name);
            Assert.assertEquals("jerry", users.get(1).name);
        }

單測要有覆蓋度

強度是指單元測試中對結果的驗證要全麵,覆蓋度則是指測試用例本身的設計要覆蓋被測試程序(SUT, Sysem Under Test)盡可能多的邏輯。隻有覆蓋度和強度都比較高才能較好的實現單測的目的。

按照測試理論,SUT的覆蓋度分為方法覆蓋度,行覆蓋度,分支覆蓋度和組合分支覆蓋度幾類。不同的係統對單測覆蓋度的要求不盡相同,但這是有底線的。一般來說,程序配套的 單測至少要達到>80%的方法覆蓋以及>60%的行覆蓋 ,才能起到"看門狗"的作用,也才是有維護價值的單測。

等價類劃分可以幫助我們用更少的測試代碼寫出更高覆蓋度的單測。 單元測試是典型的白盒測試 ,等價類的劃分以及單元測試的編寫最好都由SUT的編寫者自己去完成,這樣整體效率最高。

單測粒度要小

和集成測試不同,單元測試的粒度一定要小,隻有粒度小才能在出錯時盡快定位到出錯的地點。單測的粒度最大是類,一般是方法。**單測不負責檢查跨類或者跨係統的交互邏輯** , 那都是集成測試的範圍。

通俗的說,程序員寫單測的目的是"擦好自己的屁股",把自己的代碼從實現中隔離出來,在集成測試前先保證自己的代碼沒有邏輯問題。至於集成測試乃至其它測試中暴露出來的接口理解不一致或者性能問題,那都不在單元測試的範圍內。

單測要穩定

單元測試通常會被放到持續集成(CI)中,每次有代碼check in時單元測試都會被執行。如果單測依賴有對外部環境(網絡、服務、中間件)的依賴,任何一次網絡抖動或者返回的變化都會造成單測失敗進而造成持續集成的失敗。這會造成整個持續集成有大量誤報,進而導致持續集成機製的不可用。所以 單測不能受到外界環境的影響

為了不受外界環境影響,要求設計代碼時就把SUT的依賴改成注入,在測試時用spring或者guice這樣的DI框架注入一個本地(內存)實現或者Mock實現。用這種方法保證在SUT出錯時單測才會報錯,持續集成才能更穩定,單測的失敗也才更重要。

單測速度要快

作為"看門狗",最好是在每次代碼有修改時都運行單元測試,這樣才能盡快的發現問題。這就要求單元測試的運行一定要快。一般要求 單個測試的運行時間不超過3秒 , 而整個項目的單測時間控製在3分鍾之內,這樣才能在持續集成中盡快暴露問題。

單測不僅僅是給持續集成跑的,跑測試更多的是程序員本身, 單測速度和程序員跑單測的意願成反比 ,如果單測隻要5秒,程序員會經常跑單測,去享受一下全綠燈的滿足感,可如果單測要跑5分鍾,能在提交前跑一下單測就不錯了。

實際上,上一條要求將單測的外部依賴全部改成本地實現或者Mock,除了係統穩定性外,執行速度也是考量之一。改成本地實現或者Mock後,絕大多數單測運行的時間都非常快,基本上可以說是瞬間就能跑完。

單元測試的方案

明確了單測的目標之後,單測方案的選型也比較明確了。原則就是 本地化,選型上也盡量以內存方案為主。

下麵我們以Spring boot開發為例,給出一套解決方案,以下代碼都是以Spring Annotation Configuration給出的,如果有必要也可以換成XML

數據庫測試

數據庫測試多用在DAO中,DAO對數據庫的操作依賴於mybatis的sql mapper 文件,這些sql mapper多是手工寫的,在單測中驗證所有sql mapper的正確性非常重要,在DAO層有足夠的覆蓋度和強度後,Service層的單測才能僅僅關注自身的業務邏輯。

為了驗證sql mapper,我們需要一個能實際運行的數據庫。為了提高速度和減少依賴,可以使用內存數據庫。內存數據庫和目標數據庫(MySQL,TDDL)在具體函數上有微小差別,不過隻要使用標準的SQL 92,兩者都是兼容的。下麵的方案中就使用 H2 作為單測數據庫。

數據庫單測方案需要解決3個問題:

  • Schema的初始化和同步
  • 每個測試完成後的數據清除
  • 調試過程查看數據庫內容

下麵的方案中對這3個問題都給出了方法

  • 在pom中引入H2的依賴
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.190</version>
    <scope>test</scope>
</dependency>
  • 在測試資源目錄 src/test/resource 下新建 your_module.sql,其中的內容是需要初始化的建表語句,這些建表語句可以從idb中導出。如果表結構發生了更改,需要人工重新導出。 image
create table something(
    id bigint unsigned  not null auto_increment comment '主鍵',
    gmt_create datetime  not null comment '創建時間',
    gmt_modified datetime  not null comment '修改時間',

    primary key (id)
)  comment='something';
  • 創建DAO接口,注意這裏不用寫實現。隻要按照一定的規範,可以動態生成所有DAO的實現,自動映射到相應的sql mapper中
package com.taobao.daren.service.qualification.server.domain.dao;

import com.taobao.daren.service.qualification.server.domain.entity.HSFDataSourceMeta;

/**
 * @author lotus.jzx
 */
public interface HSFDataSourceMetaDao {

    /**
     * 生成一個新的數據源元信息
     *
     * @param name    名稱
     * @param group   HSF組別
     * @param version HSF版本
     *
     * @return ID
     */
    Long insertHSFDataSourceMeta(String name, String group, String version);
}

  • 在程序的資源目錄src/main/resources/sqlmapper中放置對應的sqlmapper文件,文件名最好和DAO對應,以便人工查找,比如這裏對應的sqlmapper就叫HSFDataSourceMetaDao.xml。注意!為了實現自動映射, mapper的namespace一定要和DAO的類名相同

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd" >
    <mapper namespace="com.taobao.daren.service.qualification.server.domain.dao.HSFDataSourceMetaDao">
    
    <resultMap 
               type="com.taobao.daren.service.qualification.server.domain.entity.HSFDataSourceMeta">
        <id column="id" property="id"/>
        <result column="gmt_create" property="gmtCreate"/>
        <result column="gmt_modified" property="gmtModified"/>
        <result column="name" property="name"/>
        <result column="is_active" property="active"/>
        <result column="service_group" property="group"/>
        <result column="service_version" property="version"/>
    </resultMap>
    
    <select  parameterType="Long" resultMap="HSFDataSourceMetaResultMap">
        select
        id,
        name,
        gmt_create,
        gmt_modified,
        service_group,
        service_version,
        is_active
        from daren_qualification_hsf_datasource_meta
        where id=#{id}
    </select>
    </mapper>
    
  • 在測試目錄 src/test/java/com/taobao/.../your_module/config 下新建測試配置

    
    @Configuration
    @ComponentScan({"com.taobao.your_modle"})
    public class BaseTestBeanConfig{
    
    @Bean(name = QualificationBeanConfig.QUALIFICATION_DATASOURCE)
    public DataSource dataSource() {
        EmbeddedDatabaseBuilder databaseBuilder = new EmbeddedDatabaseBuilder();
    
        EmbeddedDatabase db = databaseBuilder
                .setType(EmbeddedDatabaseType.H2)
                        //啟動時初始化建表語句
                .addScript("classpath:schema/qualification.sql")
                .build();
    
        return db;
    }
    
     @Bean(name = "h2WebServer", initMethod = "start", destroyMethod = "stop")
    //啟動一個H2的web server, 調試時可以通過localhost:8082訪問到H2的內容
    //JDBC URL: jdbc:h2:mem:testdb
    //User Name: sa
    //Password: 無
    //注意如果使用斷點,斷點類型(Suspend Type)一定要設置成Thread而不能是All,否則web server無法正常訪問!
    public Server server() throws Exception {
        //在8082端口上啟動一個web server
        Server server = Server.createWebServer("-web", "-webAllowOthers", "-webDaemon", "-webPort", "8082");
        return server;
    }
    
    @Bean(name = QualificationBeanConfig.QUALIFICATION_SQL_SESSION_FACTORY_BEAN)
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        //加載所有的sqlmapper文件
        Resource[] mapperLocations = resolver.getResources("classpath*:sqlmapper/**/*.xml");
        sessionFactory.setMapperLocations(mapperLocations);
        return sessionFactory.getObject();
    }
    
    @Bean(name = QualificationBeanConfig.QUALIFICATION_MAPPER_SCANNER_CONFIGURER)
    public MapperScannerConfigurer mapperScannerConfigurer() {
        //隻需要寫DAO接口,不用寫實現類,運行時動態生成代理
        MapperScannerConfigurer configurer = new MapperScannerConfigurer();
        configurer.setBasePackage("com.taobao.daren.service.qualification.server.domain.dao");
        configurer.setSqlSessionFactoryBeanName(QualificationBeanConfig.QUALIFICATION_SQL_SESSION_FACTORY_BEAN);
        return configurer;
    }
    
    }
    
  • 寫一個數據庫單測的基類,後續的數據庫單測繼承這個基類即可

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(BaseTestBeanConfig.class)
//每次跑完測試方法後清空數據庫
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public abstract class BaseTest {

    @Autowired
    protected DataSource dataSource;

    /**
     * Execute SQL directly, these SQL may be for test purpose only and should not be included in mybatis sqlMapper
     * configuration files.
     *
     * @param sql SQL statement to execute, variable binding is NOT supported for simplicity.
     */
    protected void execute(String sql) {
        try {
            dataSource.getConnection().createStatement().execute(sql);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 然後就可以寫數據庫單測了

    /**
    * @author lotus.jzx
    */
    public class QualificationDaoTest extends BaseTest {
    
    @Autowired
    private QualificationDao qualificationDao;
    
    @Test
    public void testFindQualificationItemMetaById() {
        String sql = "insert into daren_qualification("
                + "id"
                + ",gmt_create"
                + ",gmt_modified"
                + ",root_item_id"
                + ")"
                + " values ("
                + "1"
                + ",NOW()"
                + ",NOW()"
                + ",10"
                + ")";
        execute(sql);
    
        Assert.assertNull(qualificationDao.findQualificationById(-1L));
        Qualification qualification = qualificationDao.findQualificationById(1L);
    
        Assert.assertEquals(1, qualification.getId().intValue());
        Assert.assertNotNull(qualification.getGmtCreate());
        Assert.assertNotNull(qualification.getGmtModified());
        Assert.assertEquals(10, qualification.getRootId().intValue());
        Assert.assertNull(qualification.getRoot());
    }
    }
    

到此為止搭建了一整套基於H2的數據庫單測方案。這套方案基於內存,沒有對外部的依賴,可以在單機上飛快的跑完 mvn clean test。

對於前文提到的三個問題,這個方案是這麼解決的

  • Schema的初始化和同步

    通過離線的方式人工同步Schema至src/test/resources/sqlmapper目錄。這步目前還不夠自動化。如果idb能開放接口,這個步驟也可以通過腳本一鍵完成,當然目前還是比較人肉的。

  • 每個測試完成後的數據清除

    在BaseTest中@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)這行會自動完成數據清除,寫test case時可以認為表都是空的。
    而且我們采用的是內存表,非持久化,所以不存在持久化數據對後續單測的影響

  • 調試過程查看數據庫內容

    在測試配置中有一個h2WebServer,在測試用例運行的過程,可以訪問本地server,通過H2 web console用如下配置查看數據庫中的數據。
    JDBC URL: jdbc:h2:mem:testdb
    User Name: sa
    Password: 無

image

image

要注意的是,如果在測試用例中加了斷點,一定要把斷點類型從缺省的"All"改成為“Thread”,否則H2 web console會被阻塞。

image

其它外部依賴

對於除數據庫以外的依賴,包括Tair, Diamond,Nofity等中間件以及外部的HSF/HTTP服務,在單測中全部采用Mock進行解耦。具體的Mock框架采用 easymock,主要是考慮到它的語法相對自然,同時支持對class的mock。

是否使用mock的爭議一直很大,反對者認為mock要額外寫很多代碼,同時mock通過並不能保證線上工作正常,而支持者認為mock速度快並且穩定,這就是最大的作用。而我們覺得在單元測試中mock外部依賴還是合理的。一方麵單元測試的目的是"擦好自己的屁股",對接口的理解錯誤應該在(自動化)集成測試而不是單元測試中去檢測,另一方麵mock的使用範圍僅僅是邊界上的外部依賴,其使用還是可控的。

下麵以一個例子說明如何用easymock寫單元測試

這是需要測試的代碼


package com.jinlo.springbootdemo.demo;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;

/**
 * 根據黑名單和白名單確認用戶是否能訪問
 */
public class AccessControlService {

    private UICService UICService;

    private BlackListProvider blackListProvider;

    private WhiteListProvider whiteListProvider;

    @Autowired
    public void setUICService(UICService UICService) {
        this.UICService = UICService;
    }

    @Autowired
    public void setBlackListProvider(BlackListProvider blackListProvider) {
        this.blackListProvider = blackListProvider;
    }

    @Autowired
    public void setWhiteListProvider(WhiteListProvider whiteListProvider) {
        this.whiteListProvider = whiteListProvider;
    }

    /**
     * 返回指定用戶是否能夠訪問
     * @param userId 用戶ID
     *
     * @return
     */
    public boolean canAccess(Long userId) {
        List<Long> whiteListProviderList = whiteListProvider.provideUserIdWhiteList();
        if (whiteListProviderList.contains(userId)) {
            return true;
        } else {
            return !blackListProvider.provideUserNameBlackList().contains(UICService.findNameById(userId));
        }
    }
}

其中UICSerive定義如下,這是一個HSF的遠程服務

public interface UICService {

    /**
     * 查找ID對應的名稱
     * @param id
     * @return
     */
    String findNameById(Long id);
}

黑名單提供者有一個本地實現

/**
 * 黑名單提供者
 */
public interface BlackListProvider {

    /**
     * 提供黑名單
     * @return
     */
    List<String> provideUserNameBlackList();
}

/**
 * BlackListProvider的一個本地實現
 */
public class LocalBlackListProvider implements BlackListProvider {

    private List<String> blackList;

    public LocalBlackListProvider(List<String> blackList) {
        this.blackList = new ArrayList<>(blackList);
    }

    @Override
    public List<String> provideUserNameBlackList() {
        return Collections.unmodifiableList(blackList);
    }
}

白名單則需要通過HSF遠程獲取

/**
 * 白名單提供者
 */
public interface WhiteListProvider {

    /**
     * 提供白名單ID
     * @return
     */
    List<Long> provideUserIdWhiteList();
}

由於代碼中有兩個對外部服務的依賴,想在實際環境中去驗證canAccess代價很大。一方麵跨係統準備數據很麻煩;另一方麵即使當時數據準備好了,過兩天可能又沒了。這個驗證沒法自動化就沒法到持續集成,也就稱不上單元測試。

通過使用Mock,可以讓這兩個外部依賴按照我們的想法返回數據,準備數據變得容易,單測的強度也就容易提升。

首先在pom中引入easymock

        <dependency>
            <groupId>org.easymock</groupId>
            <artifactId>easymock</artifactId>
            <version>3.4</version>
            <scope>test</scope>
        </dependency>

然後開始寫單測

package com.jinlo.springbootdemo.demo;

import static org.easymock.EasyMock.createMock;
import static org.easymock.EasyMock.expect;
import static org.easymock.EasyMock.replay;
import static org.easymock.EasyMock.verify;

import java.util.Arrays;

import org.junit.Assert;
import org.junit.Test;


/**
*
* @author lotus.jzx
*/
public class AccessControlServiceTest {
    @Test
    public void testCanAccess(){

        // STEP1: 準備數據
        UICService uicService=createMock(UICService.class);
        //UIC服務傳入1L,返回tom
        expect(uicService.findNameById(1L)).andReturn("tom").anyTimes();
        //UIC服務傳入2L,返回jerry
        expect(uicService.findNameById(2L)).andReturn("jerry").anyTimes();
        //UIC服務傳入3L,返回anna
        expect(uicService.findNameById(3L)).andReturn("anna").anyTimes();
        //UIC服務傳入4L,返回lucky
        expect(uicService.findNameById(4L)).andReturn("lucky").anyTimes();

        BlackListProvider blackListProvider=new LocalBlackListProvider(Arrays.asList("tom","jerry","peter"));

        WhiteListProvider whiteListProvider=createMock(WhiteListProvider.class);
        //白名單包含jerry,anna和lucky
        expect(whiteListProvider.provideUserIdWhiteList()).andReturn(Arrays.asList(2L, 3L, 4L)).anyTimes();

        //重放uicService和whiteListProvider,重放後mock對象才能被使用
        replay(uicService,whiteListProvider);


        //被測試的對象
        AccessControlService accessControlService=new AccessControlService();

        //依賴注入
        accessControlService.setBlackListProvider(blackListProvider);

        accessControlService.setUICService(uicService);

        accessControlService.setWhiteListProvider(whiteListProvider);

        // STEP2: 測試和斷言

        //在黑名單裏,不在白名單裏,不能訪問
        Assert.assertFalse(accessControlService.canAccess(1L));

        //在黑名單裏,也在白名單裏,可以訪問
        Assert.assertTrue(accessControlService.canAccess(2L));

        //不在黑名單裏,在白名單裏,可以訪問
        Assert.assertTrue(accessControlService.canAccess(3L));

        //不在黑名單裏,也不在白名單裏,可以訪問
        Assert.assertTrue(accessControlService.canAccess(4L));

        // STEP3: 驗證mock對象的行為符合預期,可選
        verify(uicService,whiteListProvider);
    }
} 

這裏不展開將easymock的語法。一般來說一個測試用例需要三個步驟,如上例中的STEP1~STEP3

  1. 準備數據: 構造mock對象或本地對象,組裝對象
  2. 測試和斷言: 這步主要考慮測試覆蓋率
  3. 驗證mock對象的行為:這是使用mock特有的行為,可選。

這個例子比較簡單,但仍然有幾點值得注意的地方
1. 為了可測試性,被測試的對象(AccessControlService)所用到的依賴一定要作成可注入的(依賴翻轉,提供setter方法)。測試對象和依賴之間應該基於接口注入(BlackListProvider,WhiterListProvider和UICSerive都是接口)而不是基於實現注入。這樣在WhiteListProvider還沒有提供實現時我們就能為AccessControlService寫單測。
2. 實際上依賴注入這個概念最早提出很大程度上就是為了提高可測試性,從這個角度說,一個"容易測試的"代碼一定是耦合較少的代碼,如果一個被測試的對象需要注入十幾二十個依賴,通常意味著bad smell,需要重構(Builder/Factory例外)
3. 並不需要mock所有的依賴,比如上例的BlackListProvider提供了一個本地實現,在測試用例中就直接使用了這個本地實現,而沒有去mock。一般來說,有本地實現盡量用本地實現,本地實現更能暴露問題,隻有對外部依賴才必須使用mock。

單測的誤區

以上介紹了單測的理念和方法,下麵介紹一些通常對單測的理解誤區。

補單測

經常聽到開發匯報說"XXX功能已經寫完了,今天的工作是補些單測"。願意補單測是很有責任心的表現,但還是要說 單測應該隨著代碼同時產生,而不應該是補出來的

按照錯誤率恒定定律,錯誤的產生是客觀存在的。一次性手寫超過20行代碼基本就會出錯。當一段代碼(一個類或者一個方法)剛被寫出來的時候,開發對整個上下文非常清楚,要測試什麼邏輯也很明確(再次強調 單測是白盒測試),這時候寫單測速度最快,也最容易設計出高強度的單元測試。如果等一次產出N個類,上千行代碼再去寫單測,很多當時的上下文都已經遺忘了,而且惰性會使人麵對大量工作時產生畏難情緒,這時寫的單測質量就比較差了。至於為幾個月甚至幾年前的代碼寫單測,基本上除了大規模重構,是沒人願意去寫的。

在測試前置這方麵最激進的嚐試是TDD (Test Driven Development),其次是TFD (Test First Development),它們都要求單測在代碼前完成。盡管這兩個實踐目前不是很流行,但還是推薦有興趣的同學去嚐試一下TDD,經過TDD熏陶的代碼會自然的覺得單元測試是程序的一部分,對於這點理解也會更深。

項目緊,沒時間寫單測

這也是開發經常會說的話,尤其是沒有寫單測習慣的開發經常會說的話。然而這句話其實也是不對的,不考慮單測框架自身的學習成本,**任何情況下寫單測都隻會降低整體交付時間**

根據"錯誤率恒定定律"和"規模代價平方定律",因為單測可以在盡量小規模內發現問題,其實這是一個很自然的結論。再緊的項目都要有設計、編碼、測試和發布這些環節,如果說項目緊不寫單測,看起來編碼階段省了一些時間,但必然會在測試和線上花掉成倍甚至更多的時間來修複。

錯誤率是恒定的,需要的調試量也是固定的,用測試甚至線上環境調試並不能降低調試的量,隻會降低調試效率。

單測是QA的工作

這是混淆了單元測試和集成測試的邊界。

單元測試是白盒測試,應該隨著代碼一起產出,一起修改。單元測試的目的是讓程序員"擦幹淨自己的屁股",保證相對小的模塊確實在按照設計目標工作。單元測試需要代碼和程序同時變動,不要說QA,就是換個開發寫單測都趕不上這個節奏(除非結對編程)。所以單元測試一定是開發的工作。

集成測試是黑盒測試,一般是端到端的測試,很大的工作量在維護上下遊環境的兼容上。集成測試運行的頻率也比單元測試低,這部分工作由QA來作還是可以接受的。

總結

越是重要的項目,程序員越需要安全感。單元測試就是程序員的救生圈,在代碼的海洋中為程序員提供安全感。有了單元測試的保障,程序員才有信心在約定時間內完成聯調和發布,才敢對已有的程序作修改和重構而不擔心引入新問題。

作為軟件開發中投入產出比最高的實踐,我們要更大力度的推廣單元測試。讓更多的程序員嚐到它的好處,從而愛上它。

最後更新:2017-06-05 11:33:33

  上一篇:go  二、Angular 2.0開發指南以及搭建開發環境
  下一篇:go  基於表格存儲的高性能監控數據存儲計算方案