单元测试的思考与实践

1. 什么是单元测试

通常来说单元测试,是一种自动化测试,同时包含一下特性:

  • 验证很小的一段代码(业务意义 或者 代码逻辑 上不可再分割的单元),能够更准确的定位到问题代码的位置
  • 能够快速运行(单元测试的意义,在于快速且周期性的验证原有代码的准确性),提高项目开发效率
  • 以隔离的方式 (isolated manner)运行(对外部依赖通过插桩解耦,避免单元测试的复杂度,实现问题快速定位,简化单元测试的运行环境,多个单元测试可以以任何顺序甚至并行进行)

2. 为什么要单元测试

因为单元测试有如下优点:

  1. 能快速的回归,提高自测的效率
  2. 集成测试或者端到端的手工测试效率低,而且无法覆盖到更细节的逻辑分支
  3. 也存在功能设计超前于产品设计,通过接口维度,无法触达某些逻辑分支,需要通过单元测试来覆盖
  4. 功能开发人员更了解代码的实现,开发人员写出的测试用例往往能更全面的覆盖代码
  5. 有良好单测的代码,往往更方便重构
  6. 单元测试是项目代码的一部分,维护方便,当然这也依赖良好的单元测试编写习惯,合适的颗粒度

3. 如何识别有测试价值的代码?

当我们考虑给代码添加 单元测试时,需要首先考虑加入单测后能够带来的收益有多少,以及其付出的成本有多少,用最小的维护成本提供最高的价值的单元测试

3.1 项目属性
软件本身发布更新成本比较大,如嵌入式软件,客户端程序;或者 软件的缺陷 更可能带来较大的资损,如工厂,银行内部的软件,这类软件都是需要优先考虑单元测试。
如果一个项目本身不是特别核心的项目,影响面小,迭代更新相对较容易,那么对单元测试的要求,或者说对质量的要求,也就没有那么强烈

3.2 代码属性
3.2.1 重要的代码

  1. 领域层
  2. 基础设施代码
    3.2.2 不容易被集成测试覆盖的代码
  3. 边界条件
  4. 异常条件
  5. 低概率场景
    3.2.3 容易出现问题的代码
  6. 复杂的业务逻辑分支
  7. 状态机
  8. 胶水代码:负责组合多个功能,多个功能的输入具有不确定性

3.3 个人不建议的单元测试的行为

  1. 通常来说不建议在单元测试的时候,启动spring容器后,会牵扯过多的外部依赖,导致单元测试难以进行,或者成本过高
  2. 同样,外部接口,数据库依赖,中间件依赖,都不建议在单元测试中加载,可以通过mock或者sub的方式来进行隔离

4. 编写 Unit Test

通常按照单元测试的AAA模式来编写单元测试,分为三部分:Arrange, Act, Assert

  1. Arrange
    准备测试数据和测试环境,确保测试的可重复性和可预测性。这包括初始化对象、设置变量、模拟外部依赖等
  2. Act
    执行实际的测试操作,也就是调用需要测试的方法或函数,并获取返回值或状态。这个阶段应该仅包含单个操作,以确保测试的独立性和可维护性
  3. Assert
    验证测试结果是否符合预期,也就是检查实际的输出是否与预期的输出相同。如果结果不符合预期,我们需要检查测试代码和被测试代码,找出问题所在并进行修复
  4. 结果验证 - 对函数返回结果进行验证
  5. 状态验证 - 对过程中的属性值来进行验证
  6. 行为验证 - 对过程中会执行的动作进行验证

spock测试框架代码示例:


class OrderServiceImplTest extends Specification {

    OrderService orderService = new OrderServiceImpl();
    InventoryService inventoryService = Mock(InventoryService)
    OrderConverter orderConverter = Mock(OrderConverter.class)
    PaymentChannelClient paymentChannelClient = Mock(PaymentChannelClient)
    OrderMapper orderMapper = Mock(OrderMapper)


    def setupSpec() {}    // runs once -  before the first feature method
    def setup() { // runs before every feature method
        orderService.setInventoryService(inventoryService)
        orderService.setPaymentChannelClient(paymentChannelClient)
        orderService.setOrderMapper(orderMapper)
        orderService.setOrderConverter(orderConverter)
    }

    def cleanup() {}      // runs after every feature method
    def cleanupSpec() {}  // runs once -  after the last feature method

    def "create order correctly"() {
    
        //准备测试需要的参数
        given:
        Long id = 1
        CreateOrderCommand command = new CreateOrderCommand(orderNo, itemNo, orderItemQuantity, user, totalPrice)
    
        //创建一个spy,可以用来做行为验证
        MockOrderEntity spyOrder = Spy(constructorArgs: [id, orderNo, itemNo, orderItemQuantity, null, user, totalPrice])
    
        //指定返回spy
        orderConverter.toEntity(_ as CreateOrderCommand) >> spyOrder
    
        LockInventoryCommand lockInventoryCommand = new LockInventoryCommand(itemNo, orderItemQuantity)
    
        when:
        //触发测试
        Long resultId = orderService.createOrder(command)
    
        then:
        //行为验证, 创建订单的同时,执行锁定库存lockInventory会被执行一次,同时会验证参数是否和我们提供lockInventoryCommand是否equals
        1 * inventoryService.lockInventory(lockInventoryCommand)
        //行为验证,最终订单执行insert
        1 * spyOrder.insert()
        //结果验证,验证返回的id
        resultId == id
        //状态验证
        spyOrder.orderStatus == OrderStatus.CREATE
    
        //以表格的形式提供测试数据集合
        where:
        orderNo | itemNo | orderItemQuantity | user    | totalPrice
        "1"     | "it"   | 10                | "userA" | 9.9
    }
    
  }

5. 如何自动化执行单元测试

使用spock框架进行单测,可以通过添加maven插件,来在maven打包的时候自动执行单元测试代码

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>2.1-groovy-3.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.10.19</version>
    <scope>test</scope>
</dependency>


<!-- Mandatory plugins for using Spock -->
<plugin>
    <groupId>org.codehaus.gmavenplus</groupId>
    <artifactId>gmavenplus-plugin</artifactId>
    <version>1.12.0</version>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>compileTests</goal>
            </goals>
        </execution>
    </executions>
</plugin>
<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0-M5</version>
    <configuration>
        <includes>
            <!-- 指定后缀为Test的文件,需要被执行单元测试 -->
            <include>**/*Test</include>
        </includes>
    </configuration>
</plugin>

6. Spock测试框架中Mock,Stub,Spy的区别

  1. Stub(桩对象):Stub对象用于模拟被测试对象的某些行为。Stub对象通常用来模拟一些外部依赖(interface)返回指定数据,以便于进行单元测试。不能用于用来做行为验证。
def ""() {
    given:
    def inventoryMapper = Stub(InventoryMapper)
    InventoryServce inventoryService = new InventoryServiceImpl(inventoryMapper)
    Inventory inv = new Inventory(10)
    inventoryMapper.selectById(_) >> inv

    when:
    //inventoryService.stockOut(quantity, id)
    inventoryService.stockOut(5, 1)

    then:
    inv.quantity == 5
    
}
  1. Mock(模拟对象):Mock对象和Stub对象类似,但是可以用来做行为验证,所以在spock中通常可以用mock替代stub
def ""() {
    given:
    def inventoryMapper = Mock(InventoryMapper)
    InventoryServce inventoryService = new InventoryServiceImpl(inventoryMapper)
    Inventory inv = new Inventory(10)
    inventoryMapper.selectById(_) >> inv

    when:
    //inventoryService.stockOut(quantity, id)
    inventoryService.stockOut(5, 1)

    then:
    //行为验证,inventoryMapper执行了一次stockOut
    1 * inventoryMapper.stockOut(_)
    inv.quantity == 5
    
}

3. Spy(监视对象):上面的Stub,Mock都是创建一个假的实例,而Spy是在真实实例的基础上,类似创建一个包装类,它可以记录被测试对象的行为。既保留了原有实例功能的同时,还可以做行为验证
```groovy
def ""() {
    given:
    def inventoryMapper = Stub(InventoryMapper)
    InventoryServce inventoryService = new InventoryServiceImpl(inventoryMapper)
    Inventory inv = Spy(Inventory)
    inv.setQuantity(10)
    inventoryMapper.selectById(_) >> inv

    when:
    //inventoryService.stockOut(quantity, id)
    inventoryService.stockOut(5, 1)

    then:
    //行为验证,inv执行了一次stockOut
    1 * inv.stockOut(_)
    inv.quantity == 5
    
}

通常来说调用Spy对象的方法,会被默认委托给真实的对象来执行,即执行真实的方法,但是Spy同样也适用Stub行为,如:

def subscriber = Spy(SubscriberImpl, constructorArgs: ["Fred"])
//Spy对象也可以像 Stub对象一样,替换掉receive方法,返回指定的值
subscriber.receive(_) >> "ok"

7. Partial Mocks(部分Mock)

7.1 callRealMethod
通常来说Mock可以对class或者interface创建一个fake对象,不会执行真实的方法,当在写单元测试时有时会需要执行Mock对象的某些真实方法的时候,可以callRealMethod的方式来执行。

given:
def subscriber = Mock(SubscriberImpl)
//mock call方法
subscriber.call(_) >> {return "called"}
//通过callRealMethod指定mock对象执行原来的真实方法
subscriber.receive(_) >> { callRealMethod() }

then:
subscriber.receive("")

7.2 spy
通过callRealMethod是一种方式,另一种,就是通过Spy来实现,因为Spy是基于真实的对象创建的,那么就可以反过来实现一个对象既可以调用真实方法,又可以调用假的方法

given:
def subscriber = Spy(SubscriberImpl, constructorArgs: ["Fred"])
//mock call方法
subscriber.call(_) >> {return "called"}

then:
//这里会直接执行真实方法
subscriber.receive("")

posted on 2024-04-03 16:32  mindSucker  阅读(27)  评论(0编辑  收藏  举报