如何寫好單元測試:Mock脫離數據庫+不使用@SpringBootTest

目錄

1、一般的單元測試寫法

2、單元測試步驟

3、對一般的單元測試寫法分析優化

4、最佳的單元測試寫法:Mock脫離數據庫+不啓動Spring+優化測試速度+不引入項目組件

一、普遍的單元測試方法

作爲一個Java後端程序員,肯定需要寫單元測試。我先提供一個典型的錯誤的單元測試例子:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
@Transactional
@Rollback(true) // 事務自動回滾,默認是true。可以不寫
public class HelloServiceTest {

    @Autowired
    private HelloService helloService;

    @Test
    public void sayHello() {
        helloService.sayHello("zhangsan");
    }

這個例子錯誤點有4個:(本文的錯誤統一指不標準,實際上這樣子寫單元測試也可以,只是不規範,顯示不出在座各位優秀的編程能力)

1、@Autowired啓動了Spring

2、@SpringBootTest啓動了SpringBoot環境,而classes = Application.class啓動了整個項目

3、通過@Transactional可以知道調用了數據庫

4、沒有Assert斷言

 

二、一般的錯誤的單元測試步驟(SpringBoot環境下)

1、使用@RunWith(SpringRunner.class)聲明在Spring的環境中進行單元測試,這樣Spring的相關注解就會被識別並起效

2、然後使用@SpringBootTest,它會掃描應用程序的spring配置,並構建完整的Spring Context。

3、通過@SpringBootTest我們可以指定啓動類,或者給@SpringBootTest的參數webEnvironment賦值爲SpringBootTest.WebEnvironment.RANDOM_PORT,這樣就會啓動web容器,並監聽一個隨機的端口,同時,爲我們自動裝配一個TestRestTemplate類型的bean來輔助我們發送測試請求。

如果項目稍微複雜一點,像SpringCloud那樣多模塊,還使用了緩存、分片、微服務、集羣分佈式等東西,然後電腦配置再差一點,那你每執行一次單元測試的啓動-運行-測試時間,漫長得夠你去喝杯茶再回來了。

或者你的項目使用了@Component註解(在SpringBoot項目啓動的時候就會跟着實例化/啓動)

啓動類上也定義了啓動時就實例化的類

這個@Component註解的類裏有多線程方法,隨着啓動類中定義的ApplicationStartup類啓動了,那麼在你執行單元測試的時候,由於多線程任務的影響,就可能對你的數據庫造成了數據修改,即使你使用了事務回滾註解@Transactional。我出現的問題是:在我運行單元測試的時候,代碼裏的其他類的多線程中不停接收activeMQ消息,然後更新數據庫中對應的數據。跟單元測試的執行過程交叉重疊,導致單元測試失敗。其他組員在操作數據庫的時候,也因爲我無意中帶起的多線程更改了數據庫,造成了開發上的困難。

另外附帶@Component源碼,順便學習一下

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
public @interface Component {
    //這個值可能作爲邏輯組件(即類)的名稱,在自動掃描的時候轉化爲spring bean,
    //即相當<bean id="" class="" />中的id
    String value() default "";
}

@Component是一個元註解,意思是可以註解其他類註解,如@Controller @Service @Repository @Aspect。官方的原話是:帶此註解的類看爲組件,當使用基於該註解的配置和類路徑掃描的時候,這些類就會被實例化。其他類級別的註解也可以被認定爲是一種特殊類型的組件,比如@Repository @Aspect。所以,@Component可以註解其他類註解。

 

三、優化單元測試寫法

我先來上圖,這樣子寫單元測試運行一次所需要的時間。然後我們通過對比,得出編寫最佳單元測試的方法。我這個6年前的筆記本,運行一次單元測試,需要差不多1分鐘,而經過代碼優化,只需要幾秒鐘。下面是優化方式:

 

首先,我們要明確單元測試的終極目標,就是完全脫離數據庫完全脫離數據庫完全脫離數據庫!其次,單元測試是隻針對某一個類的一個方法(一個小的單元)來測,在測試過程中,我們不要啓動其它東西,要脫離項目中其它因素可能產生的干擾。

所以可以發現上面的例子簡直是侮辱了單元測試,最初級的入門的學生才這樣寫。衆所周知,現在看到這裏的各位都是架構師的能力,接下來我們一行行代碼,一秒五噴,嚴厲抨擊這段錯誤的單元測試:

1、不應使用@Autowired

@Autowired
private HelloService helloService;

這個@Autowired簡直是畫蛇添足!就是這個東西啓動了Spring。以前沒有@Autowired的時候,我們需要這樣配置bean屬性

<property name="屬性名" value=" 屬性值"/>

這種方式代碼較多,配置繁瑣,於是Spring 2.5 引入了 @Autowired 註釋。

@Autowired的原理

在啓動spring IOC時,容器自動裝載了一個AutowiredAnnotationBeanPostProcessor後置處理器,當容器掃描到@Autowied、@Resource或@Inject時,就會在IOC容器自動查找需要的bean,並裝配給該對象的屬性

<bean class="org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor"/> 

