被你忽视的单元测试-理论篇

一套可以运行的业务代码往往只是一个软件产品中的一部分,一个完备的软件产品应该包含至少以下元素:

  • 业务代码
  • 自动化测试
  • 文档
  • 持续集成/持续交付(CI/CD)
  • 产品协议

单纯的代码是不够的,只有以上内容被集成到一个坚实的系统中,才能被称为一个软件产品。而其中测试在软件开发中是至关重要的,在一些架构设计方法论中,测试甚至可以主导系统的架构设计(如TDD),从而进一步的保证了代码的可测试性。

可维护和可读的测试代码对于提升单元测试覆盖率至关重要,当我们修改系统中的一些功能或进行重构时,这些单元测试又可以检测我们是否对功能进行了破坏。

同时单元测试可以是优秀的代码文档,一个对系统的行为不了解的人可以通过单元测试快速了解各个类的目的和API使用方法。

1. 单元测试与集成测试的区别

每个开发人员都有编写单元测试的经验,我们都知道它们的目的和编写方式,但是要给单元测试下一个严格的定义是比较困难的,最核心的在于如何理解“单元”。

Wiki百科对单元测试和集成测试分别进行了以下解释:

单元测试:又称为模块测试,是针[程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法

集成测试:即对程序模块采用一次性或增值方式组装起来,对系统的接口进行正确性检验的测试工作,一般在单元测试之后、系统测试之前进行

简单地说,做单元测试时只测试一个单元的代码,一次一个方法,不包括所有与被测试的组件交互的其他方法

而集成测试是测试方法之间的集成。由于单元测试中所有方法的行为是独立,我们不知道它们之间如何协同工作,此时就需要集成测试来进行验证。

2. 单元测试的要求

单元测试并不是看起来那么简单的,他们要能够实际验证代码的行为,而不仅仅是使用一些断言来查看错误信息,也不仅仅是用一些特定参数来进行mock。为了能够保证单元测试的质量,需要保证单测代码符合以下原则

2.1 测试四要素

一个单元测试理论上应该包含四个部分:

  • mock:被测方法执行过程中,调用的被测类以外的方法都应当被mock处理。如果被测方法没有这种调用,则可以忽略这个部分
  • 输入:测试准备,例如入参数据或需要使用到的配置
  • 执行:调用想要测试的方法或动作
  • 输出:并非简单打印执行结果,而是执行断言,验证输出或动作的正确性
@Test
public void findProduct() {
    insertIntoDatabase(new Product(100, "IPad"));

    Product product = dao.findProduct(100);

    assertThat(product.getName()).isEqualTo("IPad");
}

2.2 单元测试的FIRST原则

  • F:Fast,快速
  • I:Independent,独立
  • R:Repeatable,可复验
  • S:Self-Validating,自足验证
  • T:Thorough,彻底/及时

Fast,快速

单元测试是执行一个特定任务的一小段代码。与集成测试不同的是,单元测试很小,没有网络通信,不执行数据库操作,所以它们应当运行得非常快。这样的特性也可以保证开发者在实现应用程序功能时,可以经常运行单元测试。

Independent,独立

单元测试必须是相互独立的。一个单元测试不应该依赖于另一个单元测试所产生的结果,因为在大多数情况下,单元测试是以随机的顺序运行的。

被测试的代码或系统也应该与它的依赖隔离开。为了确保这些依赖中的错误不影响单元测试,确保结果的准确性,通常会使用mock或stub的方式进行数据处理。

Repeatable,可复验

一个单元测试在不同的计算机、不同的时间点多次运行,都应该产生相同的结果。这就是为什么单元测试是独立于环境和其他单元测试的。

Self-Validating,自足验证

自足验证的含义是,开发人员如果要了解一个单元测试是否通过,不应该在测试完成后做任何额外的检查。

单元测试需要自动验证被测方法所产生的结果,并由它自己决定是通过还是失败。

因此,不需要在单元测试中添加任何打印日志的语句。如果只有打印出日志才能判断单元测试是否通过,就需要重新审视你的单元测试看看哪里出了问题。

Thorough,彻底/及时

在测试一个功能时,我们除了考虑主要逻辑路径以外,还要关注边界或负面的场景。因此在多数时候,我们除了要创建一个具有有效入参的单元测试,还需要准备其他使用了无效入参的单元测试。例如被测方法入参有一个范围,从MIN到MAX,那么应该创建额外的单元测试来测试输入为MIN和MAX时是否能正确处理。

也有的文章将Thorough理解为及时的。这种理解的含义是:最好在编写新功能的时候就创建单元测试。这样就能够尽快验证该功能确实能按预期的效果进行工作,也就有更少的可能去引入一个错误。因此,在将代码push到生产分支之前,应该用单元测试覆盖你所修改的代码。

2.3 被测类不应打破依赖反转原则

打破这一规则将使单元测试难以编写和执行。看一下下面这段代码

public class OrderService {
  private final OrderRepository orderRepository = new OrderRepositoryImpl();
  private final CommodityRepository commodityRepository = new CommodityRepositoryImpl();
  ...
}

CommentService声明了两个外部依赖(本文中的“外部”均指当前类的外部,而非系统外部),但这两个依赖都绑定在了CommentService的内部,使得我们很难通过stub、mock的方式来隔离验证当前类的行为(虽然可以通过反射的方式曲线救国)

2.4 单元测试应保证确定性

一个单元测试应该只依赖于输入参数,而不依赖于外部状态(系统时间、CPU数量、默认编码等),因为我们不能保证团队中的每个开发人员都有相同的硬件设置。假设你的电脑有8个CPU,一段单元测试对此做了一个假设和断言,那么另一个拥有16个CPU的同事可能会因为每次该测试都运行失败而感到恼火。

举一个栗子。想象一下,我们想测试一个工具方法,它可以告诉我们所提供的日期时间是否是早晨,如果你的单元测试是这样的:

@Test
public void shouldBeMorning() {
    OffsetDateTime now = OffsetDateTime.now();
    assertTrue(DateUtil.isMorning(now));
  }

这个测试在结果上是不确定的,只有当你在早晨运行这段代码时,它才会成功。

这里的最佳做法是避免通过调用非确定性函数来声明测试数据,这些数据包括

  • 当前日期时间
  • 系统时区
  • 硬件参数
  • 随机数
  • 文件绝对地址

2.5 单元测试应避免任何外部依赖

只有抛弃所有的外部依赖,才能保证在任何环境下每次运行测试都有相同的结果,否则难以使用断言对执行结果的正确性进行推断。

假设我们正在创建一个提供需求状态的服务,该服务的入参是一段Open Api的URL。下面的代码片段是该服务的单元测试,用于检测处理需求信息的逻辑是否正确:

	String apiUrl = "http://api.jagile.jd.com/demand?id=123";
    DemandService demandService = new DemandService();

    DemandInfo demandInfo = demandService.getDemandDetail(apiUrl);

    assertEquals("Good Test",demandInfo.getName());

问题是外部服务或依赖可能是不稳定的,不能保证外部服务会一直保持正常且一致的响应。即使外部服务做到了,运行构建的CI服务器仍然有可能存在操作限制,例如可能存在防火墙的限制。

单元测试应当是是一段坚实的代码,不需要依赖任何外部服务就能成功运行。

3. 最佳实践

3.1 使用断言

为了满足“自足验证”的要求,需要使用断言来验证预期与实际的结果。可以使用JUnit的Assert类中的各种方法。

例如,可以使用Assert.assertEquals方法或assertNotEquals来进行值断言。其他方法如assertNotNull、assertTrue和assertNotSame可以在不同的场景中选用。

3.2 使用前缀标明预期值与实际值

如果要在断言中使用变量,尽量在变量前加上“actual*” 和“expected*”,分别表示实际执行的结果和预期的结果。这样可以增加可读性,并且明确了变量的意图,避免在断言中出现混淆

不要这么做👎

ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);

