走進Java介面測試之簡單解決寫介面臟數據問題

  • 2019 年 12 月 24 日
  • 筆記

前言

「臟」數據指數據在被實際使用前,已經被進行了非預期的修改:

  • 比如,我們在登錄介面中使用事先創建好的用戶進行測試,但這個用戶的密碼被之前的測試無意中修改了,導致測試用例執行時登錄失敗,也就不能順利完成測試了。那麼,此時這個測試用戶數據就成為了「臟」數據。
  • 再比如,我們在測試用例中使用事先創建的測試優惠券去完成訂單操作,但是由於某種原因這張優惠券已經被使用過了,導致訂單操作的失敗,也就意味著測試用例執行失敗。那麼,此時這個測試優惠券數據也是「臟」數據。

由此可見,這些事先創建好的測試數據( Out-of-box ),在測試用例執行的那個時刻,是否依然可用其實是不一定的,因為這些數據很有可能在被使用前已經發生了非預期的修改。

而這些非預期的修改主要來自於以下三個方面:

  • 其他測試用例,主要是寫介面使用了這些事先創建好的測試數據,並修改了這些數據的狀態;
  • 執行手工測試時,因為直接使用了事先創建好的數據,很有可能就會修改了某些測試數據;
  • 自動化測試用例的調試過程,修改了事先創建的測試數據;

為了解決這些「臟」數據,我們只能通過優化流程去控制數據的使用。本文主要針對解決第一種臟數據的情況,即針對所有寫介面服務端公用的數據,首先統一提前準備,提供一鍵準備/恢複測試數據的方法,儘可能減少因為環境/數據準備造成的時間浪費。

解法

主要步驟:

  1. 測試開始;
  2. 備份資料庫數據:執行寫介面用例前,先把原有業務表通過 rename 的方式整表備份(前置動作);
  3. 執行被測介面:準備測試數據,發起對被測 API 的 request(測試中);
  4. 介面返回值assert:驗證返回結果的 response(測試中);
  5. 數據變更assert:驗證資料庫變更結果(測試中);
  6. 清理數據表數據:清理產生的測試數據,恢復到前置動作備份的數據(後置動作)。
  7. 測試結束;

具體實現

這裡從 0 到 1 我演示一個向業務表插入新記錄的示例 demo。

開發環境

  • SUN JDK1.8及以上
  • Maven 3.5.4及以上
  • IntelliJ IDEA 2018及以上
  • windows/macOS
  • MySQL 5.7及以上
  • Navicat Premium 11.2.7及以上 或 SQLyog 11.3及以上

數據準備

這裡我們選用 MySQL 資料庫,首先需要構造一個測試表。

建表:

drop table t_coffee if exists;  create table t_coffee (      id bigint notnull auto_increment,  # 自增欄位      name varchar(255),      price bigint notnull,      create_time timestamp,      update_time timestamp,      primary key (id)  );

插入數據:

insert into t_coffee (name, price, create_time, update_time) values ('espresso', 2000, now(), now());  insert into t_coffee (name, price, create_time, update_time) values ('latte', 2500, now(), now());  insert into t_coffee (name, price, create_time, update_time) values ('capuccino', 2500, now(), now());  insert into t_coffee (name, price, create_time, update_time) values ('mocha', 3000, now(), now());  insert into t_coffee (name, price, create_time, update_time) values ('macchiato', 3000, now(), now());

初始化完成:

腳手架搭建

新建 Spring Boot 項目:

引包,配置 pom.xml:

<dependencies>  <!--MyBatis、資料庫驅動、資料庫連接池、logback-->  <dependency>  <groupId>org.mybatis.spring.boot</groupId>  <artifactId>mybatis-spring-boot-starter</artifactId>  <version>2.1.1</version>  </dependency>    <!--引入 testng 測試框架-->  <dependency>  <groupId>org.testng</groupId>  <artifactId>testng</artifactId>  <version>6.14.3</version>  <scope>compile</scope>  </dependency>    <!--money類型-->  <dependency>  <groupId>org.joda</groupId>  <artifactId>joda-money</artifactId>  <version>LATEST</version>  </dependency>    <!--mysql 驅動-->  <dependency>  <groupId>mysql</groupId>  <artifactId>mysql-connector-java</artifactId>  <scope>runtime</scope>  </dependency>    <!--mybatis-generator生成器-->  <dependency>  <groupId>org.mybatis.generator</groupId>  <artifactId>mybatis-generator-core</artifactId>  <version>1.3.7</version>  </dependency>    <!--lombok 插件-->  <dependency>  <groupId>org.projectlombok</groupId>  <artifactId>lombok</artifactId>  <optional>true</optional>  </dependency>  <dependency>  <groupId>org.springframework.boot</groupId>  <artifactId>spring-boot-starter-test</artifactId>  <scope>test</scope>  <exclusions>  <exclusion>  <groupId>org.junit.vintage</groupId>  <artifactId>junit-vintage-engine</artifactId>  </exclusion>  </exclusions>  </dependency>  </dependencies>

搭建程式碼骨架結構:

├─src  │  ├─main  │  │  ├─java  │  │  │  └─com  │  │  │      └─zuozewei  │  │  │          └─SpringbootDataBackupRecoveryDemoApplication  │  │  │              │  SpringbootDataBackupRecoveryDemoApplication.java # 啟動類  │  │  │              │  │  │  │              ├─db  │  │  │              │  ├─auto      # 存放MyBatis Generator生成器生成的數據層程式碼,可以隨時刪除再生成  │  │  │              │  │  ├─mapper # DAO 介面  │  │  │              │  │  └─model  # Entity 實體  │  │  │              │  └─manual    # 存放自定義的數據層程式碼,包括對MyBatis Generator自動生成程式碼的擴展  │  │  │              │      ├─mapper # DAO 介面  │  │  │              │      └─model  # Entity 實體  │  │  │              ├─handler  # 數據轉換  │  │  │              └─service # 業務邏輯  │  │  │                  └─impl # 實現類  │  │  │  │  │  └─resources  │  │      │  application.yml       # 全局配置文件  │  │      │  generatorConfig.xml # Mybatis Generator 配置文件  │  │      ├─db  │  │      ├─mapper  │  │      │  └─com  │  │      │      └─zuozewei  │  │      │          └─SpringbootDataBackupRecoveryDemoApplication  │  │      │              └─db  │  │      │                  ├─auto      # 存放MyBatis Generator生成器生成的數據層程式碼,可以隨時刪除再生成  │  │      │                  │  └─mapper # 資料庫 Mapping 文件  │  │      │                  │  │  │      │                  └─manual    # 存放自定義的數據層程式碼,包括對MyBatis Generator自動生成程式碼的擴展  │  │      │                      └─mapper # 資料庫 Mapping 文件  │  │  │  └─test  │      └─java  │          └─com  │              └─zuozewei  │                  └─springbootdatadrivendemo  │                      └─demo   # 測試用例  ├─pom.xml

業務持久層

處理自定義類型

這裡的 price 我們擴展了自定義類型,所以我們需要使用 TypeHandler 解決自定義類型預處理。因為 price 是joda-money 類型,資料庫中卻是 bigint 類型。MyBatis 為我們提供的方法即是 TypeHandler 來應對 Java 和 jdbc 欄位類型不匹配的情況。MyBatis 中內置了不少的TypeHandler,如果我們想要自己自定義一個 TypeHandler 可以實現 TypeHandler 介面,也可以繼承 BaseTypeHandler 類。下面我們實現一個將 Java 中的 joda-money 類型利用我們自定義的 MoneyTypeHandler 來轉換為 JDBC 的 bigint 類型。

新建一個 handler package,編寫 MoneyTypeHandler.java:

/**   * 在 Money 與 Long 之間轉換的 TypeHandler,處理 CNY 人民幣   */    publicclassMoneyTypeHandlerextendsBaseTypeHandler<Money> {    /**       *  設置非空參數       * @param ps       * @param i       * @param parameter       * @param jdbcType       * @throws SQLException       */  @Override  publicvoid setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throwsSQLException{          ps.setLong(i, parameter.getAmountMinorLong());  }    /**       * 根據列名,獲取可以為空的結果       * @param rs       * @param columnName       * @return       * @throws SQLException       */  @Override  publicMoney getNullableResult(ResultSet rs, String columnName) throwsSQLException{  return parseMoney(rs.getLong(columnName));  }    /**       * 根據列索引,獲取可以為空的結果       * @param rs       * @param columnIndex       * @return       * @throws SQLException       */  @Override  publicMoney getNullableResult(ResultSet rs, int columnIndex) throwsSQLException{  return parseMoney(rs.getLong(columnIndex));  }    /**       *       * @param cs       * @param columnIndex       * @return       * @throws SQLException       */  @Override  publicMoney getNullableResult(CallableStatement cs, int columnIndex) throwsSQLException{  return parseMoney(cs.getLong(columnIndex));  }    /**       *  處理 CNY 人民幣       * @param value       * @return       */  privateMoney parseMoney(Long value) {  returnMoney.of(CurrencyUnit.of("CNY"), value / 100.0);  }  }

使用 mybatis-generator

MyBatis Generator是 MyBatis 的程式碼生成器,支援為 MyBatis 的所有版本生成程式碼。非常容易及快速生成 Mybatis 的Java POJO文件及資料庫 Mapping 文件。

配置 generatorConfig.xml:

<?xml version="1.0" encoding="UTF-8"?>  <!DOCTYPE generatorConfiguration          PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"          "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">    <generatorConfiguration>  <contextid="MySQLTables"targetRuntime="MyBatis3">  <!--支援流式 fluent 方法-->  <plugintype="org.mybatis.generator.plugins.FluentBuilderMethodsPlugin"/>  <!-- 自動生成toString方法 -->  <plugintype="org.mybatis.generator.plugins.ToStringPlugin"/>  <!-- 自動生成hashcode方法 -->  <plugintype="org.mybatis.generator.plugins.SerializablePlugin"/>  <!-- 分頁插件 -->  <plugintype="org.mybatis.generator.plugins.RowBoundsPlugin"/>    <!--資料庫連接資訊-->  <jdbcConnectiondriverClass="com.mysql.cj.jdbc.Driver"  connectionURL="jdbc:mysql://localhost:3306/zuozewei?useUnicode=true&amp;characterEncoding=UTF-8&amp;useJDBCCompliantTimezoneShift=true&amp;useLegacyDatetimeCode=false&amp;serverTimezone=UTC&amp;useSSL=false"  userId="zuozewei"  password="123456">  </jdbcConnection>    <!--模型生成器、Mapper生成器-->  <javaModelGeneratortargetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.model"  targetProject="./src/main/java">  <propertyname="enableSubPackages"value="true"/>  <propertyname="trimStrings"value="true"/>  </javaModelGenerator>  <sqlMapGeneratortargetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.mapper"  targetProject="./src/main/resources/mapper">  <propertyname="enableSubPackages"value="true"/>  </sqlMapGenerator>  <javaClientGeneratortype="MIXEDMAPPER"  targetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.mapper"  targetProject="./src/main/java">  <propertyname="enableSubPackages"value="true"/>  </javaClientGenerator>    <!--表映射-->  <tabletableName="t_coffee"domainObjectName="Coffee">  <generatedKeycolumn="id"sqlStatement="SELECT LAST_INSERT_ID()"identity="true"/>  <columnOverridecolumn="price"javaType="org.joda.money.Money"jdbcType="BIGINT"  typeHandler="com.zuozewei.springbootdatabackuprecoverydemo.handler.MoneyTypeHandler"/>  </table>  </context>  </generatorConfiguration>

注意:

  • id 是自增的;
  • price 欄位需要映射到 MoneyTypeHandler。

啟動方法

在工程啟動類編寫一個調用方法:

@Slf4j  @SpringBootApplication  @MapperScan("com.zuozewei.springbootdatabackuprecoverydemo.db")  publicclassSpringbootDataBackupRecoveryDemoApplicationimplementsApplicationRunner{    publicstaticvoid main(String[] args) {  SpringApplication.run(SpringbootDataBackupRecoveryDemoApplication.class, args);          log.info("程式啟動!");  }    @Override  publicvoid run(ApplicationArguments args) throwsException{          generateArtifacts();          log.info("啟動generateArtifacts");  }    /**       * 執行MyBatisGenerator       * @throws Exception       */  privatevoid generateArtifacts() throwsException{  List<String> warnings = newArrayList<>();  ConfigurationParser cp = newConfigurationParser(warnings);  Configuration config = cp.parseConfiguration(  this.getClass().getResourceAsStream("/generatorConfig.xml"));  DefaultShellCallback callback = newDefaultShellCallback(true);  MyBatisGenerator myBatisGenerator = newMyBatisGenerator(config, callback, warnings);          myBatisGenerator.generate(null);  }  }

啟動工程:

檢查配置文件指定路徑是否生成文件:

實現Service方法

在 service package 下新建 Service 介面 CoffeeService.java:

/**   * 描述: coffee Service   *   * @author zuozewei   * @create 2019-11-21 18:00   */    publicinterfaceCoffeeService{    // 插入  int addCoffee(Coffee coffee);    // 查詢  List selectCoffeeFromDs(CoffeeExample coffeeExample) throwsInterruptedException;    }

實現 CoffeeService 介面,新建 CoffeeServiceImpl.java:

/**   * 描述: CoffeeService 實現類   *   * @author zuozewei   * @create 2019-11-21 18:00   */    @Service  publicclassCoffeeServiceImplimplementsCoffeeService{    @Resource  privateCoffeeMapper coffeeMapper;    @Override  publicint addCoffee(Coffee coffee) {  return coffeeMapper.insert(coffee);  }    @Override  publicList selectCoffeeFromDs(CoffeeExample coffeeExample) throwsInterruptedException{  return coffeeMapper.selectByExample(coffeeExample);  }    }

配置 mybatis

在 application.yml 中配置 mybatis

spring:    datasource:      url: jdbc:mysql://localhost:3306/zuozewei?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&useSSL=false      username: zuozewei      password: 123456  mybatis:    type-aliases-package: com.zuozewei.springbootdatabackuprecoverydemo.db # 自動掃描實體類所在的包    type-handlers-package: com.zuozewei.springbootdatabackuprecoverydemo.handler # 指定 TypeHandler 所在的包    configuration:      map-underscore-to-camel-case: true# 開啟駝峰功能    mapper-locations: classpath*:/mapper/**/*.xml # 掃描類路徑下所有以xml文件結尾的文件

數據備份&恢復開發

這裡使用 MyBatis 實現對錶進行 DML(insert, delete, update等) 和 DDL(create, alter, drop)操作。

Mapper.xml

編寫對應的 TestDataMapper.xml:

<?xml version="1.0" encoding="UTF-8"?>  <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >  <mappernamespace="com.zuozewei.springbootdatabackuprecoverydemo.db.manual.mapper.TestDataMapper">    <!--修改資料庫的表名字-->  <updateid="alterTableName">          alter table ${originalTableName} rename to ${newTableName}  </update>    <!--drop指定資料庫表的數據-->  <updateid="dropTable">          drop table ${tableName}  </update>    </mapper>

注意:

  • alterTableName:不同的資料庫可能存在語法不一致的情況。

Dao介面

dao 層增加 TestDataMapper.java:

/**   * 描述:   * 執行資料庫相關測試表的Mapper   *   * @author zuozewei   * @create 2019-11-21   */    publicinterfaceTestDataMapper{    /**       * 修改資料庫的表名字       * @param originalTableName       * @param newTableName       * @return       */  int alterTableName(@Param("originalTableName") String originalTableName,  @Param("newTableName") String newTableName);    /**       * drop指定資料庫表的數據       * @param tableName       * @return       */  int dropTable(@Param("tableName") String tableName);    /**       * 根據傳入的表明,創建新的表並且將原表的數據插入到新的表中       * @param newTableName       * @param originalTableName       */  void createNewTableAndInsertData(@Param("newTableName") String newTableName,  @Param("originalTableName") String originalTableName);  }

Service 的介面 TestDataService :

/**   * 描述: TestDataService   *   * @author zuozewei   * @create 2019-11-21   */    publicinterfaceTestDataService{    /**      * 準備資料庫數據      * @param tableName      */  void createTableData(String tableName);      /**      * 清理資料庫數據      * @param tableName      */  void recycleTableData(String tableName);  }

實現 Service 的介面調用方法:

/**   * 描述: TestDataService 實現類   *   * @author zuozewei   * @create 2019-11-21   */    @Service  publicclassTestDataServiceImplimplementsTestDataService{    @Resource  privateTestDataMapper testDataMapper;    /**       * 準備資料庫數據       * @param tableName       */  @Override  publicvoid createTableData(String tableName) {  // 新表名  String newTableName = tableName + "_bak";  // 源表名  String originalTableName = tableName;    // 創建測試表並複製數據          testDataMapper.createNewTableAndInsertData(newTableName,originalTableName);  }      /**       * 清理資料庫數據       * @param tableName       */  @Override  publicvoid recycleTableData(String tableName) {  // 新表名  String newTableName = tableName ;  // 源表名  String originalTableName = tableName + "_bak";    // 刪除測試表          testDataMapper.dropTable(tableName);  // 恢復備份表          testDataMapper.alterTableName(originalTableName,newTableName);  }  }

測試

新建一個測試類,TestMapperService:

@SpringBootTest  @Slf4j  publicclassTestMapperServiceextendsAbstractTestNGSpringContextTests{    privateString tableName = "t_coffee"; //表名    @Autowired  privateCoffeeService coffeeService;    @Autowired  privateTestDataService testDataService;    @BeforeMethod(description = "備份及準備測試數據")  publicvoid beforeMethod() {          testDataService.createTableData(tableName);  }    @Test(description = "測試demo")  publicvoid testSelect() throwsInterruptedException{    // 插入數據  Coffee espresso = newCoffee()  .withName("zuozewei")  .withPrice(Money.of(CurrencyUnit.of("CNY"), 20.0))  .withCreateTime(newDate())  .withUpdateTime(newDate());          coffeeService.addCoffee(espresso);    CoffeeExample example = newCoffeeExample();    // 指定查詢條件          example.createCriteria().andNameEqualTo("zuozewei");    // 查詢數據  List<Coffee> list = coffeeService.selectCoffeeFromDs(example);            list.forEach(e -> log.info("selectByExample: {}", e));    // 篩選指定屬性  List<Money> moneys = list.stream().map(Coffee::getPrice).collect(Collectors.toList());          log.info( moneys.get(0).toString() );    // 斷言結果  Assert.assertEquals("CNY 20.00",moneys.get(0).toString());    }    @AfterMethod(description = "清理及恢複數據")  publicvoid afterMethod() {          testDataService.recycleTableData(tableName);  }    }

注意:

  • SpringBoot 中使用 TestNg必須加上 @SpringBootTest,並且繼承 AbstractTestNGSpringContextTests,如果不繼承 AbstractTestNGSpringContextTests,會導致 @Autowired 不能載入 Bean;
  • @Test:測試邏輯地方;
  • 數據備份及清理調用只能放在 @BeforeMethod/@AfterMethod 註解。

最後就是跑測了,我們先看下數據:

執行測試:

測試完成後,我們再檢查下業務數據:

我們看到數據被成功恢復了。

小結

本文主要提出一種簡單的解決方案,針對所有寫介面服務端公用的數據,統一提前準備,跑測的時候提供一鍵準備/恢複測試數據的方法,儘可能減少因為環境/數據準備造成的時間浪費。

希望能都對你有所啟發。

Tags: