單元測試實踐思考(junit5+jmockit+testcontainer)

背景

之前整理過一篇,基於(SpringCloud+Junit5+Mockito+DataMocker)的框架整理的單元測試。當時的項目是一個編排層的服務項目,所以沒有涉及到資料庫或者其他中間件的複雜問題。而且是項目剛開始,程式碼環境不複雜,當時的架構基本上能夠滿足需求。
最近在一個較老項目,現在希望加強項目的程式碼品質,所以開始引入單元測試框架。於是乎先按照原本的設計引入了junit5的整套框架,同時引入了h2用於資料庫模擬,以及rabbitmq的mock服務。這個項目使用的是SpringCloud Alibaba框架,服務註冊和配置管理使用nacos,其他沒有太多特別的地方。但是實際編寫的過程中,發現了一些問題:

  • Mock框架使用了Mockito和PowerMock,開發人員需要同時使用兩種框架。
  • H2的資料庫和實際的Mysql資料庫相比還是有一些差異,比如無法支援函數等情況。
  • 單元測試的數據準備相對比較複雜,如何能夠很好的隔離不同單元測試的影響是個問題。
  • 單元測試是為了覆蓋率還是為了有強度的品質保證,如何提高研發人員的單元測試品質。

方案設計

針對上述問題,我們來一條一條解決。
首先是針對Mock框架,考察之後認為可以選擇Jmockit框架,能夠直接滿足普通方法和靜態方法,但是語法相對不如Mockito自然,學習曲線相對較高。但最終還是決定嘗試以統一框架來做,降低架構的複雜度。
其次是資料庫問題,有兩種方案,一種是完善H2資料庫,可以用自定義的函數來支援缺失的特性,但缺點也很明確,H2始終不是真實的Mysql資料庫。第二種找到了TestContainer方案,這是一個Java操作Docker的類庫,可以利用Java程式碼直接生成Docker的鏡像與容器並且運行,這樣就有辦法直接啟動一個Mysql的容器用於單元測試,結束後直接完全銷毀。這種方法的缺點在於環境問題,所有需要運行單元測試的環境都需要安裝Docker支援,包含研發自己和CI環境。但是好處在於一個通用的中間件模擬方案,後續Redis、MQ或者其他的中間件都完全可以使用這樣的方案來模擬了。
數據準備,這個問題我們設定了兩種數據準備的方式。第一部分是在初始化資料庫的時候,導入基礎腳本,這部分的腳本包含結構和數據,是公用的內容所有的單元測試都需要依賴的基礎數據,比如公司、部門、員工、角色、許可權等等。第二部分是在單元測試單個類初始化時,引入數據腳本,這些數據僅僅是為了單個類/方法中的單元測試使用,運行完方法後會回滾,不會影響到其他單元測試的運行。
最後是單元測試的強度,主要還是一些規範,例如要求所有的單元測試都必須要有斷言,並且斷言的條件是要對數據內容欄位進行合理驗證的。可以參考一下這一篇寫有價值的單元測試
所以最終落定的框架就是 Junit5 + Jmockit + TestContainer。

單元測試指導思想

在底層框架搭建之前,可以先討論一下如何才能寫出真正有價值的單元測試,而不是單純為了績效中的單元測試覆蓋率?
之前一段中提到的寫有價值的單元測試和阿里Java程式碼規約中有提到一些點