这样更好👍

ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); // 👍🏻

3.3 避免使用随机数据

使用随机数据会导致每次测试得到的结果都不一致,这样会很难调试。如果为了让测试通过而不使用断言,又难以发现问题并追溯错误代码。

相反,如果对所有数据都使用固定值,则可以更容易地创建可复用的测试。或者将产生随机变量的功能进行封装,可以让单元测试更方便的mock

不要这么做👎

Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad

同样的,单元测试代码中也应尽量避免使用如Instant.now()new Date()等会产生随机数据的方法,因为每次执行单元测试得到的结果都是不同的,所以难以使用断言进行结果校验

不要这么做👎

public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update().set("dateModified", now);
        Query query = Query().addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}

3.4 避免使用静态成员

每个测试用例应该相互独立,因此永远不要在测试代码中使用静态数据成员,因为它们的值很有可能被其他单元测试修改。但是如果一定要这样使用,请记住在执行每个测试用例之前对其重新初始化。

3.5 隔离外部依赖

如果要测试的代码与外部资源有交互,应当考虑使用Mock框架隔离这些外部依赖,避免访问真实的外部资源,从而保证单元测试的稳定性。Mockito框架就是一个很好的选择。

@RunWith(MockitoJUnitRunner.class)
public class CarServiceTest {

	private CarService carService;

	@Mock
	private RateFinder rateFinder;

	@Before
	public void init() {
    	carService = new CarService(rateFinder);
	}

	@Test
	public void shouldInteractWithRateFinderToFindBestRate() {
    	carService.schedulePickup(new Date(), new Route());
    	verify(rateFinder, times(1)).findBestRate(any(Route.class));
	}
}