 注意事項:

  1、在使用@Autowired時,會先在IOC容器中查詢要自動引入的對應類型的bean

       2、如果查詢結果剛好爲一個,就將該bean裝配給@Autowired指定的屬性值

  3、如果查詢的結果不止一個,那麼@Autowired會根據屬性名來查找。

  4、如果查詢的結果爲空,那麼會拋出異常。解決方法:使用required=false

 

那麼問題就來了,我們只是要寫單元測試,爲什麼要啓動Spring呢?首先,啓動Spring只會讓你run->Junit Test的時候程序變慢,這是每次運行單元測試都很慢的原因之一。然後單元測試是隻針對某一個類的方法來測,啓動Spring完全是多餘的,所以我們只需要對應的實體類實例就夠了。在需要注入bean的時候,我們直接new,如下

@Autowired
private HelloService helloService;

改爲:

private HelloService helloService = new HelloServiceImpl();

// 這個HelloServiceImpl是你每個接口的對應實現類

 

2、不應使用@SpringBootTest

@SpringBootTest(classes = Application.class)

這個@SpringBootTest簡直犯罪有木有!它就是每次運行單元測試都很慢的罪魁禍首,相信我,把它刪掉你的單元測試速度會快的飛起。@SpringBootTest和@Autowired一樣,在單元測試裏面是完全多餘的,根本就不搭邊的兩個東西!每次單元測試都先啓動SpringBoot

然後我們來看一下@SpringBootTest的源碼

大概意思:

1、@SpringBootTest是在SpringBoot項目上使用的,它在@SpringBootContextLoader的基礎上,配置文件屬性的讀取。

2、在常規Spring TestContext框架之上提供以下特性:

1)當定義沒有特定的@ContextConfiguration(loader=…)時,使用SpringBootContextLoader作爲默認的ContextLoader。ContextLoader的作用:實際上由ContextLoaderListener調用執行根應用上下文的初始化工作。

2)當不使用嵌套@Configuration時,自動搜索@SpringBootConfiguration,並且沒有指定顯式的類。

3)允許使用properties屬性定義自定義環境屬性。

4、爲不同的webEnvironment模式提供支持,包括啓動一個完全運行的web服務器,監聽一個已定義的或隨機的端口。

5)註冊一個TestRestTemplate或WebTestClient bean,用於在web測試中使用完全運行的web服務器。

使用方式

