UT开发总结

本文强调了在重构代码时使用UT(单元测试)的重要性,以避免问题推迟到后续阶段。介绍了UT的规范,如类和函数命名约定,以及@Mock和@MockBean的区别和使用场景。文章还讨论了@SpringBootTest在集成测试中的角色,DB是否需要mock的情况,以及void函数的测试策略。另外,提出了UT开发中的常见问题和解决方案,包括使用H2内存数据库进行测试以及处理自增列在UT后的重置问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

UT

如果打算做一些现有代码的重构,强烈建议重构要有UT保障,毕竟,没有UT的重构就是裸奔…

UT的作用

  • 提前发现问题,避免将问题推迟到sit甚至更后期;
  • 修改问题单或者重构时更有保障;
  • 底层三方包升级保障

UT规范

类命名

{待测试类名} + “Test”后缀

例如:

CustomerSvcMgrServiceTest

函数命名

“test” + {被测试的方法名称} + “_Should” + {预期结果} + “_When” + {条件}

例如:

testXXX_ShouldSuccess_WhenDoingSth
testXXX_ShouldSuccess

如果ut方法名无法取得较好的自注释效果,可对ut方法增加注释说明。

UT讨论

@Mock和@MockBean

@Mock是Mockito提供的注解,用于创建待测类的模拟对象。

@MockBean不仅是创建待测类的模拟对象,还将这个模拟对象加到spring上下文(ApplicationContext)里。如果我们使用的是SpringBootTest做集成测试,且要mock一个已有的bean(@Service、@Component等注解标注),应该使用@MockBean,而不是@Mock。

@MockBean一般用于成员级别,还有一个@MockBeans的注解,最终效果跟@MockBean差不多,必须放到类级别,然后在成员字段引入的时候依然用@Autowired或@Inject,比如下例:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = StartupApplication.class)
@Slf4j
@MockBeans({@MockBean(IPhaseService.class)})
public class PhaseCacheHelperTest {

    @Autowired
    private IPhaseService phaseService;
    
    @Test
    public void testGetByPhaseCode_ShouldLoadFromDs() {

        ...

        Mockito.when(phaseService.findPhase(phaseCode)).thenReturn(resp);
        PhaseVO actualVo = PhaseCacheHelper.getByPhaseCode(phaseCode);
        Assert.assertNotNull(actualVo);
        Assert.assertEquals(actualVo.getPhaseName(), expectVo.getPhaseName());
        Mockito.verify(phaseService, Mockito.times(1)).findPhase(phaseCode);

    }

用MockBeans注解的时候,测试框架会提前把IPhaseService类mock化,再用通常的 @Autowired注解注入,它不会像@Mockbean那样走MockitoPostProcessor的后处理过程。这个后处理过程在有些情况下会产生多次注入的错误:

the field private cannot have an existing value

关于Mockito的参数匹配,参看:

https://blog.youkuaiyun.com/listeningsea/article/details/123224131

我们用的比较多的是精确匹配和模糊匹配。精确匹配要注意,如果传入的是一个自定义对象的话,要求这个对象有equals方法,不然做不到精确匹配。

@SpringBootTest的涵义

@SpringBootTest为我们的测试提供了spring boot支持,它启动了spring上下文,提供的是E2E的测试能力,所以本质上它已经属于IT的范围了。我们将IT放到UT阶段做,提前发现问题,并无不妥,不用拘泥于概念。

说明一下,@RunWith(SpringRunner.class)是junit4的写法,junit5(jupyter)下已经没有这个注解了。

DB要不要mock

我理解,如果将@SpringBootTest认为是IT的话,DB可以不mock,连接真实环境能提前发现一些很明显的sql语法错误,一个典型的场景是为mybatis mapper接口写测试。当然,如果测试的重点是代码逻辑,或者要构造一些DB异常,那DB还是要mock的。

void函数的测试

void函数既然无法检查返回值,可以视情况检查副作用,比如一些潜在的数据变化、调用次数。

UT开发常见问题

  • UT函数名随意取,比如testXXX1,testXXX2,建议按规范来,要求看到名字能猜出要测什么;
  • UT里只有方法调用,而没有assertion断言;
  • 一个UT只测一种场景,不要将所有场景的测试放到一个UT里;
  • 由于三方依赖,只测失败用例,没有成功用例(注意:这时UT代码覆盖率依然可能是达标的!)。这时可以适当借助mock方法来模拟成功用例;
  • UT代码也要有基本的设计和代码质量保证,适当做一些公共抽取,避免出现大量重复的测试代码
  • 跑去测试getter和setter方法,这样毫无意义!
  • 写全部类的UT工作量太大,此时,可测试你最关心的部分、测试你觉得最容易出错的部分
  • 忽略边界条件的UT,比如数组的边界测试

在UT中使用H2

既然DB不mock,那就得连真实数据库,可能的做法是:要么本地搭一个mysql,要么连接远程mysql。前者有搭建工作量,后者受制于环境数据,跑UT容易出问题。

因此,考虑在UT中引入H2内存数据库,外加flyway来初始化数据库表结构。

我们先做一个bean来在dataSource就绪时立刻执行建表语句:

@Component
@Slf4j
@DependsOnDatabaseInitialization
public class DBPrepare {
    private static final String H2SQL_DIR = "/tmp/H2SQL";