引用阿里規約:

  1. 【強制】好的單元測試必須遵守 AIR原則。
    說明:單元測試在線上運行時,感覺像空氣(AIR)一樣並不存在,但在測試品質的保障上,
    卻是非常關鍵的。好的單元測試宏觀上來說,具有自動化、獨立性、可重複執行的特點。 A:Automatic(自動化) I:Independent(獨立性) R:Repeatable(可重複)
  2. 【強制】單元測試應該是全自動執行的,並且非互動式的。測試用例通常是被定期執行的,執
    行過程必須完全自動化才有意義。輸出結果需要人工檢查的測試不是一個好的單元測試。單元
    測試中不準使用 System.out來進行人肉驗證,必須使用 assert來驗證。
  3. 【強制】保持單元測試的獨立性。為了保證單元測試穩定可靠且便於維護,單元測試用例之間
    決不能互相調用,也不能依賴執行的先後次序。
    反例:method2需要依賴 method1的執行,將執行結果作為 method2的輸入。
  4. 【強制】單元測試是可以重複執行的,不能受到外界環境的影響。
    說明:單元測試通常會被放到持續集成中,每次有程式碼 check in時單元測試都會被執行。如
    果單測對外部環境(網路、服務、中間件等)有依賴,容易導致持續集成機制的不可用。
    正例:為了不受外界環境影響,要求設計程式碼時就把 SUT的依賴改成注入,在測試時用 spring
    這樣的 DI框架注入一個本地(記憶體)實現或者 Mock實現。
  5. 【強制】對於單元測試,要保證測試粒度足夠小,有助於精確定位問題。單測粒度至多是類級
    別,一般是方法級別。
    說明:只有測試粒度小才能在出錯時儘快定位到出錯位置。單測不負責檢查跨類或者跨系統的
    交互邏輯,那是集成測試的領域。

其中有一些思想會決定我們在單元測試程式碼具體的實現方式。我們嘗試了之後,根據上述的指導思想有兩種不同的實現方式。

  • 單層隔離
  • 內部穿透

接下來我們就兩種方式來進行說明。

單層隔離

正常程式碼分層會分為controller、service、dao等,在單層隔離的思想中,是針對每一層的程式碼做各自的單元測試,不向下穿透。這樣的寫法主要是保證單層的業務邏輯固化且正確。
實踐過程中,例如針對controller層編寫的單元測試需要將對應controller類程式碼文件外部所有的調用全部mock,包括對應的內部/外部的service。其他層的程式碼也是如此。

這樣做的優點:

  • 單元測試程式碼極其輕量,運行速度快。由於只保證單個類內部的邏輯正確,其他全部mock,所以可以放棄中間件的mock,甚至Spring的注入都可以放棄,專註在單元測試邏輯驗證的編寫。這樣整套單元測試程式碼運行完成應該也是輪秒計時,相對來講Spring容器初始化完成可能都需要20秒。
  • 真正符合了單元測試的原則,可以在斷網的情況下進行運行。單層邏輯中可以屏蔽服務註冊和配置管理,各種中間件的影響。
  • 單元測試品質更高。針對單層邏輯的驗證和斷言能夠更加清晰,如果要覆蓋多層,可能會忽略丟失中間的各種驗證環節,如果加上可能條件規模是一個笛卡爾乘積過於複雜。

缺點也是存在:

  • 單元測試的程式碼量比較大,因為是針對每層單獨編寫單元測試,而且需要mock掉的外部依賴也是比較多的。
  • 學習曲線相對較高,由於程式設計師的習慣針對單元測試是給定輸入驗證輸出。所以沒有了底層的輸出,單純驗證過程邏輯要存在一個思維上的轉變。
  • 對於低複雜度的項目比較不友好。如果你的項目大部分都是單純的分層之後的CRUD,那單元測試其實可驗證的東西不太多。但是如果是程式碼當中執行了複雜邏輯,這樣的寫法就能夠起到比較好的品質保證。

在這個項目中,最終沒有採用這樣的方法,而是採用了穿透的方式。項目的場景、人員組成、複雜度的實際情況,我覺得用這種方式不算很合適。

內部穿透

穿透,自然就是從頂層一直調用到底層。為什麼還要加上內部二字?就是除了項目內的方法可以穿透,項目外部依賴還是要mock掉的。
實踐過程中,就是單元測試針對controller層編寫,但是會完整調用service、dao,最終對落地結果進行驗證。

優點:

  • 程式碼量相對較小,由於進行了穿透所以多層程式碼的覆蓋僅需要從頂層的單元測試驗證即可。
  • 學習曲線低,穿透的單元測試更偏向黑盒,開發人員構造輸入條件,然後從落地結果中(存儲,例如資料庫)驗證預期結果。

缺點:

  • 整體較重,啟動Spring容器,中間件mock,整體單元測試運行預計需要是需要分鐘級別。所以基本是要在CI的時候來執行。

技術實現