@SpringBootTest(classes = Application.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

現在一般寫成這樣
@SpringBootTest(classes = Application.class)

或者這樣
@SpringBootTest

但不管寫成怎樣,這個註解都不該用

classes = Application.class指定啓動類,在執行這裏的時候,會讀取、解析一些項目配置文件,還會連接數據庫,然後如果啓動類又帶有別的啓動類、@Component、多線程等,在你執行單元測試的時候,程序不止運行慢,時間長,而且由於多線程任務的影響,就可能對你的數據庫造成了數據修改,即使你使用了事務回滾註解@Transactional

 

3、不應調用數據庫

@Transactional
@Rollback(true) // 事務自動回滾,默認是true。可以不寫

單元測試的目標,就是完全脫離數據庫!這個註解如果使用,就是完全背道而馳了,一般使用了這個註解的單元測試,脫離數據庫後很多都會執行報錯

 

4、應使用Assert斷言

Assert斷言的使用方式,可以看這篇博客:單元測試中Assert斷言的使用

那麼我們到底應該如何寫單元測試呢?

 

四、正確的單元測試寫法:Mock脫離數據庫

首先放上正確的單元測試例子

    //@SpringBootTest
    //@SpringBootTest(classes = Application.class)
    // 在啓動類啓動的時候也啓動了這個類,所以也要引入進來
    //@Import(ApplicationStartup.class)
    // 不執行項目裏Component註解過的方法
    //@TestComponent

    // 注意點一:保留了RunWith註解
    @RunWith(SpringRunner.class)
    public class HelloServiceTest {
        
        //@Autowired
        // 不使用Autowired,不啓動Spring容器,對需要實現的方法實現類直接new進行實例化
        private HelloService helloService = new HelloServiceImpl();


        @Test
        public void sayHello() {
            // 模擬JPA的EntityManager,官方的接口、類都要模擬
            EntityManager em =  init(helloService);
            
            // any()代替任意類型的參數
            Mockito.doReturn("我是模擬的返回值").when(em).findById( any());
            // 沒有返回值的方法,可以不另外寫,因爲模擬實體類的時候已經自動模擬了
            Mockito.doNothing().when(em).find(any());
            
            helloService.sayHello("zhangsan");
            Assert.isTrue(true,"完全正確的單元測試");
        }


        EntityManager init(Object classInstance ){
            // 要模擬的類
            EntityManager em = Mockito.mock(EntityManager.class);
            // 指定反射類
            Class<?> clazz = classInstance.getClass();
            // 獲得指定類的屬性
            Field field = null;
            try {
                field = clazz.getDeclaredField("em");
                // 值爲 true 則指示反射的對象在使用時應該取消 Java 語言訪問檢查。
                // 值爲 false 則指示反射的對象應該實施 Java 語言訪問檢查。
                // 默認 false
                field.setAccessible(true);
                // 更改私有屬性的值
                field.set(classInstance, em);
            } catch (NoSuchFieldException | IllegalAccessException e) {
                e.printStackTrace();
            }
            return em;
        }

    }

    // HelloServiceImpl是實現類,以下代碼只是爲了表達意思,它的sayHello方法代碼爲
    class HelloServiceImpl {
        @Autowired
        private EntityManager et;

        sayHello(String name) {
            // 沒有返回值的操作數據庫的方法
            et.find(name);
            // 有返回值的方法
            String oldSecondName = et.findById(name.substring(2));
            
          
        }
    }

可以看到保留了@RunWith註解

1、@RunWith 在JUnit中有很多個Runner,他們負責調用你的測試代碼,每一個Runner都有各自的特殊功能,你要根據需要選擇不同的Runner來運行你的測試代碼。一般都是使用SpringRunner.class

2、如果我們只是簡單的做普通Java測試,不涉及Spring Web項目,你可以省略@RunWith註解,這樣系統會自動使用默認Runner來運行你的代碼。

然後最主要的就是Mock了,Mock所需的jar在這裏已經包含

        <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

到這裏你需要一點Mock的基礎,Mock就是模擬一切操作數據庫的步驟,不執行任何SQL,我們直接模擬這句操作數據庫的代碼執行時成功的,而且可以模擬任何返回值,主要有兩個註解

@MockBean

只要是本地的,自己寫的bean,都可以使用這個註解,它會把所有操作數據庫的方法模擬。如果是沒有返回值的方法,我們就可以不管。如果是有返回值的方法,我們可以給它返回各自我們需要模擬的值。用法如下:

             // any()代替任意類型的參數
            Mockito.doReturn("我是模擬的返回值").when(em).findById( any());
            // 沒有返回值的方法,可以不另外寫,因爲模擬實體類的時候已經自動模擬了
            Mockito.doNothing().when(em).find(any());

@SpyBean

如果是我們本地,調用別的公司,別的地方給我們寫好的接口,不是操作我們自己的數據庫,是我們寫好入參,別人給我們返回值,我們就用這個。它的用法和@MockBean一樣

二者的主要用法區別:


MockBean 適用本地,模擬全部方法

SpyBean適用遠程不同環境, 只模擬個別方法

然後我們這裏Mock的是JPA官方的EntityManager,對於官方的接口、類在我們的實現類裏面作爲private屬性來操作數據庫,我們可以通過這個方法來模擬

    EntityManager init(Object classInstance ){
            // 要模擬的類
            EntityManager em = Mockito.mock(EntityManager.class);
            // 指定反射類
            Class<?> clazz = classInstance.getClass();
            // 獲得指定類的屬性
            Field field = null;
            try {
                field = clazz.getDeclaredField("em");
                // 值爲 true 則指示反射的對象在使用時應該取消 Java 語言訪問檢查。
                // 值爲 false 則指示反射的對象應該實施 Java 語言訪問檢查。
                // 默認 false
                field.setAccessible(true);
                // 更改私有屬性的值
                field.set(classInstance, em);
            } catch (NoSuchFieldException | IllegalAccessException e) {
                e.printStackTrace();
            }
            return em;
        }

如果你的項目沒有這麼複雜,你只需要在你想要模擬的類頭頂加上這個@MockBean註解就可以了,一般都是用這個,如

    public class HelloServiceTest {
        
        //@Autowired
        // 不使用Autowired,不啓動Spring容器,對需要實現的方法實現類直接new進行實例化
        
        private HelloService helloService = new HelloServiceImpl();

        @MockBean
        HelloDao dao;

        @Test
        public void sayHello() {
           
            // any()代替任意類型的參數
            Mockito.doReturn("我是模擬的返回值").when(dao).findById( any());
            // 沒有返回值的方法,可以不另外寫,因爲模擬實體類的時候已經自動模擬了
            Mockito.doNothing().when(dao).find(any());
            
            helloService.sayHello("zhangsan");
            Assert.isTrue(true,"完全正確的單元測試");
        }

這段代碼可能跟上面有點不通,我隨手敲的,我要表達的就是:如果你不需要模擬官方的接口、類來操作數據庫,那你直接在你的實現類頭頂加@MockBean或者@SpyBean註解,然後使用Mockito語法就可以了。

你懂我的意思吧?

部分內容參考:

https://blog.csdn.net/fxbin123/article/details/80617754

https://www.jianshu.com/p/72b19e24a602

https://blog.csdn.net/lycyl/article/details/82865009

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章