前言
“脏”数据指数据在被实际使用前,已经被进行了非预期的修改:
- 比如,我们在登录接口中使用事先创建好的用户进行测试,但这个用户的密码被之前的测试无意中修改了,导致测试用例执行时登录失败,也就不能顺利完成测试了。那么,此时这个测试用户数据就成为了“脏”数据。
- 再比如,我们在测试用例中使用事先创建的测试优惠券去完成订单操作,但是由于某种原因这张优惠券已经被使用过了,导致订单操作的失败,也就意味着测试用例执行失败。那么,此时这个测试优惠券数据也是“脏”数据。
由此可见,这些事先创建好的测试数据( 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 数据库,首先需要构造一个测试表。
建表:
1. drop table t_coffee if exists;
2. create table t_coffee (
3. id bigint notnull auto_increment, # 自增字段
4. name varchar(255),
5. price bigint notnull,
6. create_time timestamp,
7. update_time timestamp,
8. primary key (id)
9. );
插入数据:
1. insert into t_coffee (name, price, create_time, update_time) values ('espresso', 2000, now(), now());
2. insert into t_coffee (name, price, create_time, update_time) values ('latte', 2500, now(), now());
3. insert into t_coffee (name, price, create_time, update_time) values ('capuccino', 2500, now(), now());
4. insert into t_coffee (name, price, create_time, update_time) values ('mocha', 3000, now(), now());
5. insert into t_coffee (name, price, create_time, update_time) values ('macchiato', 3000, now(), now());
初始化完成:
脚手架搭建
新建 Spring Boot 项目: 引包,配置 pom.xml:
1. <dependencies>
2. <!--MyBatis、数据库驱动、数据库连接池、logback-->
3. <dependency>
4. <groupId>org.mybatis.spring.boot</groupId>
5. <artifactId>mybatis-spring-boot-starter</artifactId>
6. <version>2.1.1</version>
7. </dependency>
8.
9. <!--引入 testng 测试框架-->
10. <dependency>
11. <groupId>org.testng</groupId>
12. <artifactId>testng</artifactId>
13. <version>6.14.3</version>
14. <scope>compile</scope>
15. </dependency>
16.
17. <!--money类型-->
18. <dependency>
19. <groupId>org.joda</groupId>
20. <artifactId>joda-money</artifactId>
21. <version>LATEST</version>
22. </dependency>
23.
24. <!--mysql 驱动-->
25. <dependency>
26. <groupId>mysql</groupId>
27. <artifactId>mysql-connector-java</artifactId>
28. <scope>runtime</scope>
29. </dependency>
30.
31. <!--mybatis-generator生成器-->
32. <dependency>
33. <groupId>org.mybatis.generator</groupId>
34. <artifactId>mybatis-generator-core</artifactId>
35. <version>1.3.7</version>
36. </dependency>
37.
38. <!--lombok 插件-->
39. <dependency>
40. <groupId>org.projectlombok</groupId>
41. <artifactId>lombok</artifactId>
42. <optional>true</optional>
43. </dependency>
44. <dependency>
45. <groupId>org.springframework.boot</groupId>
46. <artifactId>spring-boot-starter-test</artifactId>
47. <scope>test</scope>
48. <exclusions>
49. <exclusion>
50. <groupId>org.junit.vintage</groupId>
51. <artifactId>junit-vintage-engine</artifactId>
52. </exclusion>
53. </exclusions>
54. </dependency>
55. </dependencies>
搭建代码骨架结构:
1. ├─src
2. │ ├─main
3. │ │ ├─java
4. │ │ │ └─com
5. │ │ │ └─zuozewei
6. │ │ │ └─SpringbootDataBackupRecoveryDemoApplication
7. │ │ │ │ SpringbootDataBackupRecoveryDemoApplication.java # 启动类
8. │ │ │ │
9. │ │ │ ├─db
10. │ │ │ │ ├─auto # 存放MyBatis Generator生成器生成的数据层代码,可以随时删除再生成
11. │ │ │ │ │ ├─mapper # DAO 接口
12. │ │ │ │ │ └─model # Entity 实体
13. │ │ │ │ └─manual # 存放自定义的数据层代码,包括对MyBatis Generator自动生成代码的扩展
14. │ │ │ │ ├─mapper # DAO 接口
15. │ │ │ │ └─model # Entity 实体
16. │ │ │ ├─handler # 数据转换
17. │ │ │ └─service # 业务逻辑
18. │ │ │ └─impl # 实现类
19. │ │ │
20. │ │ └─resources
21. │ │ │ application.yml # 全局配置文件
22. │ │ │ generatorConfig.xml # Mybatis Generator 配置文件
23. │ │ ├─db
24. │ │ ├─mapper
25. │ │ │ └─com
26. │ │ │ └─zuozewei
27. │ │ │ └─SpringbootDataBackupRecoveryDemoApplication
28. │ │ │ └─db
29. │ │ │ ├─auto # 存放MyBatis Generator生成器生成的数据层代码,可以随时删除再生成
30. │ │ │ │ └─mapper # 数据库 Mapping 文件
31. │ │ │ │
32. │ │ │ └─manual # 存放自定义的数据层代码,包括对MyBatis Generator自动生成代码的扩展
33. │ │ │ └─mapper # 数据库 Mapping 文件
34. │ │
35. │ └─test
36. │ └─java
37. │ └─com
38. │ └─zuozewei
39. │ └─springbootdatadrivendemo
40. │ └─demo # 测试用例
41. ├─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:
1. /**
2. * 在 Money 与 Long 之间转换的 TypeHandler,处理 CNY 人民币
3. */
4.
5. publicclassMoneyTypeHandlerextendsBaseTypeHandler<Money> {
6.
7. /**
8. * 设置非空参数
9. * @param ps
10. * @param i
11. * @param parameter
12. * @param jdbcType
13. * @throws SQLException
14. */
15. @Override
16. publicvoid setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throwsSQLException{
17. ps.setLong(i, parameter.getAmountMinorLong());
18. }
19.
20. /**
21. * 根据列名,获取可以为空的结果
22. * @param rs
23. * @param columnName
24. * @return
25. * @throws SQLException
26. */
27. @Override
28. publicMoney getNullableResult(ResultSet rs, String columnName) throwsSQLException{
29. return parseMoney(rs.getLong(columnName));
30. }
31.
32. /**
33. * 根据列索引,获取可以为空的结果
34. * @param rs
35. * @param columnIndex
36. * @return
37. * @throws SQLException
38. */
39. @Override
40. publicMoney getNullableResult(ResultSet rs, int columnIndex) throwsSQLException{
41. return parseMoney(rs.getLong(columnIndex));
42. }
43.
44. /**
45. *
46. * @param cs
47. * @param columnIndex
48. * @return
49. * @throws SQLException
50. */
51. @Override
52. publicMoney getNullableResult(CallableStatement cs, int columnIndex) throwsSQLException{
53. return parseMoney(cs.getLong(columnIndex));
54. }
55.
56. /**
57. * 处理 CNY 人民币
58. * @param value
59. * @return
60. */
61. privateMoney parseMoney(Long value) {
62. returnMoney.of(CurrencyUnit.of("CNY"), value / 100.0);
63. }
64. }
使用 mybatis-generator
MyBatis Generator是 MyBatis 的代码生成器,支持为 MyBatis 的所有版本生成代码。非常容易及快速生成 Mybatis 的Java POJO文件及数据库 Mapping 文件。
配置 generatorConfig.xml
:
1. <?xml version="1.0" encoding="UTF-8"?>
2. <!DOCTYPE generatorConfiguration
3. PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
4. "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
5.
6. <generatorConfiguration>
7. <contextid="MySQLTables"targetRuntime="MyBatis3">
8. <!--支持流式 fluent 方法-->
9. <plugintype="org.mybatis.generator.plugins.FluentBuilderMethodsPlugin"/>
10. <!-- 自动生成toString方法 -->
11. <plugintype="org.mybatis.generator.plugins.ToStringPlugin"/>
12. <!-- 自动生成hashcode方法 -->
13. <plugintype="org.mybatis.generator.plugins.SerializablePlugin"/>
14. <!-- 分页插件 -->
15. <plugintype="org.mybatis.generator.plugins.RowBoundsPlugin"/>
16.
17. <!--数据库连接信息-->
18. <jdbcConnectiondriverClass="com.mysql.cj.jdbc.Driver"
19. connectionURL="jdbc:mysql://localhost:3306/zuozewei?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&useSSL=false"
20. userId="zuozewei"
21. password="123456">
22. </jdbcConnection>
23.
24. <!--模型生成器、Mapper生成器-->
25. <javaModelGeneratortargetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.model"
26. targetProject="./src/main/java">
27. <propertyname="enableSubPackages"value="true"/>
28. <propertyname="trimStrings"value="true"/>
29. </javaModelGenerator>
30. <sqlMapGeneratortargetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.mapper"
31. targetProject="./src/main/resources/mapper">
32. <propertyname="enableSubPackages"value="true"/>
33. </sqlMapGenerator>
34. <javaClientGeneratortype="MIXEDMAPPER"
35. targetPackage="com.zuozewei.springbootdatabackuprecoverydemo.db.auto.mapper"
36. targetProject="./src/main/java">
37. <propertyname="enableSubPackages"value="true"/>
38. </javaClientGenerator>
39.
40. <!--表映射-->
41. <tabletableName="t_coffee"domainObjectName="Coffee">
42. <generatedKeycolumn="id"sqlStatement="SELECT LAST_INSERT_ID()"identity="true"/>
43. <columnOverridecolumn="price"javaType="org.joda.money.Money"jdbcType="BIGINT"
44. typeHandler="com.zuozewei.springbootdatabackuprecoverydemo.handler.MoneyTypeHandler"/>
45. </table>
46. </context>
47. </generatorConfiguration>
注意:
- id 是自增的;
- price 字段需要映射到 MoneyTypeHandler。
启动方法
在工程启动类编写一个调用方法:
1. @Slf4j
2. @SpringBootApplication
3. @MapperScan("com.zuozewei.springbootdatabackuprecoverydemo.db")
4. publicclassSpringbootDataBackupRecoveryDemoApplicationimplementsApplicationRunner{
5.
6. publicstaticvoid main(String[] args) {
7. SpringApplication.run(SpringbootDataBackupRecoveryDemoApplication.class, args);
8. log.info("程序启动!");
9. }
10.
11. @Override
12. publicvoid run(ApplicationArguments args) throwsException{
13. generateArtifacts();
14. log.info("启动generateArtifacts");
15. }
16.
17. /**
18. * 执行MyBatisGenerator
19. * @throws Exception
20. */
21. privatevoid generateArtifacts() throwsException{
22. List<String> warnings = newArrayList<>();
23. ConfigurationParser cp = newConfigurationParser(warnings);
24. Configuration config = cp.parseConfiguration(
25. this.getClass().getResourceAsStream("/generatorConfig.xml"));
26. DefaultShellCallback callback = newDefaultShellCallback(true);
27. MyBatisGenerator myBatisGenerator = newMyBatisGenerator(config, callback, warnings);
28. myBatisGenerator.generate(null);
29. }
30. }
启动工程: 检查配置文件指定路径是否生成文件:
实现Service方法
在 service package 下新建 Service 接口 CoffeeService.java
:
1. /**
2. * 描述: coffee Service
3. *
4. * @author zuozewei
5. * @create 2019-11-21 18:00
6. */
7.
8. publicinterfaceCoffeeService{
9.
10. // 插入
11. int addCoffee(Coffee coffee);
12.
13. // 查询
14. List selectCoffeeFromDs(CoffeeExample coffeeExample) throwsInterruptedException;
15.
16. }
实现 CoffeeService 接口,新建 CoffeeServiceImpl.java:
1. /**
2. * 描述: CoffeeService 实现类
3. *
4. * @author zuozewei
5. * @create 2019-11-21 18:00
6. */
7.
8. @Service
9. publicclassCoffeeServiceImplimplementsCoffeeService{
10.
11. @Resource
12. privateCoffeeMapper coffeeMapper;
13.
14. @Override
15. publicint addCoffee(Coffee coffee) {
16. return coffeeMapper.insert(coffee);
17. }
18.
19. @Override
20. publicList selectCoffeeFromDs(CoffeeExample coffeeExample) throwsInterruptedException{
21. return coffeeMapper.selectByExample(coffeeExample);
22. }
23.
24. }
配置 mybatis
在 application.yml
中配置 mybatis
1. spring:
2. datasource:
3. url: jdbc:mysql://localhost:3306/zuozewei?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&useSSL=false
4. username: zuozewei
5. password: 123456
6. mybatis:
7. type-aliases-package: com.zuozewei.springbootdatabackuprecoverydemo.db # 自动扫描实体类所在的包
8. type-handlers-package: com.zuozewei.springbootdatabackuprecoverydemo.handler # 指定 TypeHandler 所在的包
9. configuration:
10. map-underscore-to-camel-case: true# 开启驼峰功能
11. mapper-locations: classpath*:/mapper/**/*.xml # 扫描类路径下所有以xml文件结尾的文件
数据备份&恢复开发
这里使用 MyBatis 实现对表进行 DML(insert, delete, update等) 和 DDL(create, alter, drop)操作。
Mapper.xml
编写对应的 TestDataMapper.xml:
1. <?xml version="1.0" encoding="UTF-8"?>
2. <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
3. <mappernamespace="com.zuozewei.springbootdatabackuprecoverydemo.db.manual.mapper.TestDataMapper">
4.
5. <!--修改数据库的表名字-->
6. <updateid="alterTableName">
7. alter table ${originalTableName} rename to ${newTableName}
8. </update>
9.
10. <!--drop指定数据库表的数据-->
11. <updateid="dropTable">
12. drop table ${tableName}
13. </update>
14.
15. </mapper>
注意:
- alterTableName:不同的数据库可能存在语法不一致的情况。
Dao接口
dao 层增加 TestDataMapper.java:
1. /**
2. * 描述:
3. * 执行数据库相关测试表的Mapper
4. *
5. * @author zuozewei
6. * @create 2019-11-21
7. */
8.
9. publicinterfaceTestDataMapper{
10.
11. /**
12. * 修改数据库的表名字
13. * @param originalTableName
14. * @param newTableName
15. * @return
16. */
17. int alterTableName(@Param("originalTableName") String originalTableName,
18. @Param("newTableName") String newTableName);
19.
20. /**
21. * drop指定数据库表的数据
22. * @param tableName
23. * @return
24. */
25. int dropTable(@Param("tableName") String tableName);
26.
27. /**
28. * 根据传入的表明,创建新的表并且将原表的数据插入到新的表中
29. * @param newTableName
30. * @param originalTableName
31. */
32. void createNewTableAndInsertData(@Param("newTableName") String newTableName,
33. @Param("originalTableName") String originalTableName);
34. }
Service 的接口 TestDataService :
1. /**
2. * 描述: TestDataService
3. *
4. * @author zuozewei
5. * @create 2019-11-21
6. */
7.
8. publicinterfaceTestDataService{
9.
10. /**
11. * 准备数据库数据
12. * @param tableName
13. */
14. void createTableData(String tableName);
15.
16.
17. /**
18. * 清理数据库数据
19. * @param tableName
20. */
21. void recycleTableData(String tableName);
22. }
实现 Service 的接口调用方法:
1. /**
2. * 描述: TestDataService 实现类
3. *
4. * @author zuozewei
5. * @create 2019-11-21
6. */
7.
8. @Service
9. publicclassTestDataServiceImplimplementsTestDataService{
10.
11. @Resource
12. privateTestDataMapper testDataMapper;
13.
14. /**
15. * 准备数据库数据
16. * @param tableName
17. */
18. @Override
19. publicvoid createTableData(String tableName) {
20. // 新表名
21. String newTableName = tableName + "_bak";
22. // 源表名
23. String originalTableName = tableName;
24.
25. // 创建测试表并复制数据
26. testDataMapper.createNewTableAndInsertData(newTableName,originalTableName);
27. }
28.
29.
30. /**
31. * 清理数据库数据
32. * @param tableName
33. */
34. @Override
35. publicvoid recycleTableData(String tableName) {
36. // 新表名
37. String newTableName = tableName ;
38. // 源表名
39. String originalTableName = tableName + "_bak";
40.
41. // 删除测试表
42. testDataMapper.dropTable(tableName);
43. // 恢复备份表
44. testDataMapper.alterTableName(originalTableName,newTableName);
45. }
46. }
测试
新建一个测试类,TestMapperService:
1. @SpringBootTest
2. @Slf4j
3. publicclassTestMapperServiceextendsAbstractTestNGSpringContextTests{
4.
5. privateString tableName = "t_coffee"; //表名
6.
7. @Autowired
8. privateCoffeeService coffeeService;
9.
10. @Autowired
11. privateTestDataService testDataService;
12.
13. @BeforeMethod(description = "备份及准备测试数据")
14. publicvoid beforeMethod() {
15. testDataService.createTableData(tableName);
16. }
17.
18. @Test(description = "测试demo")
19. publicvoid testSelect() throwsInterruptedException{
20.
21. // 插入数据
22. Coffee espresso = newCoffee()
23. .withName("zuozewei")
24. .withPrice(Money.of(CurrencyUnit.of("CNY"), 20.0))
25. .withCreateTime(newDate())
26. .withUpdateTime(newDate());
27. coffeeService.addCoffee(espresso);
28.
29. CoffeeExample example = newCoffeeExample();
30.
31. // 指定查询条件
32. example.createCriteria().andNameEqualTo("zuozewei");
33.
34. // 查询数据
35. List<Coffee> list = coffeeService.selectCoffeeFromDs(example);
36.
37. list.forEach(e -> log.info("selectByExample: {}", e));
38.
39. // 筛选指定属性
40. List<Money> moneys = list.stream().map(Coffee::getPrice).collect(Collectors.toList());
41. log.info( moneys.get(0).toString() );
42.
43. // 断言结果
44. Assert.assertEquals("CNY 20.00",moneys.get(0).toString());
45.
46. }
47.
48. @AfterMethod(description = "清理及恢复数据")
49. publicvoid afterMethod() {
50. testDataService.recycleTableData(tableName);
51. }
52.
53. }
注意:
SpringBoot
中使用 TestNg必须加上@SpringBootTest
,并且继承AbstractTestNGSpringContextTests
,如果不继承AbstractTestNGSpringContextTests
,会导致@Autowired
不能加载 Bean;- @Test:测试逻辑地方;
- 数据备份及清理调用只能放在
@BeforeMethod/@AfterMethod
注解。
最后就是跑测了,我们先看下数据: 执行测试: 测试完成后,我们再检查下业务数据: 我们看到数据被成功恢复了。
小结
本文主要提出一种简单的解决方案,针对所有写接口服务端公用的数据,统一提前准备,跑测的时候提供一键准备/恢复测试数据的方法,尽可能减少因为环境/数据准备造成的时间浪费。
希望能都对你有所启发。
示例代码:
https://github.com/7DGroup/Java-API-Test-Examples/tree/master/springboot-data-backup-recovery-demo