单元测试实战手册

转载自  F I 博客:http://blog.mschina.io

软件测试的重要性及其对软件质量好坏的预测非常重要。软件测试是软件质量保证的关健环节,代表了需求、设计和编码的最终检查。软件测试技术包含很多方面,其中单元测试是保卫您的代码的第一级屏障,捍卫在应用大殿的入口,一个合格的单元测试护卫能够迅速察觉到任何Bug造成的风吹草动,进而快速采取行动,避免后续的损失。同时,单元测试也为代码重构提供了安全保障,每一次改动都应该能够经受住单元测试的考验。

接下来,我将通过几个简单的例子来说明如何有效的进行单元测试。 假设我们有一个计算本息和的CalcService,其利率monthlyRate是通过配置文件直接读入:

public class CalcService {

    @Value("${monthlyRate}")
    private BigDecimal monthlyRate;


/**
 * 复利计算,每月固定存入本金,计算固定月之后的本息之和
 *
 * @param monthlyPrincipal 每月本金
 * @param month            月数
 * @return 本息之和
 */
public BigDecimal compoundingCalc(BigDecimal monthlyPrincipal, Integer month) {
    Validate.isTrue(monthlyPrincipal != null && monthlyPrincipal.compareTo(BigDecimal.ZERO) > 0,
            "monthlyPrincipal not null, and must bigger than zero");
    Validate.isTrue(month != null && month > 0,
            "month must bigger than 1");

    BigDecimal total = monthlyPrincipal;
    int i = 1;
    while (i < month) {
        total = total.multiply(monthlyRate.add(BigDecimal.ONE))
                .add(monthlyPrincipal);
        i++;
    }

    return total;
}
}

对于这种简单且和项目中其余模块交互较少的类,仅使用Junit进行测试即足够。

JUnit是由 Erich Gamma 和 Kent Beck 编写的一个回归测试框架(regression testing framework)。Junit测试是程序员测试,即所谓白盒测试,因为程序员知道被测试的软件如何(How)完成功能和完成什么样(What)的功能。 然而,它仅适合于纯粹的单元测试,对于集成测试应该使用例如TestNG等来代替。通常对于每一项测试点需要一个正测试一个负测试。

要使用Junit,首先需要添加其依赖:

Maven:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

Gradle:

testCompile group: 'junit', name: 'junit', version: '4.12'

Junit为primitive类型和Objects提供了一系列重载的断言方法,它们都在类org.junit.Assert中,以下是该类常用方法和说明:

//condition为真,则断言成功;反之断言失败,并抛出AssertionError
static public void assertTrue(boolean condition)</code>

//同上,message为断言失败时,AssertionError的标识信息
static public void assertTrue(String message, boolean condition)</code>

//condition为假,则断言成功;反之断言失败,并抛出AssertionError
static public void assertFalse(boolean condition)</code>

//同上,message为断言失败时,AssertionError的标识信息
static public void assertFalse(String message, boolean condition)</code>

//若期望值(expected)与实际值(actual)相等,断言成功,反之断言失败,并抛出AssertionError
private static boolean isEquals(Object expected, Object actual)</code>

//同上,message为断言失败时,AssertionError的标识信息
static public void assertEquals(String message, Object expected,Object actual)

//断言两个对象不相等,如果相等,则抛出AssertionError
static public void assertNotEquals(Object unexpected, Object actual)</code>

//同上,message为断言失败时,AssertionError的标识信息
static public void assertNotEquals(String message, Object unexpected,Object actual)

//断言object为null,若object不为null,则抛出AssertionError
 static public void assertNull(Object object)

//同上,message为断言失败时,AssertionError的标识信息
static public void assertNull(String message, Object object)

更多方法请参考org.junit.Assert

认识了Junit,接下来我们看看如何对上面提到的CalcService进行测试:

public class CalcServiceTest {

    private CalcService calcService = new CalcService();

@Test
public void test_compoundingCalc() throws Exception {
    Field field = calcService.getClass().getDeclaredField("monthlyRate");
    field.setAccessible(true);
    ReflectionUtils.setField(field, calcService, new BigDecimal("0.01"));

    BigDecimal total = calcService.compoundingCalc(new BigDecimal("4000"), 1);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("4000")) == 0);


    total = calcService.compoundingCalc(new BigDecimal("4000"), 2);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8040")) == 0);

    total = calcService.compoundingCalc(new BigDecimal("4000"), 4);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("16241.604")) == 0);
}

@Test(expected = IllegalArgumentException.class)
public void test_compoundingCalcIllegalArgumentMonthlyPrincipal() {
    calcService.compoundingCalc(BigDecimal.ZERO, 12);
}

@Test(expected = IllegalArgumentException.class)
public void test_compoundingCalcIllegalArgumentMonth() {
    calcService.compoundingCalc(BigDecimal.TEN, 0);
}

}

可以看到,我们首先引入了被测试类CalcService,接着进行了三个分支的测试。

第一个分支是正向测试,主要测试程序的计算逻辑是否正确。

首先通过反射获取并设置了月利率monthlyRate的值为0.01,接着通过断言验证了本金4000的情况下分别存入1、2和4个月后的本息之和是否正确。

第二和第三个测试是负向测试,验证了输入不合理参数时程序反应是否符合预期,本例中即为是否按照逻辑能够抛出对应的异常。

接下来我们将Service稍微复杂化,改为从其他服务中获取月利率,而非直接设置,并且增加了用户角色信息,不同用户按照业务逻辑可以获取不同的月利率值。修改后的Service如下:

public class CalcServiceWithRemoteRate {

    private final RateService rateService;
    private final Long magicOffsetNumber;

    public CalcServiceWithRemoteRate(RateService rateService, Long magicOffsetNumber) {
    this.rateService = rateService;
    this.magicOffsetNumber = magicOffsetNumber;
}

/**
 * 复利计算,每月固定存入本金,计算固定月之后的本息之和
 *
 * @param monthlyPrincipal 每月本金
 * @param month            月数
 * @return 本息之和
 */
public BigDecimal compoundingCalc(BigDecimal monthlyPrincipal, Integer month) {
    Validate.isTrue(monthlyPrincipal != null && monthlyPrincipal.compareTo(BigDecimal.ZERO) > 0,
            "monthlyPrincipal not null, and must bigger than zero");
    Validate.isTrue(month != null && month > 0,
            "month must bigger than 1");

    BigDecimal total = monthlyPrincipal;
    int i = 1;
    while (i < month) {
        total = total.multiply(rateService.currentRate().add(BigDecimal.ONE))
                .add(monthlyPrincipal);
        i++;
    }

    return total;
}


/**
 * 复利计算,每月固定存入本金,计算固定月之后的本息之和
 *
 * @param monthlyPrincipal 每月本金
 * @param month            月数
 * @return 本息之和
 */
public BigDecimal compoundingCalcWithUserId(BigDecimal monthlyPrincipal, Integer month, Long userId) {
    Validate.isTrue(monthlyPrincipal != null && monthlyPrincipal.compareTo(BigDecimal.ZERO) > 0,
            "monthlyPrincipal not null, and must bigger than zero");
    Validate.isTrue(month != null && month > 0,
            "month must bigger than 1");

    BigDecimal total = monthlyPrincipal;
    int i = 1;
    while (i < month) {
        total = total.multiply(rateService.currentRate(userId).add(BigDecimal.ONE))
                .add(monthlyPrincipal);
        i++;
    }

    return total;
}

/**
 * 复利计算,每月固定存入本金,计算固定月之后的本息之和
 * 用户ID经过魔数变化
 *
 * @param monthlyPrincipal 每月本金
 * @param month            月数
 * @return 本息之和
 */
public BigDecimal compoundingCalcWithMagicUserId(BigDecimal monthlyPrincipal, Integer month, Long userId) {
    Validate.isTrue(monthlyPrincipal != null && monthlyPrincipal.compareTo(BigDecimal.ZERO) > 0,
            "monthlyPrincipal not null, and must bigger than zero");
    Validate.isTrue(month != null && month > 0,
            "month must bigger than 1");

    BigDecimal total = monthlyPrincipal;
    int i = 1;
    while (i < month) {
        total = total.multiply(rateService.currentRate(userId + magicOffsetNumber).add(BigDecimal.ONE))
                .add(monthlyPrincipal);
        i++;
    }

    return total;
}
}

RateService假设通过Http获取月利率具体值,其中包含了考虑用户角色与否的两个获取月利率的方法(仅用于演示如何进行单元测试,因此并未配置可用的Request)。具体内容如下:

public class RateService {

private OkHttpClient okHttpClient;

/**
 * 获得当前利率
 *
 * @return 当前利率
 */
public BigDecimal currentRate() {
    String res = null;
    try {
        res = okHttpClient.newCall(new Request.Builder().build())
                .execute().body().string();
    } catch (IOException e) {
        throw new RuntimeException();
    }
    return new BigDecimal(res);
}

/**
 * 获得当前利率
 *
 * @param userId 用户ID
 * @return 用户当前利率
 */
public BigDecimal currentRate(Long userId) {
    String res = null;
    try {
        res = okHttpClient.newCall(new Request.Builder()
                .addHeader("user_id", String.valueOf(userId))
                .build())
                .execute().body().string();
    } catch (IOException e) {
        throw new RuntimeException();
    }
    return new BigDecimal(res);
}
}

这时候我们的CalcServiceWithRemoteRate将强依赖RateService,那么应该如何独立的进行单元测试呢?

第一种方式:使用匿名内部类。

在测试中利用匿名内部类代替依赖的实际类,从而隔离两个强关联的类,仅关注被测试类的逻辑走向。本例中利用匿名内部类去代替了RateService,在内部类创建时指定了测试类需要的月利率值。

public class CalcServiceWithRemoteRateTest {

private CalcServiceWithRemoteRate calcService = new CalcServiceWithRemoteRate(new RateService() {
    @Override
    public BigDecimal currentRate() {
        return new BigDecimal("0.01");
    }

    @Override
    public BigDecimal currentRate(Long userId) {
        if (userId == 0) {
            return new BigDecimal("0.01");
        } else {
            return new BigDecimal("0.02");
        }
    }
}, 0L);


@Test
public void test_compoundingCalc() throws Exception {

    BigDecimal total = calcService.compoundingCalc(new BigDecimal("4000"), 1);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("4000")) == 0);

    total = calcService.compoundingCalc(new BigDecimal("4000"), 2);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8040")) == 0);

    total = calcService.compoundingCalc(new BigDecimal("4000"), 4);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("16241.604")) == 0);
}

@Test
public void test_compoundingCalcWithUserId() throws Exception {

    BigDecimal total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 1, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("4000")) == 0);

    total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 2, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8040")) == 0);

    total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 4, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("16241.604")) == 0);
}

//注:采用匿名内部类的隔离方法很难去测试 compoundingCalcWithMagicUserId 的业务逻辑
}

因为反向测试前面介绍过,在此不再累述。

看到这里,您也许会发现,利用匿名内部类的方式有很大的局限性。您无法在一开始创建时就设定好所有的逻辑走向,对于复杂的业务逻辑,想要通过新建类直接设置返回值去模拟复杂的功能就变得更加难以实现了。

因此,在这里再介绍一个单元测试的好伙伴————Mockito。

那么什么是Mockito呢,请继续看下去吧。

示例:

如果我们要对Class A进行测试,那么就需要先把整个依赖树构建出来,也就是BCDE的实例,一种替代方案就是使用mocks。

 

从图中可以清晰的看出:

 

  • mock对象就是在调试期间用来作为真实对象的替代品

  • mock测试就是在测试过程中,对那些不容易构建的对象用一个虚拟对象来代替的测试方法

同样的,要想使用它,我们先添加依赖。

Maven:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-all</artifactId>
    <version>1.10.19</version>
    <scope>test</scope>
</dependency>

Gradle:

testCompile group: 'org.mockito', name: 'mockito-all', version: '1.10.19'

有两种方法创建Mock对象:

  • 1 使用注解:在被测对象上加注解@InjectMocks,需要Mock的对象上加注解@mock,并在@Before注解的setUp()中进行初始化;

  • 2 直接Mock对象。

以下示例采用第二种方式。

Mockito能够验证程序的行为,示例mock了一个List,因为大家熟悉它的接口,实际使用中请不要mock一个List类型,使用真实的实例来替代。(注意,不能对final,Anonymous ,primitive类进行mock。)

 // 静态导入会使代码更简洁
 import static org.mockito.Mockito.*;

 // 创建mock对象
 List mockedList = mock(List.class);

 // 使用mock对象
 mockedList.add("one");
 mockedList.clear();

 //verification 验证是否调用了add()和clear()方法
 verify(mockedList).add("one");
 verify(mockedList).clear();

其中,verify用于验证程序中某些方法的执行次数,默认为times(1)省略不写,可以设置次数num:

 Mockito.verify(mockedList, Mockito.times(num)).add(Mockito.any());          

验证方法从未调用:

// 使用never()进行验证,never相当于times(0)
 verify(mockedList, never()).add("never happened");

验证方法最少和最多调用次数:

// 使用atLeast()/atMost()
 verify(mockedList, atLeastOnce()).add("three times");
 verify(mockedList, atLeast(2)).add("five times");
 verify(mockedList, atMost(5)).add("three times");

在单元测试的时候,我们需要让虚拟对象返回我们需要的值,那么这时候可以做一些测试桩 (Stub)。

  • 默认情况下,所有的函数都有返回值。mock函数默认返回的是null

  • 测试桩函数可以被覆写,最近的一次覆写有效;

  • 一旦测试桩函数被调用,该函数将会返回测试者设置的固定值;

示例:

LinkedList mockedList = mock(LinkedList.class);
// 测试桩
Mockito.stub(mockedList.get(0)).toReturn("first");
// 输出“first”
System.out.println(mockedList.get(0));

通过toThrow设置某个方法抛出异常:

Mockito.stub(mockedList.get(0)).toThrow(new Exception());

为返回值为void的方法设置通过stub抛出异常:

doThrow(new RuntimeException()).when(mockedList).clear();

// 调用这句代码会抛出异常
mockedList.clear(); 

Mockito还可以为连续调用做测试桩:

Mockito.stub(titanAsyncJob.isProcessing()).toReturn(false).toReturn(true);
// 第一次调用:res1为false
boolean res1 = titanAsyncJob.isProcessing();
// 第二次调用:res2为true
boolean res2 = titanAsyncJob.isProcessing();

介绍了这么多,回到我们的示例中来吧,接下来示范了如何使用Mockito来虚拟替换掉原本的RateService类,并且如何设置调用虚拟类时期望的返回值。(此处增加了一个魔数magicNumber,用于模拟实际业务中对于用户id可能进行的一系列操作,等效添加了随机值)

public class CalcServiceWithRemoteRateTestWithConstructorMock {
    private RateService rateService = Mockito.mock(RateService.class);
    private final static Long magicNumber = 1000L;
    private CalcServiceWithRemoteRate calcService = new CalcServiceWithRemoteRate(rateService, magicNumber);


@Before
public void setup() {
    Mockito.stub(rateService.currentRate()).toReturn(new BigDecimal("0.01"));
    Mockito.stub(rateService.currentRate(Mockito.anyLong())).toReturn(new BigDecimal("0.02"));
    Mockito.stub(rateService.currentRate(Mockito.eq(0L))).toReturn(new BigDecimal("0.01"));

}

@Test
public void test_compoundingCalc() throws Exception {

    BigDecimal total = calcService.compoundingCalc(new BigDecimal("4000"), 1);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("4000")) == 0);

    total = calcService.compoundingCalc(new BigDecimal("4000"), 2);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8040")) == 0);

    total = calcService.compoundingCalc(new BigDecimal("4000"), 4);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("16241.604")) == 0);
}

@Test
public void test_compoundingCalcWithUserId() throws Exception {

    BigDecimal total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 1, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("4000")) == 0);

    total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 2, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8040")) == 0);

    total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 4, 0L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("16241.604")) == 0);


    total = calcService.compoundingCalcWithUserId(new BigDecimal("4000"), 2, 1L);
    Assert.assertTrue("total money error", total.compareTo(new BigDecimal("8080")) == 0);
}

//测试是否正确的调用 rateService 的 currentRate
@Test
public void test_compoundingCalcWithMagicUserId() throws Exception {
    Long userId = 0L;
    calcService.compoundingCalcWithMagicUserId(new BigDecimal("4000"), 2, userId);
    Mockito.verify(rateService, Mockito.only()).currentRate(Mockito.eq(magicNumber + userId));
}
}

可以看到,在测试的@Before中我们对虚拟对象的相关值进行了设置。

前两个测试不再累述,第三个测试的Mockito.verify是为了验证虚拟对象rateServicecurrentRate方法是否被正确调用过,这是在验证虚拟对象自身的行为是否符合预期。

当然,Junit和Mockito的功能还有很多,单元测试小神器也数不胜数。另外,在前后端联调或者系统间联调时,如果您不希望因为其他系统的原因导致您的代码发生错误,那么MockServer就是一个不错的选择。说到MockServer,那又是另一个故事了,今天就到这里,小编下次再来唠嗑吧!

[转载请说明出处]  F I 博客:http://blog.mschina.io

posted @ 2017-07-16 10:30  李涛军  阅读(374)  评论(0编辑  收藏  举报