Android单元测试(五):JUnit进阶

前面一章介绍了JUnit的一些基本用法,本章来介绍关于JUnit更高级的用法,这些功能我们可能并不一定会用到,但是了解它,对JUnit会有更深刻的认识。

5.1 Test runners

大家刚开始使用JUnit的时候,可能会跟我一样有一个疑问,JUnit没有main()方法,那它是怎么开始执行的呢?众所周知,不管是什么程序,都必须有一个程序执行入口,而这个入口通常是main()方法。显然,JUnit能直接执行某个测试方法,那么它肯定会有一个程序执行入口。没错,其实在org.junit.runner包下,有个JUnitCore.java类,这个类有一个标准的main()方法,这个其实就是JUnit程序的执行入口,其代码如下:

public static void main(String... args) {
    Result result = new JUnitCore().runMain(new RealSystem(), args);
    System.exit(result.wasSuccessful() ? 0 : 1);
}

通过分析里面的runMain()方法,可以找到最终的执行代码如下:

    public Result run(Runner runner) {
        Result result = new Result();
        RunListener listener = result.createListener();
        notifier.addFirstListener(listener);
        try {
            notifier.fireTestRunStarted(runner.getDescription());
            runner.run(notifier);
            notifier.fireTestRunFinished(result);
        } finally {
            removeListener(listener);
        }
        return result;
    }

可以看到,所有的单元测试方法都是通过Runner来执行的。Runner只是一个抽象类,它是用来跑测试用例并通知结果的,JUnit提供了很多Runner的实现类,可以根据不同的情况选择不同的test runner。

5.1.1 @RunWith

通过@RunWith注解,可以为我们的测试用例选定一个特定的Runner来执行。

  • 默认的test runner是 BlockJUnit4ClassRunner
  • @RunWith(JUnit4.class),使用的依然是默认的test runner,实质上JUnit4继承自BlockJUnit4ClassRunner。
5.1.2 Suite

Suite翻译过来是测试套件,意思是让我们将一批其他的测试类聚集在一起,然后一起执行,这样就达到了同时运行多个测试类的目的。



如上图所示,假设我们有3个测试类:TestLogin, TestLogout, TestUpdate,使用Suite编写一个TestSuite类,我们可以将这3个测试类组合起来一起执行。TestSuite类代码如下:

@RunWith(Suite.class)
@Suite.SuiteClasses({
        TestLogin.class,
        TestLogout.class,
        TestUpdate.class
})
public class TestSuite {
    //不需要有任何实现方法
}

执行运行TestSuite,相当于同时执行了这3个测试类。
Suite还可以进行嵌套,即一个测试Suite里包含另外一个测试Suite。

@RunWith(Suite.class)
@Suite.SuiteClasses(TestSuite.class)
public class TestSuite2 {
}
5.1.3 Parameterized

我们常规的测试方法都是public void修饰的,不能带有任何输入参数。但是有时我们需要在测试方法里输入参数,甚至可能需要指定批量的参数,如果使用常规的模式,那就需要为每一种参数写一个测试方法,这显然不是我们所期望的。使用Parameterized这个test runner就能实现这个目的。

我们有一个待测试类,菲波那切函数,代码如下:

public class Fibonacci {
    public static int compute(int n) {
        int result = 0;
        if(n <= 1) {
            result = n;
        } else {
            result = compute(n -1) + compute(n - 2);
        }
        return result;
    }
}

针对这个函数,我们需要多个输入参数来验证是否正确,来看看怎么实现这个目的。

  • 使用构造函数来注入参数值
//指定Parameterized作为test runner
@RunWith(Parameterized.class)
public class TestParams {

    //这里是配置参数的数据源,该方法必须是public static修饰的,且必须返回一个可迭代的数组或者集合
    //JUnit会自动迭代该数据源,自动为参数赋值,所需参数以及参数赋值顺序由构造器决定。
    @Parameterized.Parameters
    public static List getParams() {
        return Arrays.asList(new Integer[][]{
                { 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 }
        });
    }

    private int input;
    private int expected;

    //在构造函数里,指定了2个输入参数,JUnit会在迭代数据源的时候,自动传入这2个参数。
    //例如:当获取到数据源的第3条数据{2,1}时,input=2,expected=1
    public TestParams(int input, int expected) {
        this.input = input;
        this.expected = expected;
    }

    @Test
    public void testFibonacci() {
        System.out.println(input + "," + expected);
        Assert.assertEquals(expected, Fibonacci.compute(input));
    }

}

执行该测试类,可以看到执行过程中的打印结果:

0,0
1,1
2,1
3,2
4,3
5,5
6,8
  • 使用@Parameter注解来注入参数值