3.6 避免void返回值

具有返回值的函数更容易测试,因为可以很方便地对方法返回值进行断言。

如果方法是void返回值,那么单元测试就需要对方法入参对象内部的某些值进行断言,单元测试就与方法的内部实现出现了紧耦合,必须对被测方法的内部逻辑有了解才能实现断言。

如果一个方法的返回值只能是void,那么一般有以下3中方法进行测试

  • 使用mock来验证该方法内部所依赖的其他方法是否被调用
  • 验证该方法的入参是否发生了预期改变
  • 验证是否抛出了一个已知的异常

3.7 不要吞掉异常

单元测试中不要catch住异常后不进行任何后续处理,否则会隐藏掉被测方法中的异常。如以下展示的两个单元测试,第一个无论是不是符合预期永远会通过,第二个更加明确,如果没有抛出预期的异常,则会失败

不要这样做👎

@Test
public void myTest(){
		try{
      	……
    } catch(Exception e){
      	assertTrue(true);
    }
}

这样更好👍

@Test(expected=ExceptionClass.class)
public void myTest() throws ExceptionClass{
		……
}

3.8 减少对Spring容器的依赖

此处的容器依赖是指完全靠Spring的依赖反转来对bean的方法进行测试,因为启动Spring框架往往需要较长的时间,且越复杂、外部依赖越多的应用启动时间越长,这会拉长测试执行时间,减慢反馈的周期。

手动创建所需要的对象并使用Mock的方式进行注入可以解决依赖Spring容器的问题。但是如果想要测试的是配置读取、容器启动时自动运行的方法等,那么对容器的依赖是必不可少的。

可以尝试使用@Before和@After方法来设置所有测试用例的前提条件。如果需要在@Before或@After中支持多个不同的测试用例,那么就要考虑创建新的测试类了。

3.9 多使用辅助函数

将细节或重复代码提取到子函数中,并且取一个描述性比较强的名字,可以使测试代码更简短,同时可以让开发人员更快速的了解当前测试的核心内容是什么。

使用辅助函数创建复杂对象或断言时,只传递和当前测试有关的参数给辅助函数,对其他数据可以合理使用默认值

不要这么做👎

@Test
public void categoryQueryParameter() throws Exception {
    List<ProductEntity> products = List.of(
            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
    );
    for (ProductEntity product : products) {
        template.execute(createSqlInsertStatement(product));
    }
    String responseJson = client.perform(get("/products?category=Office"))
            .andExpect(status().is(200))
            .andReturn().getResponse().getContentAsString();

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}

这样更好👍

@Test
public void categoryQueryParameter2() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("1", "Office"),
            createProductWithCategory("2", "Office"),
            createProductWithCategory("3", "Hardware")
    );
    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}

3.10 避免过度使用变量

开发人员通常喜欢将多次使用的值提取到变量中,但在一些情况下,这会大大增加开发测试代码的工作量,同时也增加了追溯相关失败代码行的复杂度。

测试代码应尽可能的保持简短,如果多处地方使用了相同的值,那么使用变量是没有问题的。

不要这么做👎

@Test
public void variables() throws Exception {
    String relevantCategory = "Office";
    String id1 = "4243";
    String id2 = "1123";
    String id3 = "9213";
    String irrelevantCategory = "Hardware";
    insertIntoDatabase(
            createProductWithCategory(id1, relevantCategory),
            createProductWithCategory(id2, relevantCategory),
            createProductWithCategory(id3, irrelevantCategory)
    );
    String responseJson = requestProductsByCategory(relevantCategory);

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly(id1, id2);
}

这样更好👍

@Test
public void variables() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("4243", "Office"),
            createProductWithCategory("1123", "Office"),
            createProductWithCategory("9213", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("4243", "1123");
}

3.11 不要在现有的测试上做新功能的扩展

在已有的测试代码中增加一个case实现起来非常简单,但这会逐渐使这个测试变得更大,更难理解,你很难去掌握这个大测试涵盖了哪些测试案例。而且如果这个测试失败了,很难看出到底是什么地方出了问题。

相反,最好为新的逻辑或执行路径创建一个新的测试方法,并且使用一个描述性的名字表达出来这个方法的预期行为。这肯定会需要更多的编码工作,但创建出的测试是更加清晰和明确的。

简而言之,如果想要验证多个场景,需要创建多个单独的测试用例,而不是向同一个单元测试添加多个断言。

不要这么做👎

public class ProductControllerTest {
    @Test
    public void happyPath() {
        // a lot of code comes here...
    }
}

这样更好👍

public class ProductControllerTest {
    @Test
    public void multipleProductsAreReturned() {}
    @Test
    public void allProductValuesAreReturned() {}
    @Test
    public void filterByCategory() {}
    @Test
    public void filterByDateCreated() {}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值