    @Inject
    private DataSource dataSource;

    @PostConstruct
    public void init() {
        // only H2 db use flyway
        if (isH2DB()) {
            log.info("DBPrepare");
            genH2SqlScript();
            String[] locations = new String[] {"filesystem:" + H2SQL_DIR + "/"};
            Flyway flyway = buildFlyway(dataSource, "prjname", locations);
            try {
                flyway.migrate();
                log.info("DBPrepare success");
            } finally {
                cleanH2SqlScript();
            }
        }
    }

    ...
}

由于原始的mysql脚本在H2里执行要做一些调整,所以我们写了genH2SqlScript来做这个转换的事情。
转换的逻辑看下节。

H2与MySQL的兼容性

接着,要看H2与mysql的兼容性,幸运的是H2可以设置MYSQL模式:

MODE=MySQL

以兼容mysql建表语句。不过,即使在兼容mysql的模式下,也有些不同之处需注意:

  • PRIMARY KEY后不能使用USING BTREE和COMMENT
  • UNIQUE KEY或KEY后不能用COMMENT
  • ENGINE = InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci 在H2的1.4.200版本不能支持,不过在随后的新版本里是支持的
  • replace into在H2里不支持,改用ON DUPLICATE KEY UPDATE
  • ZEROFILL在H2里不支持
  • CURRENT_TIMESTAMP(0)和CURRENT_TIMESTAMP在H2里都是支持的
  • timestamp(0)和timestamp在H2里都是支持的
  • CHARACTER SET xxx COLLATE xxx的写法在H2里不支持
  • mysql的索引名是表内唯一的,但H2的索引名是schema内唯一,这在UT中很容易产生问题,需要预处理一下,比如为索引名自动加上唯一性后缀

H2连接字符串

我们设置的H2连接字符串为:

jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL;DATABASE_TO_LOWER=TRUE

选项说明如下:

By default, closing the last connection to a database closes the database. For an in-memory database, this means the content is lost. To keep the database open, add DB_CLOSE_DELAY=-1 to the database URL. To keep the content of an in-memory database as long as the virtual machine is alive, use jdbc:h2:mem:test;DB_CLOSE_DELAY=-1

By default, a database is closed when the last connection is closed. However, if it is never closed, the database is closed when the virtual machine exits normally, using a shutdown hook. In some situations, the database should not be closed in this case, for example because the database is still used at virtual machine shutdown (to store the shutdown process in the database for example). For those cases, the automatic closing of the database can be disabled in the database URL. The first connection (the one that is opening the database) needs to set the option in the database URL (it is not possible to change the setting afterwards). The database URL to disable database closing on exit is:
String url = "jdbc:h2:~/test;DB_CLOSE_ON_EXIT=FALSE";

参考:http://www.h2database.com/html/features.html

@Rollback不会重置自增列的问题

对于auto increment列,每个UT执行后,它都是继续往上递增,而不理会@Rollback注解的,H2和mysql都是这种行为,这点需要特别注意。

如确实有重置自增列的诉求,参考这篇文章,写得非常详细:
https://www.petrikainulainen.net/programming/spring-framework/spring-from-the-trenches-resetting-auto-increment-columns-before-each-test-method/

总的思路,一句话:
每个UT函数执行之前(使用@Before注解),先执行一次sql语句:
ALTER TABLE ${tablename} ALTER COLUMN ${column} RESTART WITH 1
这是按上述思路的代码样例:

public final class DBUtil {

    private static final String RESET_AUTOINCR_SQL_TMPL = "ALTER TABLE %s ALTER COLUMN %s RESTART WITH 1";

    public static void resetAutoIncrementColumns(List<Pair<String, String>> tbl2ColList) {
        try {
            DataSource dataSource = (DataSource) springContext().getBean("dataSource");
            try (Connection dbConnection = dataSource.getConnection()) {
                for (Pair<String, String> item : tbl2ColList) {
                    try (Statement statement = dbConnection.createStatement()) {
                        String resetSql = String.format(RESET_AUTOINCR_SQL_TMPL, item.getFirst(), item.getSecond());
                        log.info("DBUtil.resetAutoIncrementColumns sql:{}", resetSql);
                        statement.execute(resetSql);
                    }
                }
                log.info("DBUtil.resetAutoIncrementColumns success");
            }
        } catch (Exception e) {
            log.error("DBUtil.resetAutoIncrementColumns catch err:{}", e.toString());
        }

    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值