@RunWith(Parameterized.class)
public class TestParams2 {

    @Parameterized.Parameters
    public static List getParams() {
        return Arrays.asList(new Integer[][]{
                { 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 }
        });
    }

    //这里必须是public,不能是private
    @Parameterized.Parameter
    public int input;

    //注解括号里的参数,用来指定参数的顺序,默认为0
    @Parameterized.Parameter(1)
    public int expected;

    @Test
    public void testFibonacci() {
        System.out.println(input + "," + expected);
        Assert.assertEquals(expected, Fibonacci.compute(input));
    }

}
5.1.4 Categories

Categories继承自Suite,但是比Suite功能更加强大,它能对测试类中的测试方法进行分类执行。当你想把不同测试类中的测试方法分在一组,Categories就很管用。

  • 先定义category marker类,它们只是用来标记类别的,并不承担任何业务逻辑。
public interface CategoryMarker {

    public interface FastTests {
        /* category marker */
    }

    public interface SlowTests {
        /* category marker */
    }
}
  • 通过@Category注解来标记测试方法的类别
public class A {

    @Test
    public void a() {
        System.out.println("method a() called in class A");
    }

    //标记该测试方法的类别
    @Category(CategoryMarker.SlowTests.class)
    @Test
    public void b() {
        System.out.println("method b() called in class A");
    }

}
  • 通过@Category注解来标记测试类的类别
@Category({CategoryMarker.FastTests.class, CategoryMarker.SlowTests.class})
public class B {

    @Test
    public void c() {
        System.out.println("method c() called in class B");
    }

}
  • 分类执行
@RunWith(Categories.class)
@Categories.IncludeCategory(CategoryMarker.SlowTests.class)
@Suite.SuiteClasses({A.class, B.class})  //Categories本身继承自Suite
public class SlowTestSuite {
    //如果不加@Categories.IncludeCategory注解,效果与Suite一样
}

//执行结果,打印信息如下:
method b() called in class A
method c() called in class B
@RunWith(Categories.class)
@Categories.IncludeCategory({CategoryMarker.SlowTests.class})    //指定包含的类别
@Categories.ExcludeCategory({CategoryMarker.FastTests.class})    //需要排除的类别
@Suite.SuiteClasses({A.class, B.class})
public class SlowTestSuite2 {
}

//执行结果,打印信息如下:
method b() called in class A

5.2 @Test的属性

5.2.1 timeout