敲定方案之後我們就可以進行技術實現了,這是一個Java項目,使用Maven進行依賴管理。接下來我們主要分為三部分介紹:

  • 依賴管理
  • 基礎架構
  • 實現實例

依賴管理

依賴管理中第一個注意的點,由於目前Junit4還佔有較多的市場,我們要盡量去排除掉一些測試相關的依賴中包含對與4的引用。
接下來我先貼出Pom文件中和單元測試相關的部分

        <!-- Jmockit -->          <dependency>              <groupId>org.jmockit</groupId>              <artifactId>jmockit</artifactId>              <version>1.49</version>              <scope>test</scope>          </dependency>            <!-- junit5 框架 -->          <dependency>              <groupId>org.junit.jupiter</groupId>              <artifactId>junit-jupiter</artifactId>              <version>5.6.1</version>              <scope>test</scope>          </dependency>            <!--  Spring Boot 測試框架 -->          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-test</artifactId>              <scope>test</scope>              <!-- exclude junit 4 -->              <exclusions>                  <exclusion>                      <groupId>junit</groupId>                      <artifactId>junit</artifactId>                  </exclusion>              </exclusions>          </dependency>            <!--  公司內部封裝的一個數據自動Mock框架,來源於Jmockdata -->          <dependency>              <groupId>cn.vv.service.unittest</groupId>              <artifactId>vv-data-mocker</artifactId>              <version>0.0.1-SNAPSHOT</version>              <scope>test</scope>          </dependency>            <!--  testcontainers對於mysql的封裝包,當然也可以將mysql替換為testcontainers,這樣直接引入底層容器包 -->          <dependency>              <groupId>org.testcontainers</groupId>              <artifactId>mysql</artifactId>              <version>1.12.0</version>              <scope>test</scope>          </dependency>            <!--  testcontainers 容器對於junit5的支援 -->          <dependency>              <groupId>org.testcontainers</groupId>              <artifactId>junit-jupiter</artifactId>              <version>1.12.0</version>              <scope>test</scope>          </dependency>  

依賴的引入基本就是這些了,其中還需要注意的是surefire的插件配置

<plugin>                  <groupId>org.apache.maven.plugins</groupId>                  <artifactId>maven-surefire-plugin</artifactId>                  <version>3.0.0-M4</version>                  <configuration>                      <argLine>-javaagent:${settings.localRepository}/org/jmockit/jmockit/1.49/jmockit-1.49.jar                          -Dfile.encoding=UTF-8 -Xmx1024m                      </argLine>                      <enableAssertions>true</enableAssertions>                      <!-- <useSystemClassLoader>true</useSystemClassLoader>-->                  </configuration>                  <dependencies>                      <dependency>                          <groupId>org.apache.maven.surefire</groupId>                          <artifactId>surefire-api</artifactId>                          <version>3.0.0-M4</version>                      </dependency>                  </dependencies>              </plugin>  

這裡的注意點是Jmockit需要使用javaagent來初始化JVM參數。

基礎架構

基礎架構的部分,我想分為三點來講:

  • 單元測試基類,封裝了一些項目使用的基礎Mock對象和公用方法
  • 單元測試配置相關
  • TestContainer的封裝

其實這三點都是與單元測試基類相關的,分開講各自的實現方式後,最終會給出完整的程式碼。

封裝Junit5&Jmockit

首先是註解的部分Junit4到5註解有調整和變化,而且我們的項目又是基於SpringCloud的,所以最終的單元測試基類BaseTest使用了三個註解

@SpringBootTest(classes = {OaApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  @Transactional  @Slf4j  

Junit5的類頭部是不需要什麼註解的,主要還是和Spring配合,我們使用了Boot Test提供的SpringBootTest註解,指定了入口的啟動類,為了包含配置文件,獲取nacos配置。
事務註解是為了讓數據操作方法都能夠回滾,不影響其他單元測試。
最後就是lombok的日誌註解。

接下來就是BeforeAll,AfterAll,BeforeEach,AfterEach幾個註解。
這裡的思路就是使用Jmockit,對待測試業務系統內底層機制進行統一的Mock處理,例如request或者session中的頭部資訊。我這裡的程式碼可能和大家各自的項目中差異比較多,只是提供一個思路。利用Jmockit來Mock我們一些靜態方法獲取對象時,直接返回我們設計的結果對象。

      @BeforeAll      protected static void beforeAll() {            new MockUp<ShiroUtils>(ShiroUtils.class) {              @Mock              public EmployeeVO getEmployee() {                  EmployeeVO employeeVO = new EmployeeVO();                  employeeVO.setUserName("mock.UserName");                  employeeVO.setUserNo("mock.UserNo");                  employeeVO.setCompanyName("mock.CompanyName");                  employeeVO.setDepartmentName("mock.DepartmentName");                  return employeeVO;              }          };          new MockUp<LogAspect>(LogAspect.class) {              @Mock              public String getIp() {                  return "mock.ip";              }          };      }        @AfterAll      protected static void destroy() {      }        @BeforeEach      protected void beforeEach() {            new MockUp<WebUtil>(WebUtil.class) {              @Mock              public HttpServletRequest getRequest() {                  return getRequest;              }                @Mock              public VvCurrentAccount getCurrentAccount(Boolean isMustLogin) {                  VvCurrentAccount vvCurrentAccount = new VvCurrentAccount();                  vvCurrentAccount.setUserCode("mock.userCode");                  return vvCurrentAccount;              }          };          new MockUp<ServletUtils>(ServletUtils.class) {              @Mock              public HttpServletRequest getRequest() {                  return getRequest;              }          };              if (StringUtil.isNotBlank(this.getDbScript())) {              try {                  ScriptRunner runner = new ScriptRunner(dataSource.getConnection());                  runner.setErrorLogWriter(null);                  runner.setLogWriter(null);                  runner.runScript(new FileReader(this.getClass().getResource(this.getDbScript()).getPath()));              } catch (Exception e) {                  log.error("ScriptRunner error!", e);              }          }      }        @AfterEach      protected void afterEach() {      }          protected String getDbScript() {          return "";      }  

這裡有一個設計點可以討論一下,beforeEach中調用了getDbScript,用於在單元測試方法前構建單個單元測試類中需要的數據。而且由於類都繼承了事務默認回滾,所以本次操作完的數據在方法結束後都會回滾,這樣把數據的影響降到了最低。
每個單元測試類只要重寫一下getDbScript方法,提供自己的資料庫腳本即可。用這樣的設計來進行單元測試方法級別的數據隔離。

單元測試配置

由於本項目的框架使用了Nacos,其地址和空間都是配置在Pom文件中,在運行時指定Profile來調用不同環境的配置。正常使用時,中間件的訪問地址,用戶密碼等資訊也是保存在Nacos上,由於運行單元測試需要Mock真實中間件,所以所有資訊都需要替換。
第一個版本是使用了Nacos的本身特性,在單元測試頭部使用 @ActiveProfile("") 之後,會讀取對應配置文件的properties來替換佔位符,例如原本我們的配置是寫在vv-oa.yml中,我們指定了ActiveProfile("test"),則會去載入vv-oa-test.properties文件,用於替換yml中的配置。
通過這樣的方法來達到僅僅在單元測試中替換中間件連接的目的。
但是由於中間件的Mock方法使用了TestContainer,容器的地址實際上無法直接固定,所以這個方案就不是很合適了。就使用本地配置的形式(AutoConfiguration),新建一個配置類放在單元測試的包中。

  @Configuration  @EnableTransactionManagement  public class JunitDataSource {        @Bean      public DataSource dataSource() throws Exception {          Properties properties = new Properties();          properties.setProperty("driverClassName", System.getProperty("spring.datasource.driver-class-name"));          properties.setProperty("url", System.getProperty("spring.datasource.url"));          properties.setProperty("username", System.getProperty("spring.datasource.username"));          properties.setProperty("password", System.getProperty("spring.datasource.password"));          return DruidDataSourceFactory.createDataSource(properties);      }        @Bean      public PlatformTransactionManager transactionManager() throws Exception {          return new DataSourceTransactionManager(dataSource());      }    }    

其他中間件也使用相同的方式。

TestContainer封裝

首先給大家提供官方網站和他們的Github程式碼示例庫,很多用法都是參考官方的來的。本文以Mysql的容器作為樣例給大家簡單介紹一下使用。

官方方案

在官方文檔中的資料庫容器章節中,介紹了兩種資料庫容器的使用方式:

  • 程式碼中啟動容器
  • 通過JDBC url啟動容器
   @Rule      public MySQLContainer mysql = new MySQLContainer();  

程式碼中啟動就是這麼簡單,一個最簡單Mysql容器就啟動了,默認的配置資訊如下:

    public static final String NAME = "mysql";      public static final String IMAGE = "mysql";      public static final String DEFAULT_TAG = "5.7.22";      private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF";      public static final Integer MYSQL_PORT = 3306;      private String databaseName = "test";      private String username = "test";      private String password = "test";      private static final String MYSQL_ROOT_USER = "root";  

接著在BeforeAll中調用 mysql.start(),容器便啟動了。

JDBC的方式更簡單,無需任何程式碼,直接在配置中指定驅動和url即可

spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver  spring.datasource.url=jdbc:tc:mysql:5.7.22:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction  

這裡要注意的幾個點

  • 驅動必須使用tc提供的
  • url中mysql之後跟的時版本號,對應可以理解為是dockerhub中mysql的鏡像版本號其實也是mysql的實際版本。
  • tc提供了兩種資料庫初始話方式,直接指定腳本 TC_INITSCRIPT ,或者指定程式碼初始化類 TC_INITFUNCTION這兩種方法是可以同時存在的

實際方案

項目中使用的時候上面兩種使用方式實際都不是很好,MySQLContainer經過了封裝可訂製的內容相對較少,JDBC的方式也是同樣的問題例如埠等配置都無法設定。
為了更加靈活,我們使用了最原始的基礎容器類來自己構建一個Mysql的容器。先直接給出程式碼。

      @ClassRule      public static GenericContainer mysql = new VvFixedHostPortGenericContainer(              new ImageFromDockerfile("mysql-vv-gms")                      .withDockerfileFromBuilder(dockerfileBuilder -> {                          dockerfileBuilder.from("mysql:8.0.0")                                  .env("MYSQL_ROOT_PASSWORD", "test")                                  .env("MYSQL_DATABASE", "test")                                  .env("MYSQL_USER", "test")                                  .env("MYSQL_PASSWORD", "test")                                  .add("my.cnf", "/etc/mysql/conf.d")                                  .add("db-schema.sql", "/docker-entrypoint-initdb.d")                          ;                      })                      .withFileFromClasspath("my.cnf", "my.cnf")                      .withFileFromClasspath("db-schema.sql", "db-schema.sql")      )              .withFixedExposedPort(3307, 3306)              .waitingFor(Wait.forListeningPort());  
package cn.vv.oa.init;    import lombok.NonNull;  import org.testcontainers.containers.GenericContainer;  import org.testcontainers.containers.InternetProtocol;    import java.util.concurrent.Future;    public class VvFixedHostPortGenericContainer<SELF extends VvFixedHostPortGenericContainer<SELF>> extends GenericContainer<SELF> {        public VvFixedHostPortGenericContainer(@NonNull final Future<String> image) {          super(image);      }        /**       * Bind a fixed TCP port on the docker host to a container port       *       * @param hostPort a port on the docker host, which must be available       * @param containerPort a port in the container       * @return this container       */      public SELF withFixedExposedPort(int hostPort, int containerPort) {            return withFixedExposedPort(hostPort, containerPort, InternetProtocol.TCP);      }        /**       * Bind a fixed port on the docker host to a container port       *       * @param hostPort a port on the docker host, which must be available       * @param containerPort a port in the container       * @param protocol an internet protocol (tcp or udp)       * @return this container       */      public SELF withFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol) {            super.addFixedExposedPort(hostPort, containerPort, protocol);            return self();      }  }  

第二個VvFixedHostPortGenericContainer其實可以不用特別關注,這個類僅僅是為了暴露出基礎容器類的指定埠方法,和通過構建Dockerfile生成鏡像的構造函數。關鍵還是看第一段聲明mysql容器的部分。
withDockerfileFromBuilder 這個方法,實際上就是指定了Dockerfile的構造方法,能夠暴露出的方法都是Dockerfile能夠編寫的命令,如果你了解docker這是很好的訂製化方式。其中add命令能夠添加的文件,是需要我們後面用withFileFromClasspath來映射的。
通過 withFixedExposedPort方法來指定暴露埠,mysql8之後會啟動兩個埠 3306和33060,我們目前只需要3306暴露即可。
這裡添加的兩個文件也是需要了解一下。
my.cnf文件是為了覆蓋mysql的默認配置,能夠解決資料庫編碼等底層設置問題,要注意的是add命令添加的文件路徑 /etc/mysql/conf.d 這樣才能初始化配置。
db-schem.sql是初始化資料庫腳本,添加在容器中的 /docker-entrypoint-initdb.d 路徑中就會自動執行,不過注意腳本只能添加一個。
順便吧my.cnf也貼上來吧,可能會影響資料庫的中文亂碼

[mysqld]  user = mysql  datadir = /var/lib/mysql  port = 3306  #socket = /tmp/mysql.sock  skip-external-locking  key_buffer_size = 16K  max_allowed_packet = 1M  table_open_cache = 4  sort_buffer_size = 64K  read_buffer_size = 256K  read_rnd_buffer_size = 256K  net_buffer_length = 2K  skip-host-cache  skip-name-resolve  character-set-server = utf8  collation-server = utf8_general_ci    # Don't listen on a TCP/IP port at all. This can be a security enhancement,  # if all processes that need to connect to mysqld run on the same host.  # All interaction with mysqld must be made via Unix sockets or named pipes.  # Note that using this option without enabling named pipes on Windows  # (using the "enable-named-pipe" option) will render mysqld useless!  #  #skip-networking  #server-id = 1    # Uncomment the following if you want to log updates  #log-bin=mysql-bin    # binary logging format - mixed recommended  #binlog_format=mixed    # Causes updates to non-transactional engines using statement format to be  # written directly to binary log. Before using this option make sure that  # there are no dependencies between transactional and non-transactional  # tables such as in the statement INSERT INTO t_myisam SELECT * FROM  # t_innodb; otherwise, slaves may diverge from the master.  #binlog_direct_non_transactional_updates=TRUE    # Uncomment the following if you are using InnoDB tables  innodb_data_file_path = ibdata1:10M:autoextend  # You can set .._buffer_pool_size up to 50 - 80 %  # of RAM but beware of setting memory usage too high  innodb_buffer_pool_size = 16M  #innodb_additional_mem_pool_size = 2M  # Set .._log_file_size to 25 % of buffer pool size  innodb_log_file_size = 5M  innodb_log_buffer_size = 8M  innodb_flush_log_at_trx_commit = 1  innodb_lock_wait_timeout = 50    [mysql.server]  default-character-set=utf8  [mysql_safe]  default-character-set=utf8  [client]  default-character-set=utf8  

完整類程式碼

package cn.vv.oa;    import cn.vv.OaApplication;  import cn.vv.fw.common.api.VvCurrentAccount;  import cn.vv.fw.common.utils.StringUtil;  import cn.vv.fw.common.utils.WebUtil;  import cn.vv.oa.api.org.vo.EmployeeVO;  import cn.vv.oa.common.aspectj.LogAspect;  import cn.vv.oa.common.filter.TokenAuthorFilters;  import cn.vv.oa.common.shiro.ShiroUtils;  import cn.vv.oa.common.utils.ServletUtils;  import cn.vv.oa.init.VvFixedHostPortGenericContainer;  import lombok.extern.slf4j.Slf4j;  import mockit.Mock;  import mockit.MockUp;  import mockit.Mocked;  import org.apache.ibatis.jdbc.ScriptRunner;  import org.apache.shiro.authz.aop.PermissionAnnotationHandler;  import org.junit.ClassRule;  import org.junit.jupiter.api.AfterAll;  import org.junit.jupiter.api.AfterEach;  import org.junit.jupiter.api.BeforeAll;  import org.junit.jupiter.api.BeforeEach;  import org.springframework.boot.test.context.SpringBootTest;  import org.springframework.transaction.annotation.Transactional;  import org.testcontainers.containers.GenericContainer;  import org.testcontainers.containers.wait.strategy.Wait;  import org.testcontainers.images.builder.ImageFromDockerfile;    import javax.annotation.Resource;  import javax.servlet.http.HttpServletRequest;  import javax.sql.DataSource;  import java.io.FileReader;    @SpringBootTest(classes = {OaApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  @Transactional  @Slf4j  public class BaseTest {        @ClassRule      public static GenericContainer mysql = new VvFixedHostPortGenericContainer(              new ImageFromDockerfile("mysql-vv-gms")                      .withDockerfileFromBuilder(dockerfileBuilder -> {                          dockerfileBuilder.from("mysql:8.0.0")                                  .env("MYSQL_ROOT_PASSWORD", "test")                                  .env("MYSQL_DATABASE", "test")                                  .env("MYSQL_USER", "test")                                  .env("MYSQL_PASSWORD", "test")                                  .add("my.cnf", "/etc/mysql/conf.d")                                  .add("db-schema.sql", "/docker-entrypoint-initdb.d")                          ;                      })                      .withFileFromClasspath("my.cnf", "my.cnf")                      .withFileFromClasspath("db-schema.sql", "db-schema.sql")      )              .withFixedExposedPort(3307, 3306)              .waitingFor(Wait.forListeningPort());        @Resource      protected DataSource dataSource;        @Mocked      PermissionAnnotationHandler permissionAnnotationHandler;      @Mocked      cn.vv.fw.boot.logger.RequestLogAspect RequestLogAspect;      @Mocked      TokenAuthorFilters tokenAuthorFilters;      @Mocked      HttpServletRequest getRequest;        @BeforeAll      protected static void beforeAll() {          mysql.start();            System.setProperty("spring.datasource.url", "jdbc:mysql://" + mysql.getContainerIpAddress() + ":3307/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2b8");          System.setProperty("spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver");          System.setProperty("spring.datasource.username", "test");          System.setProperty("spring.datasource.password", "test");            new MockUp<ShiroUtils>(ShiroUtils.class) {              @Mock              public EmployeeVO getEmployee() {                  EmployeeVO employeeVO = new EmployeeVO();                  employeeVO.setUserName("mock.UserName");                  employeeVO.setUserNo("mock.UserNo");                  employeeVO.setCompanyName("mock.CompanyName");                  employeeVO.setDepartmentName("mock.DepartmentName");                  return employeeVO;              }          };          new MockUp<LogAspect>(LogAspect.class) {              @Mock              public String getIp() {                  return "mock.ip";              }          };      }        @AfterAll      protected static void destroy() {          mysql.stop();      }        @BeforeEach      protected void beforeEach() {            new MockUp<WebUtil>(WebUtil.class) {              @Mock              public HttpServletRequest getRequest() {                  return getRequest;              }                @Mock              public VvCurrentAccount getCurrentAccount(Boolean isMustLogin) {                  VvCurrentAccount vvCurrentAccount = new VvCurrentAccount();                  vvCurrentAccount.setUserCode("mock.userCode");                  return vvCurrentAccount;              }          };          new MockUp<ServletUtils>(ServletUtils.class) {              @Mock              public HttpServletRequest getRequest() {                  return getRequest;              }          };              if (StringUtil.isNotBlank(this.getDbScript())) {              try {                  ScriptRunner runner = new ScriptRunner(dataSource.getConnection());                  runner.setErrorLogWriter(null);                  runner.setLogWriter(null);                  runner.runScript(new FileReader(this.getClass().getResource(this.getDbScript()).getPath()));              } catch (Exception e) {                  log.error("ScriptRunner error!", e);              }          }      }        @AfterEach      protected void afterEach() {      }        protected String getDbScript() {          return "";      }    }    

實現實例

以實際的公司的介面為例,我們的單元測試入口從Controller方法進入。

package cn.vv.oa.module.org.controller;    import cn.vv.fw.common.api.R;  import cn.vv.oa.BaseTest;  import cn.vv.oa.api.org.dto.CompanyDTO;  import cn.vv.oa.module.org.entity.Company;  import cn.vv.oa.module.org.repository.mapper.CompanyMapper;  import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;  import org.junit.jupiter.api.Test;    import javax.annotation.Resource;  import java.math.BigInteger;  import java.time.LocalDate;  import java.util.Arrays;  import java.util.List;  import java.util.Map;    import static org.junit.jupiter.api.Assertions.assertEquals;    public class CompanyControllerTest extends BaseTest {        @Resource      CompanyController companyController;        @Resource      CompanyMapper companyMapper;        @Test      public void getList() throws Exception {          List dtos = companyController.getList("100", "").getData();          assertEquals(((Map) (dtos.get(0))).get("companyName"), "VV科技集團");      }        @Test      void getAllList() {          List<Company> list = companyMapper.selectList(new LambdaQueryWrapper<Company>());          assertEquals(list.size(), 3);      }          @Test      void saveOrUpdate() throws Exception {          CompanyDTO companyDTO = CompanyDTO.builder()                  .companyName("VV日本公司")                  .parentId(new BigInteger("100"))                  .companyEmail("[email protected]")                  .companyArea(Arrays.asList("Japan"))                  .regTime(LocalDate.now())                  .build();            R r = companyController.saveOrUpdate(companyDTO);            List<Company> list = companyMapper.selectList(new LambdaQueryWrapper<Company>());          assertEquals(list.size(), 4);        }  }  

這個單元測試會覆蓋到controller、service、dao的各層程式碼。可以看到由Spring負責注入的還是使用原本的方式。
這裡要注意點在於單元測試的待測試方法調用後,由於我們需要通過落地數據來驗證,所以還需要注入對應的Mapper直接對資料庫進行搜索。這點會有些繞或者不直接。
這是一個穿透的例子。我們再來看一個隔離的例子。

      @Test      void save() {          R<AccountSimpleVO> r = new R<>();          AccountSimpleVO accountSimpleVO = new AccountSimpleVO();          accountSimpleVO.setUserCode("usercode");          r.setCode(ResultCode.SUCCESS.getCode());          r.setData(accountSimpleVO);            new Expectations() {{              userMapper.selectList((Wrapper<User>) any);              result = null;                userClient.getUserInfo((AccountDTO) any);              result = null;                userClient.registered((AccountDTO) any);              result = r;                companyMapper.selectOne((Wrapper<Company>) any);              Company company = new Company();              company.setCompanyArea("中國");              result = company;          }};            new MockUp<DictUtil>(DictUtil.class) {              @Mock              public Map<String, DictDTO> getDictNameMap(String code) {                  Map<String, DictDTO> r1 = new HashMap<>();                  DictDTO dictDTO = new DictDTO();                  dictDTO.setRemark("30");                  r1.put("美國", dictDTO);                  return r1;              }                @Mock              public Map<String, DictDTO> getDictMap(String code) {                  Map<String, DictDTO> r2 = new HashMap<>();                  DictDTO dictDTO = new DictDTO();                  dictDTO.setRemark("86");                  r2.put("中國", dictDTO);                  return r2;              }            };            Assertions.assertThrows(NullPointerException.class, () -> {              employeeService.save(new EmployeeDTO());          });      }  

這個例子就是單獨測試了一個service方法,可以看到mock了很多內外部的服務,包括底層的mapper都mock了,意味著數據讀取返回的內容都已經完全隔離了。

總結

單元測試,大家都有共識是程式碼品質的最重要的手段之一,但是我們需要真正「有價值」的單元測試。有價值意味著真正維護了項目品質,也能夠讓研發願意真正花費精力來編寫和維護測試用例。如果公司只看單元測試覆蓋率,實際上也是很好糊弄的,這就變成面子而沒有價值。研發去編寫單元測試只是為了績效,高覆蓋率,沒有為項目品質提升貢獻力量。
如果正在看這篇文章的你是一個Leader,那你一定是要親身上陣,帶領團隊認真的實施,引導團隊真正理解單元測試的寫法與價值。
我們的團隊也還在進行嘗試,在我們的測試中,產生有價值的單元測試,程式碼量是實際業務程式碼的2-3倍。而且在業務不穩定的時候,業務程式碼的維護同時還引發單元測試程式碼的修改,改程式碼的效率是寫程式碼效率的一半,成本是很高的。