走進Java介面測試之簡單解決寫介面臟數據問題
- 2019 年 12 月 24 日
- 筆記

前言
「臟」數據指數據在被實際使用前,已經被進行了非預期的修改:
- 比如,我們在登錄介面中使用事先創建好的用戶進行測試,但這個用戶的密碼被之前的測試無意中修改了,導致測試用例執行時登錄失敗,也就不能順利完成測試了。那麼,此時這個測試用戶數據就成為了「臟」數據。
- 再比如,我們在測試用例中使用事先創建的測試優惠券去完成訂單操作,但是由於某種原因這張優惠券已經被使用過了,導致訂單操作的失敗,也就意味著測試用例執行失敗。那麼,此時這個測試優惠券數據也是「臟」數據。
由此可見,這些事先創建好的測試數據( Out-of-box ),在測試用例執行的那個時刻,是否依然可用其實是不一定的,因為這些數據很有可能在被使用前已經發生了非預期的修改。
而這些非預期的修改主要來自於以下三個方面:
- 其他測試用例,主要是寫介面使用了這些事先創建好的測試數據,並修改了這些數據的狀態;
- 執行手工測試時,因為直接使用了事先創建好的數據,很有可能就會修改了某些測試數據;
- 自動化測試用例的調試過程,修改了事先創建的測試數據;
為了解決這些「臟」數據,我們只能通過優化流程去控制數據的使用。本文主要針對解決第一種臟數據的情況,即針對所有寫介面服務端公用的數據,首先統一提前準備,提供一鍵準備/恢複測試數據的方法,儘可能減少因為環境/數據準備造成的時間浪費。
解法

主要步驟:
- 測試開始;
- 備份資料庫數據:執行寫介面用例前,先把原有業務表通過 rename 的方式整表備份(前置動作);
- 執行被測介面:準備測試數據,發起對被測 API 的 request(測試中);
- 介面返回值assert:驗證返回結果的 response(測試中);
- 數據變更assert:驗證資料庫變更結果(測試中);
- 清理數據表數據:清理產生的測試數據,恢復到前置動作備份的數據(後置動作)。
- 測試結束;
具體實現
這裡從 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&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&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註解。
最後就是跑測了,我們先看下數據:

執行測試:

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

我們看到數據被成功恢復了。
小結
本文主要提出一種簡單的解決方案,針對所有寫介面服務端公用的數據,統一提前準備,跑測的時候提供一鍵準備/恢複測試數據的方法,儘可能減少因為環境/數據準備造成的時間浪費。
希望能都對你有所啟發。