timeout用来测试一个方法能不能在规定时间内完成,当为一个测试方法指定了timeout属性后,该方法会运行在一个单独的线程里执行。如果测试方法运行时间超过了指定的timeout时间,测试则会失败,并且JUnit会中断执行该测试方法的线程。

    //该方法会在一个单独的线程中执行,单位为毫秒,这里超时时间为2秒
    @Test(timeout = 2000)
    public void testTimeout() {
        System.out.println("timeout method called in thread " + Thread.currentThread().getName());
    }

    //该方法默认会在主线程中执行
    @Test
    public void testNormalMethod() {
        System.out.println("normal method called in thread " + Thread.currentThread().getName());
    }

    //该方法指定了timeout时间为1秒,实际运行时会超过1秒,该方法测试无法通过
    @Test(timeout = 1000)
    public void testTimeout2() {
        try {
            Thread.sleep(1200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

执行后打印结果如下:

timeout method called in thread Time-limited test
normal method called in thread main
5.2.2 expected

expected属性是用来测试异常的。例如:

    new ArrayList<Object>().get(0);

这段代码应该抛出一个IndexOutOfBoundsException异常信息,如果我们想验证这段代码是否抛出了异常,我们可以这样写:

    @Test(expected = IndexOutOfBoundsException.class)
    public void empty() {
        new ArrayList<Object>().get(0);
    }

5.3 Rules

@Rule是JUnit4的新特性,它能够灵活地扩展每个测试方法的行为,为他们提供一些额外的功能。下面是JUnit提供的一些基础的的test rule,所有的rule都实现了TestRule这个接口类。除此外,可以自定义test rule。

5.3.1 TestName Rule

在测试方法内部能知道当前的方法名。

public class NameRuleTest {
   //用@Rule注解来标记一个TestRule,注意必须是public修饰的
  @Rule
  public final TestName name = new TestName();
  
  @Test
  public void testA() {
    assertEquals("testA", name.getMethodName());
  }
  
  @Test
  public void testB() {
    assertEquals("testB", name.getMethodName());
  }
}
5.3.2 Timeout Rule

与@Test注解里的属性timeout类似,但这里是针对同一测试类里的所有测试方法都使用同样的超时时间。

public class TimeoutRuleTest {

    @Rule
    public final Timeout globalTimeout = Timeout.millis(20);

    @Test
    public void testInfiniteLoop1() {
        for(;;) {}
    }

    @Test
    public void testInfiniteLoop2() {
        for(;;) {}
    }
}
5.3.3 ExpectedException Rules

与@Test的属性expected作用类似,用来测试异常,但是它更灵活方便。

public class ExpectedExceptionTest {

    @Rule
    public final ExpectedException exception = ExpectedException.none();

    //不抛出任何异常
    @Test
    public void throwsNothing() {
    }

    //抛出指定的异常
    @Test
    public void throwsIndexOutOfBoundsException() {
        exception.expect(IndexOutOfBoundsException.class);
        new ArrayList<String>().get(0);
    }

    @Test
    public void throwsNullPointerException() {
        exception.expect(NullPointerException.class);
        exception.expectMessage(startsWith("null pointer"));
        throw new NullPointerException("null pointer......oh my god.");
    }

}
5.3.4 TemporaryFolder Rule

该rule能够创建文件以及文件夹,并且在测试方法结束的时候自动删除掉创建的文件,无论测试通过或者失败。

public class TemporaryFolderTest {

    @Rule
    public final TemporaryFolder folder = new TemporaryFolder();

    private static File file;

    @Before
    public void setUp() throws IOException {
        file = folder.newFile("test.txt");
    }

    @Test
    public void testFileCreation() throws IOException {
        System.out.println("testFileCreation file exists : " + file.exists());
    }

    @After
    public void tearDown() {
        System.out.println("tearDown file exists : " + file.exists());
    }

    @AfterClass
    public static void finish() {
        System.out.println("finish file exists : " + file.exists());
    }

}

测试执行后打印结果如下:

testFileCreation file exists : true
tearDown file exists : true
finish file exists : false    //说明最后文件被删除掉了
5.3.5 ExternalResource Rules

实现了类似@Before、@After注解提供的功能,能在方法执行前与结束后做一些额外的操作。

public class UserExternalTest {

    @Rule
    public final ExternalResource externalResource = new ExternalResource() {
        @Override
        protected void after() {
            super.after();
            System.out.println("---after---");
        }

        @Override
        protected void before() throws Throwable {
            super.before();
            System.out.println("---before---");
        }
    };

    @Test
    public void testMethod() throws IOException {
        System.out.println("---test method---");
    }

}

执行后打印结果如下:

---before---
---test method---
---after---
5.3.6 Custom Rules

自定义rule必须实现TestRule接口。
下面我们来写一个能够让测试方法重复执行的rule:

public class RepeatRule implements TestRule {

    //这里定义一个注解,用于动态在测试方法里指定重复次数
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD})
    public @interface Repeat {
        int count();
    }

    @Override
    public Statement apply(final Statement base, final Description description) {
        Statement repeatStatement =  new Statement() {
            @Override
            public void evaluate() throws Throwable {
                Repeat repeat = description.getAnnotation(Repeat.class);
                //如果有@Repeat注解,则会重复执行指定次数
                if(repeat != null) {
                    for(int i=0; i < repeat.count(); i++) {
                        base.evaluate();
                    }
                } else {
                    //如果没有注解,则不会重复执行
                    base.evaluate();
                }
            }
        };
        return repeatStatement;
    }
}
public class RepeatTest {

    @Rule
    public final RepeatRule repeatRule = new RepeatRule();

    //该方法重复执行5次
    @RepeatRule.Repeat(count = 5)
    @Test
    public void testMethod() throws IOException {
        System.out.println("---test method---");
    }

    @Test
    public void testMethod2() throws IOException {
        System.out.println("---test method2---");
    }
}

执行结果如下:

---test method2---
---test method---
---test method---
---test method---
---test method---
---test method---

5.4 小结

本文主要介绍了JUnit自身提供的几个主要的test runner,还有@Test的timeout跟expected属性,最后介绍了一些常规的Rules使用方法。test runner是一个很重要的概念,因为在Android中进行单元测试时,通常都是JUnit结合其他测试框架来一起完成,例如mockito、robolectric,它们都有自己实现的一套test runner,我们必须使用这些框架提供的test runner才能发挥这些框架的最大作用。

系列文章:
Android单元测试(一):前言
Android单元测试(二):什么是单元测试
Android单元测试(三):测试难点及方案选择
Android单元测试(四):JUnit介绍
Android单元测试(五):JUnit进阶
Android单元测试(六):Mockito学习
Android单元测试(七):Robolectric介绍
Android单元测试(八):怎样测试异步代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345