Java单元测试Junit5入门实战

什么是单元测试

什么是软件测试

软件测试(英语:Software Testing),描述一种用来促进鉴定软件的正确性、完整性、安全性和质量的过程。换句话说,软件测试是一种实际输出与预期输出之间的审核或者比较过程。软件测试的经典定义是:在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。

单元测试

什么是单元测试

单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。 通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是什么必须的,但也不坏,这牵涉到项目管理的政策决定。

单元测试的优点

  • 适应变更

单元测试允许程序员在未来重构代码,并且确保模块依然工作正确(复合测试)。这个过程就是为所有函数和方法编写单元测试,一旦变更导致错误发生,借助于单元测试可以快速定位并修复错误。

  • 简化集成

单元测试消除程序单元的不可靠,采用自底向上的测试路径。通过先测试程序部件再测试部件组装,使集成测试变得更加简单。

  • 文档记录

单元测试提供了系统的一种文档记录。借助于查看单元测试提供的功能和单元测试中如何使用程序单元,开发人员可以直观的理解程序单元的基础 API。

  • 表达设计

在测试驱动开发的软件实践中,单元测试可以取代正式的设计。每一个单元测试案例均可以视为一项类、方法和待观察行为等设计元素。

JUnit 测试框架

JUnit 是一个开源的 Java 编程语言的单元测试框架。通过 JUnit,可以提高测试代码编写的速度与质量,而且 JUnit 测试可以自动运行,检查自身结果并提供即时反馈,无需人工整理测试结果。JUnit 凭借它的优势,在 Java 单元测试中得到广泛使用。

JUnit 集成在许多 IDE 当中,如 Eclipse。目前 JUnit 最新版本为 JUnit5,因此本课程使用 Eclipse 中的 JUnit5 作为主要的实战环境。

第一个单元测试

手动测试

编写好方法后一般会测试这个方法能不能行得通,在不使用 JUnit 的情况下,一般使用如下的方法进行测试。

public class Add {
    public int add(int a, int b) {
        return a + b;
    }

    public static void main(String[] args) {
        Add add = new Add();
        if (add.add(1, 1) == 2) {
            System.out.println("Test pass");
        } else {
            System.out.println("Test fail");
        }
    }
}

手动测试需要新建一个实例,并且调用对应的方法,然后对结果进行比较判断,最后输出测试结果。

使用 JUnit 进行测试

创建一个 JUnit 测试类 AddTest.java,具体操作为:首先选择 src 目录,在 Eclipse 顶部菜单选择 File->New->JUnit Test Case

选择 Junit Test Case,创建一个测试用例 AddTest

Eclipse 会提示是否添加 JUnit 5 的 jar 到项目中,选择 ok

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class AddTest {
    public static Add add;

    @BeforeAll //在所有测试方法运行前运行,并且只能修饰静态方法(除非修改测试实例生命周期)
    public static void setUp() throws Exception {
        add = new Add();
    }

    @Test  //表示这是个测试方法
    void add() {
        //断言相等,比较2和add.add(1,1)的返回值是否相等
        assertEquals(2,add.add(1,1));
    }
}

接着运行测试方法,测试结果如图所示。

可以看到 Eclipse 的控制台自动打印出了相关信息,包括运行的测试用例的总数,测试成功数量,测试失败数量,并且可以快速的定位到测试方法,方便进行修改。可以看到单元测试和手动测试相比要简单快捷不少。

JUnit注解

JUnit 5 注解

注解

描述

@Test

表示方法是一种测试方法。与 JUnit 4 的@Test 注解不同,此注释不会声明任何属性

@ParameterizedTest

表示方法是参数化测试

@RepeatedTest

表示方法是重复测试模板

@TestFactory

表示方法是动态测试的测试工程

@TestInstance

用于配置测试实例生命周期

@TestTemplate

表示方法是为多次调用的测试用例的模板

@DisplayName

为测试类或者测试方法自定义一个名称

@BeforeEach

表示方法在每个测试方法运行前都会运行

@AfterEach

表示方法在每个测试方法运行之后都会运行

@BeforeAll

表示方法在所有测试方法之前运行,注意使用该注解的方法必须返回 void、访问级别不允许为 private,且必须声明为静态 (static) 方法

@AfterAll

表示方法在所有测试方法之后运行,而且该注解的使用限制与 @BeforeAll 一致

@Nested

表示带注解的类是嵌套的非静态测试类,@BeforeAll 和 @AfterAll 方法不能直接在 @Nested 测试类中使用,除非修改测试实例生命周期

@Tag

用于在类或方法级别声明用于过滤测试的标记

@Disabled

用于禁用测试类或测试方法

@ExtendWith

用于注册自定义扩展,该注解可以继承

JUnit 常用注解的使用

首先创建一个项目 JunitTest,接着在src目录下创建一个类Add.java

public class Add {
    public int add(int a, int b) {
        return a + b;
    }
}

接下来 右键->new->other->Junit Test Case,创建一个测试用例 AnnotationsTest.java

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.Test;

//常用注解测试
@DisplayName("Common annotation test")
public class AnnotationsTest {

    private static Add add;

    @BeforeAll
    public static void beforeAll() {
        add=new Add();
        //在所有测试方法运行前运行
        System.out.println("Run before all test methods run");
    }

    @BeforeEach
    public void beforeEach() {
        //每个测试方法运行前运行
        System.out.println("Run before each test method runs");
    }

    @AfterEach
    public void afterEach() {
        //每个测试方法运行完毕后运行
        System.out.println("Run after each test method finishes running");
    }

    @AfterAll
    public static void afterAll() {
        //在所有测试方法运行完毕后运行
        System.out.println("Run after all test methods have finished running");
    }

    @Disabled
    @Test
    @DisplayName("Ignore the test")
    public void disabledTest() {
        //这个测试不会运行
        System.out.println("This test will not run");
    }

    @Test
    @DisplayName("Test Methods 1+1")
    public void testAdd1() {
        System.out.println("Running test method1+1");
        Assertions.assertEquals(2,add.add(1,1));
    }

    @Test
    @DisplayName("Test Methods 2+2")
    @RepeatedTest(1)
    public void testAdd2() {
        // 这个测试将重复一次
        System.out.println("Running test method2+2");
        Assertions.assertEquals(4,add.add(2,2));
    }


}

运行测试类查看结果。

JUnit断言

什么是断言

编写代码时,我们总是会做出一些假设,断言就是用于在代码中捕捉这些假设。断言表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。 使用断言可以创建更稳定、品质更好且不易于出错的代码。当需要在一个值为 FALSE 时中断当前操作的话,可以使用断言。

JUnit 5 常用断言

下表提供了一些常用的 JUnit 5 断言方法。

断言

描述

assertAll

分组断言,执行其中包含的所有断言

assertEquals

判断断言预期值和实际值是否相等

assertNotEquals

判断断言预期值和实际值是否不相等

assertArrayEquals

判断断言预期数组和实际数组相等

assertTrue

判断断言条件是否为真

assertFalse

判断断言条件是否为假

assertNull

判断断言条件是否为空

assertNotNull

判断断言条件是否不为空

assertSame

判断两个对象引用是否指向同一个对象

assertTimeout

断言超时

fail

使单元测试失败

常用断言的使用

新建一个 Java 项目 JunitTest

src 目录下,执行操作:右键 -> new -> JUnit Test Case

选择 Junit Test Case,创建一个测试用例 Assert

Eclipse 会提示是否添加 JUnit 5 的 jar 到项目中,选择 ok

项目代码如下:

import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.*;


import org.junit.jupiter.api.Test;

class Assert {
    @Test
    void standardAssertions() {
        assertEquals(2, 2);
        assertEquals(4, 4, "error message");
        assertTrue(2 == 2, () -> "error message");
    }

    @Test
    void groupedAssertions() {
        //分组断言,执行分组中所有断言,分组中任何一个断言错误都会一起报告
        assertAll("person",
            () -> assertEquals("John", "John"),
            () -> assertEquals("Doe", "Doe")
        );
    }

    @Test
    void dependentAssertions() {
        //分组断言
        assertAll("properties",
            () -> {
                // 在代码块中,如果断言失败,后面的代码将不会运行
                String firstName = "John";
                assertNotNull(firstName);
                // 只有前一个断言通过才会运行
                assertAll("first name",
                    () -> assertTrue(firstName.startsWith("J")),
                    () -> assertTrue(firstName.endsWith("n"))
                );
            },
            () -> {
                // 分组断言,不会受到first Name代码块的影响,所以即使上面的断言执行失败,这里的依旧会执行
                String lastName = "Doe";
                assertNotNull(lastName);
                // 只有前一个断言通过才会运行
                assertAll("last name",
                    () -> assertTrue(lastName.startsWith("D")),
                    () -> assertTrue(lastName.endsWith("e"))
                );
            }
        );
    }

    @Test
    void exceptionTesting() {
        //断言异常,抛出指定的异常,测试才会通过
        Throwable exception = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("a message");
        });
        assertEquals("a message", exception.getMessage());
    }

    @Test
    void timeoutNotExceeded() {
        // 断言超时
        assertTimeout(ofMinutes(2), () -> {
            // 完成任务小于2分钟时,测试通过。
        });
    }

    @Test
    void timeoutNotExceededWithResult() {
        // 断言成功并返回结果
        String actualResult = assertTimeout(ofMinutes(2), () -> {
            return "result";
        });
        assertEquals("result", actualResult);
    }

    @Test
    void timeoutExceeded() {
        // 断言超时,会在任务执行完毕后才返回,也就是1000毫秒后返回结果
        assertTimeout(ofMillis(10), () -> {
            // 执行任务花费时间1000毫秒
            Thread.sleep(1000);
        });
    }


    @Test
    void timeoutExceededWithPreemptiveTermination() {
        // 断言超时,如果在10毫秒内任务没有执行完毕,会立即返回断言失败,不会等到1000毫秒后
        assertTimeoutPreemptively(ofMillis(10), () -> {
            Thread.sleep(1000);
        });
    }

}

运行项目后可以得到如下结果:

可以看到其中有 2 个测试没有通过,这个是我们 Thread.sleep() 方法设置的时间超时导致,通过查看这两个测试方法的执行时间,我们可以很轻易的对比 assertTimeoutPreemptively()assertTimeout() 的区别。

JUnit假设和条件测试

什么是假设

JUnit 5 中的假设可以用于控制测试用例是否执行,相当于条件,只有条件满足才会执行,如果条件不满足,那么将不会执行后续测试。

JUnit 5 中的假设主要有如下内容:

方法

描述

assumeFalse

假设为 false 时才会执行,如果为 true,那么将会直接停止执行

assumeTrue

假设为 true 时才会执行,如果为 false,那么将会直接停止执行

assumingThat

assumingThat 接受一个函数式接口 Executable,假设为 true 时执行,将会执行 Executable,否则不会执行 Executable。

JUnit 5 假设示例

新建一个 java 项目 JunitTest,接着创建一个 Junit Test Case (可以参照前面的章节) Assumption.java

import static org.junit.jupiter.api.Assumptions.assumeFalse;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;

import org.junit.jupiter.api.Test;

class Assumption {
         @Test
        void assumeTrueTest() {
             //如果假设传入的值为True,那么就会执行后面测试,否则直接停止执行
            assumeTrue(false);
            System.out.println("This will not be implemented.");
        }

        @Test
        void assumeFalseTest() {
            //如果假设传入的值为false,那么就会执行后面测试,否则直接停止执行
            assumeFalse(true);
            System.out.println("This will not be implemented.");
        }

        @Test
        void assumingThatTest() {
            // assumingThat(boolean assumption, Executable executable)
            // assumingThat接受一个boolean值assumption,如果assumption为true,那么将会执行executable,否则不会执行,
            // 但是assumingThat即使为false也不会影响后续代码的执行,他和assumeFalse和assumeTrue不同,assumingThat只
            // 决定Executable是否执行,Executable是一个函数式接口,接受一个没有参数和返回值的方法。
            assumingThat(false,
                () -> {
                    System.out.println("This will not be implemented.");
                });

            //下面的输出将会执行
            System.out.println("This will be implemented.");
        }
}

执行测试。

从测试结果中可以看到 Runs:3/3(2 skipped),因为 assumeFalse assumeTrue 的条件都不满足,所以执行被中止了,而 assumingThat 不会影响到后续代码,所以 System.out.println("This will be implemented."); 被执行,我们可以在控制台中看到输出。

JUnit 5 条件测试

JUnit 中的条件测试也可以控制测试用例的执行,其中主要有以下几种条件。

操作系统条件

通过 @EnabledOnOs @DisabledOnOs 注解来在指定的操作系统上运行或者关闭测试,这两个注解常用的参数有 LINUXWINDOWS MAC OTHER 等表示操作系统的常量。

Java 运行环境条件

通过 @EnabledOnJre @DisabledOnJre 注解 ava 在指定的 Java 环境 (JRE) 下运行或者关闭测试,这两个注解常用的参数有 JAVA_8JAVA_9JAVA_10 OTHER 等表示不同版本 JRE 的常量。

系统属性条件

根据 JVM 系统属性来开启或者关闭测试,通过 @EnabledIfSystemProperty @DisabledIfSystemProperty 注解来实现。这两个注解都拥有 named matches 两个参数。named 为 JVM 系统参数名称,matches 接受一个正则表达式,用于匹配指定参数的值。

环境变量条件

根据系统环境变量来开启或者关闭测试,通 @EnabledIfEnvironmentVariable @DisabledIfEnvironmentVariable 注解来实现,都拥有 named matches 两个参数。named 为环境变量参数名称,matches 接受一个正则表达式,用于匹配指定参数的值。

代码示例

src目录下新建一个 Junit Test Case ,命名为 Condition.java

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnJre;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.condition.EnabledOnJre;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.api.condition.OS;

class Condition {

    @Test
    @EnabledOnJre(JRE.JAVA_8) // java8环境下运行
    void javaRuntimeConditions() {
        System.out.println("JAVA 8");

    }

    @Test
    @DisabledOnJre(JRE.JAVA_8) //除了java8其他的环境都会运行
    void notONJava8() {
        // 这段代码将不会运行在java8中
        System.out.println("It will not run on Java8.");

    }

    @Test
    @EnabledOnOs(OS.LINUX) //Linux系统下运行
    void operatingSystemConditions() {
        System.out.println("Running under Linux");
    }

    @Test
    @EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
    void systemPropertyConditions() {
        //在64位虚拟机下运行
        System.out.println("Running on a 64 bit system");
        //输出JVM参数列表
        System.out.println(System.getProperties());

    }

    @Test
    @EnabledIfEnvironmentVariable(named = "USER", matches = "shiyanlou")
    void environmentVariableConditions() {
        //输出环境变量参数列表
        System.out.println(System.getenv());
    }
}

运行单元测试,得到如下结果:

本次实验使用云主机的测试环境为 Linux 64 位系统、Java 8 版本,从截图中可以看见,除了方法 notONJava8() 未被执行,其他用例均符合条件。另外在方法 environmentVariableConditions() 中,根据注解参数打印了对应的环境变量信息,我们可以在控制台中看到输出。

JUnit禁用测试

禁用测试

在测试过程中,可能有一些测试暂时还不需要运行,比如功能还没有完成,或者 Bug 短时间无法处理,我们希望这一段测试不会运行,那么可以采用 @Disabled 注解的方式。

@Disabled 注解可以注解在方法上或者注解在类上,注解在方法上时禁用对应的方法,注解在类上的时候禁用该类中所有的测试。

首先新建一个项目 JunitTest,在 src 目录下新建一个 JUnit Test CaseDisabledTest

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class DisabledTest {

    @Test
    //使用@Disabled注解关闭
    @Disabled
    void disabled() {
        System.out.println("Not running");
    }

    @Test
    void open() {
        System.out.println("running");
    }
}

运行测试,结果如下所示。

可以看到注解了 @Disabled 的方法并没有运行。

过滤测试

除了使用 JUnit 的 @Disabled 注解之外,我们也可以使用 @Tag 标签来过滤测试,只运行我们需要的测试。

@Tag 标记语法规则:

  • 标记不能为 null 或空。
  • 不能包含空格。
  • 不能包含 ISO 控制字符。
  • 不能包含以下保留字符。
    • ,(逗号)
    • ( ) (左括号和右括号)
    • &
    • |(竖线)
    • ! (感叹号)

src 目录下新建一个 JUnit Test CaseTagTest

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

class TagTest {

    @Test
    @Tag("tag1")
    void tag1() {
        System.out.println("Tag1 Test");
    }

    @Test
    @Tag("tag2")
    void tag2() {
        System.out.println("Tag2 test");
    }

}

先运行一次看看:

可以看到 2 个测试都有运行。

接着在 TagTest 文件上执行操作: 单击右键 -> run as -> run configurations

点击 configure

勾选 Include Tags 选项,在输入框中填入需要运行的测试方法的标签名。

点击 OK,接着点击 Run

从运行结果可以看出 Eclipse 过滤掉了 tag2 标签,只运行了 tag1 标签的测试用例。

JUnit复测试

@RepeatedTest 注解

通过 @RepeatedTest 注解可以完成重复测试的工作,@RepeatedTest 中的 value 属性可以设置重复的次数,name 属性可以自定义重复测试的显示名,显示名可以由占位符和静态文本组合,目前支持下面几种占位符:

  • {displayName}:显示名;
  • {currentRepetition}:当前重复次数;
  • {totalRepetitions}:总重复次数。

JUnit 重复测试提供了默认的显示名的模式:repetition {currentRepetition} of {totalRepetitions}

如果没有自定义的话,显示名就会是这种形式:repetition 1 of 10

JUnit 还提供了另外一种显示名 RepeatedTest.LONG_DISPLAY_NAME,它显示的形式为:{displayName} :: repetition {currentRepetition} of {totalRepetitions},只需要将 @RepeatedTest(value = 1, name = RepeatedTest.LONG_DISPLAY_NAME) 注解到方法上就可以使用这种显示方式。如果想要用编程的方式获取当前循环测试的相关详细,可以将 RepetitionInfo 实例注入到相关的方法中。

代码示例

打开 Eclipse,新建一个项目 JunitTest,在 src 目录下新建一个 JUnit Test CaseRepeated

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.BeforeAll;

class Repeated {

    @BeforeAll
    public static void beforeAll() {
        System.out.println("Before All");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("Before Each");
    }

    //自定义重复测试的显示名称
    @RepeatedTest(value=5,name="{displayName}-->{currentRepetition}/{totalRepetitions}")
    @DisplayName("repeatTest")
    void repeatedTest(TestInfo testInfo,RepetitionInfo repetitionInfo) {
        //我们可以通过TestInfo在测试中获取测试的相关信息,比如输出自定义的测试名
        System.out.println(testInfo.getDisplayName());
        //输出当前重复次数
        System.out.println("currentRepetition:"+repetitionInfo.getCurrentRepetition());

    }

}

运行测试,测试结果如下所示。

可以看见 repeatedTest() 方法被执行了 5 次,且每次测试的结果都是成功的。

控制台输出如下所示。

从截图可见,在每次重复测试中,利用 getDisplayName() 方法将自定义的测试名打印了出来,且测试名根据 currentRepetition 当前重复次数的值在逐渐递增。另外通过 getCurrentRepetition() 方法再次打印当前重复次数的值,与前面测试名中的值是一致的。

另外应当特别注意 @BeforeAll@BeforeEach 这类注解在重复测试中的表现。从输出结果可以看到,beforeAll() 方法仍然仅在测试开始前执行了一次,而 beforeEach() 方法在每次重复前都执行一次。这种执行结果与非重复测试的情况仍然一致。实际上,重复测试的每次调用的行为就像一个常规 @Test 方法的执行,和常规的测试方法在声明周期和扩展上是一致的。

JUnit参数化测试

参数化测试

参数化测试可以使用不同的参数进行多次测试,相当于重复测试的增强版,只是参数化测试使用@ParameterizedTest 注解声明,而且参数化测试必须声明至少一个参数源,比如 @ValueSource 注解。另外要使用参数化测试必须引入 junit-jupiter-params 依赖,如果使用 Eclipse 创建 JUnit 测试则不需要引入,因为 Eclipse 在引入 JUnit 依赖的时候会引入 JUnit 的所有依赖。

下面写一个简单的参数化测试,在 Eclipse 中新建一个 Java 项目 JunitTest,在 src 目录下新建一个 Junit Test CaseParameterTest

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class ParameterTest {

    @ParameterizedTest
    @ValueSource(strings= {"Java","C++","Python"})
    void parameter(String args) {
        System.out.println(args);
    }

}

运行测试结果如下所示。

控制台输出结果如下所示。

参数源

测试方法数据的来源就是参数源,JUnit 提供了多种参数源。

参数源介绍

@ValueSource

@ValueSource 允许指定原生类型的数组,并且只能用于为每个参数化测试调用提供单个参数。@ValueSource 支持下面几种类型:

  • int[]
  • long[]
  • short[]
  • double[]
  • float[]
  • char[]
  • byte[]
  • boolean[]
  • java.lang.String[]
  • java.lang.Class<?>[]

@EnumSource

@EnumSource 提供了一种使用 Enum 常量的便捷方法。该注释提供了一个可选 names 参数,允许指定应使用哪些常量。如果省略,将使用所有常量。

@MethodSource

@MethodSource 允许引用测试类或外部类的一个或多个工厂方法。这个方法必须是 static 的(除非修改测试类生命周期为@TestInstance(Lifecycle.PER_CLASS)),返回值必须是一个 Stream、Iterable、Iterator 或者参数数组。并且这些方法不能有参数。外部类的方法则必须是 static 的。

@CsvSource

@CsvSource 允许将参数列表定义为以逗号分隔的值(即 String 类型)。@CsvSource 使用单引号 ' 作为引号字符。空的引用值 '' 会被解释成空的的 String 类型,而完全空值被解释为 null。

JUnit 还支持从 CSV 格式文件中读取数据作为数据源,使用的注解为 @CsvFileSource ,其本质与 @CsvSource 一样,都是对 CSV 格式的数据进行处理,主要区别在于文件路径的引入和数据读取的细节上,具体使用可参考文档 CsvFileSource

输入

输出

@CsvSource({ "a, b" })

"a","b"

@CsvSource({ "a, 'b,c'" })

"a","b,c"

@CsvSource({ "a, ''" })

"a",""

@CsvSource({ "a, " })

"a",null

@ArgumentsSource

@ArgumentsSource 可用于指定自定义,可重用 ArgumentsProvider。自定义时需要实现 ArgumentsProvider 接口,并且必须将实现声明为 public 类或 static 嵌套类。

代码示例

修改 ParameterTest.java

import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;

class ParameterTest {

    @ParameterizedTest
    @ValueSource(strings= {"Java","C++","Python"})
    void parameter(String args) {
        System.out.println(args);
    }

    @ParameterizedTest
    //使用names制定需要的枚举常量
    @EnumSource(value = TimeUnit.class, names = { "DAYS", "HOURS" })
    void enumSource(TimeUnit timeUnit) {
        System.out.println(timeUnit.toString());
    }

    @ParameterizedTest
    @CsvSource({ "Java, 1", "C++, 2", "'Python, Lisp', 3" })
    void csvSource(String first, int second) {
        System.out.println(first+"---"+second);
    }


    @ParameterizedTest
    @ArgumentsSource(MyArgumentsProvider.class)
    void argumentsSource(String argument) {
        System.out.print(argument);
    }

    static class MyArgumentsProvider implements ArgumentsProvider {

        @Override
        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
            return Stream.of("Java-", "C-", "Python\n").map(Arguments::of);
        }
    }
}

运行测试,结果如图所示。

由图可见,在实验例子中每个参数均通过了测试。

注意:由于例子中的测试都使用 @ParameterizedTest 注解,使用相同的注解的多个方法,它们的执行顺序是不确定的。

控制台输出结果如图所示。

JUnit测试实践

项目结构

首先展示本次实验的项目结构。注意创建 Java 项目 JunitTest 后,应当将实验给出的测试样例程序 SaleMachine.java 导入到项目和包中。

接下来讲解测试实验的具体内容。

本次单元测试实验主要包含两部分:路径测试和功能测试。

路径测试属于白盒测试,主要是检查该程序的路径分支是否可完整覆盖执行、是否存在编程逻辑错误,如错误的计算、不正确的比较等原因导致不正常的控制流程缺陷。

功能测试属于黑盒测试,着重测试软件的功能需求,不需要考虑内部结构及代码,是在程序接口上进行的测试,检查产品是否达到用户要求的功能。

因此,在进行程序测试前,首先需要理解程序的功能需求,并且对被测程序进行流程分析,画出程序流程图,分析程序的执行路径,以便设计相应的测试用例。

程序分析

被测程序 SaleMachine.java 代码如下。

package test;

public class SaleMachine {

    private int countOfBeer, countOfFiveJiao, countOfOneYuan;// 售货机中 3 个资源变量,分别代表啤酒的数量、5角硬币的数量、1元硬币的数量
    private String resultOfDeal;// 销售结果

    public SaleMachine()// 默认构造函数
    {
        initial();// 初始化
    }

    public void initial()// 将各类资源的数量清0
    {
        countOfBeer = 0; // 售货机啤酒数量清零
        countOfFiveJiao = 0;// 售货机5角硬币数量清零
        countOfOneYuan = 0;// 售货机1元硬币数量清零
    }

    public SaleMachine(int fiveJiao, int oneYuan, int numOfBeer)
    // 有参数的构造函数,将实际参数传递给形参,对类中属性变量初始化
    {
        countOfFiveJiao = fiveJiao;
        countOfOneYuan = oneYuan;
        countOfBeer = numOfBeer;
    }

    public String currentState()// 获取售货机当前四种资源变量数量值
    {
        String state = "Current State\n" + "Beer: " + countOfBeer + "\n" + "5 Jiao: " + countOfFiveJiao + "\n"
                + "1 Yuan: " + countOfOneYuan;
        return state;
    }

    public String operation(String money)// 售货机操作控制程序
    // type参数代表客户选择的购买商品类型,money参数代表客户投币类型
    {
        if (money.equalsIgnoreCase("5J")) // 如果客户投入5角钱
        {
            if (countOfBeer > 0) // 如果还有啤酒,进行交易,修改资源数量
            {
                // 路径S1输出信息
                countOfBeer--;
                countOfFiveJiao++;
                resultOfDeal = "Input Information\n" + "Money: 5 Jiao; Change: 0";
                return resultOfDeal;
            } else // 没有啤酒,输出啤酒短缺的信息
            {
                // 路径S2输出信息
                resultOfDeal = "Failure Information\n" + "Beer Shortage";
                return resultOfDeal;
            }
        } else if (money.equalsIgnoreCase("1Y")) // 如果客户投入一元钱
        {
            if (countOfFiveJiao > 0) // 如果售货机有5角零钱
            {
                // 路径S3输出信息,还有啤酒
                if (countOfBeer >= 0) {
                    countOfBeer--;
                    countOfFiveJiao++;
                    countOfOneYuan++;
                    resultOfDeal = "Input Information\n" + "Money: 1 Yuan; Change: 5 Jiao";
                    return resultOfDeal;
                } else {
                    // 路径S4,没有啤酒,输出啤酒短缺信息
                    resultOfDeal = "Failure Information\n" + "Beer Shortage";
                    return resultOfDeal;
                }
            } else // 售货机没有5角零钱,输出零钱短缺错误信息
            {
                // 路径S5输出信息
                resultOfDeal = "Failure Information\n" + "Change Shortage";
                return resultOfDeal;
            }
        } else // 客户输入不是5J和1Y,输出投币类型错误信息
        {
            // 路径S6输出信息
            resultOfDeal = "Failure Information\n" + "Money Error";
            return resultOfDeal;
        }
    }
}

SaleMachine 程序功能是模拟一台简单的啤酒售卖机,它遵守以下的销售规则:

  • 啤酒销售价格为 5 角。
  • 机器仅接受 1 元和 5 角两种货币,如果投入其他类型货币,将会提示错误信息。
  • 当收到 5 角货币时,机器检查啤酒库存,库存短缺则提示错误信息。反之销售成功,记录库存和货币数量变化信息。
  • 当收到 1 元货币时,机器首先检测是否有 5 角货币进行找零,若缺少零钱则提示错误信息;有零钱的前提下,接着检查啤酒库存,库存短缺则提示错误信息。反之销售成功,记录库存和货币数量变化信息。

SaleMachine 程序的流程图如下:

可以看见程序中一共有六个路径,根据用户不同的输入值,程序应当执行用户期望的路径,这是程序路径测试是否成功的关键。

在了解程序的功能和工作流程后,应当进行测试用例的设计。

测试用例设计

SaleMachine 程序中,其 operation(String Money) 方法函数将根据不同输入参数执行不同路径。Money 参数是客户投入的硬币金额,如 5J 和 1Y。在自动售货机程序内,使用变量 CountOfBeer 记录啤酒的数量、使用变量 CountOfFiveJiao 记录 5 角硬币数量,使用 CountOfOneYuan 记录 1 元硬币的数量。各个变量的初值在程序初始化中设置。

在设计的测试用例表中,每行对应一个路径的测试用例,路径测试用例表如下所示:

用例编号

输入值

资源变量状态

期望路径

判断准则

1

5J

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

S1

执行路径与期望路径一致

2

5J

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=0

S2

执行路径与期望路径一致

3

1Y

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

S3

执行路径与期望路径一致

4

1Y

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=0

S4

执行路径与期望路径一致

5

1Y

CountOfFiveJiao=0
CountOfOneYuan=5
CountOfBeer=5

S5

执行路径与期望路径一致

6

1J

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

S6

执行路径与期望路径一致

当样本程序 SaleMachine 的功能函数 Operation(String Money) 在传入不同的参数时,跳转不同分支路径进行功能处理,并对各个资源变量进行修改。资源变量变化后的值,与程序设计期望的输出值是否一致,决定了程序功能是否正常。根据这点设计功能测试用例表,如下所示:

用例编号

输入值

资源变量

期望输出值

判断准则

1

5J

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

Current State
Beer: 4
5 Jiao: 6
1 Yuan: 5

是否测试通过

2

1Y

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

Current State
Beer: 4
5 Jiao: 4
1 Yuan: 6

是否测试通过

3

1J

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

Current State
Beer: 5
5 Jiao:5
1 Yuan:5

是否测试通过

4

null

CountOfFiveJiao=5
CountOfOneYuan=5
CountOfBeer=5

Current State
Beer: 5
5 Jiao: 5
1 Yuan: 5

是否测试通过

编写测试用例类

  • 编写路径测试用例类 SaleMachinePathTest.java
package test;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.Test;

public class SaleMachinePathTest {

    @Test
    public void testOperationS1() { // 路径S1:有零钱 有啤酒 投5角
        SaleMachine saleMachine1 = new SaleMachine(5, 5, 5);
        String expectedResult = "Input Information\n" + "Money: 5 Jiao; Change: 0";
        assertEquals(expectedResult, saleMachine1.operation("5J"));
    }

    @Test
    public void testOperationS2() { // 路径S2:有零钱 无啤酒 投5角
        SaleMachine saleMachine2 = new SaleMachine(5, 5, 0);
        String expectedResult = "Failure Information\n" + "Beer Shortage";
        assertEquals(expectedResult, saleMachine2.operation("5J"));
    }

    @Test
    public void testOperationS3() { // 路径S3:有零钱 有啤酒 投1元
        SaleMachine saleMachine3 = new SaleMachine(5, 5, 5);
        String expectedResult = "Input Information\n" + "Money: 1 Yuan; Change: 5 Jiao";
        assertEquals(expectedResult, saleMachine3.operation("1Y"));
    }

    @Test
    public void testOperationS4() { // 路径S4:有零钱 无啤酒 投1元
        SaleMachine saleMachine4 = new SaleMachine(5, 5, 0);
        String expectedResult = "Failure Information\n" + "Beer Shortage";
        assertEquals(expectedResult, saleMachine4.operation("1Y"));
    }

    @Test
    public void testOperationS5() { // 路径S5:无零钱 有啤酒 投1元
        SaleMachine saleMachine5 = new SaleMachine(0, 5, 5);
        String expectedResult = "Failure Information\n" + "Change Shortage";
        assertEquals(expectedResult, saleMachine5.operation("1Y"));
    }

    @Test
    public void testOperationS6() { // 路径S6:有零钱 有啤酒 投1角
        SaleMachine saleMachine6 = new SaleMachine(5, 5, 5);
        String expectedResult = "Failure Information\n" + "Money Error";
        assertEquals(expectedResult, saleMachine6.operation("1J"));
    }
}

在测试用例类的编写中,每个测试方法遵循相同的步骤:根据测试用例输入不同的参数初始化对象,使用断言判断对象调用方法的结果与期望结果是否相同。为了避免格式问题导致不必要的测试失败,期望输出变量 expectedResult 的内容可直接在被测程序中复制。用例类的编写关键实际上在于前面测试用例的设计上,测试用例决定了每个方法使用什么参数,测试结果的判断依据等。

  • 编写功能测试用例类 SaleMachineFunctionTest.java
package test;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.Before;
import org.junit.Test;

public class SaleMachineFunctionTest {
    private SaleMachine saleMachine;

    @Before
    public void setUp() throws Exception {
        saleMachine = new SaleMachine(5, 5, 5);
    }

    @Test
    public void testOperation1() {
        saleMachine.operation("5J");
        String expectedResult = "Current State\n" + "Beer: 4\n" + "5 Jiao: 6\n" + "1 Yuan: 5";
        assertEquals(expectedResult, saleMachine.currentState());
    }

    @Test
    public void testOperation2() {
        saleMachine.operation("1Y");
        String expectedResult = "Current State\n" + "Beer: 4\n" + "5 Jiao: 4\n" + "1 Yuan: 6";
        assertEquals(expectedResult, saleMachine.currentState());
    }

    @Test
    public void testOperation3() {
        saleMachine.operation("1J");
        String expectedResult = "Current State\n" + "Beer: 5\n" + "5 Jiao: 5\n" + "1 Yuan: 5";
        assertEquals(expectedResult, saleMachine.currentState());
    }

    @Test
    public void testOperation4() {
        saleMachine.operation(null);
        String expectedResult = "Current State\n" + "Beer: 5\n" + "5 Jiao: 5\n" + "1 Yuan: 5";
        assertEquals(expectedResult, saleMachine.currentState());
    }
}

功能测试用例类的编写与路经测试用例类相似,区别在于功能测试不关注程序内部的具体执行路径,而是从用户的角度对程序功能进行测试。因此本次测试中事先将对象实例化,并将资源数量均初始化为 5。通过对比不同参数下资源数量变化和期望输出是否一致,判断功能是否正常。

测试运行和结果分析

  • 编译执行 SaleMachinePathTest.java

如图所示,在项目结构中右键点击 SaleMachinePathTest.java,选择 Run As -> JUnit Test 运行测试。

从 JUnit 执行结果可见,路径 S4 未通过测试。左键点击 testOperationS4 ,在 Failure Trace 选项中点击鼠标右键呼出菜单,选择 Compare Result 查看具体结果对比,如图所示。

可见期望输出信息是啤酒缺货,而实际输出为交易信息,因此路径 S4 存在错误。

  • 编译执行 SaleMachineFunctionTest.java

运行方法参考路径测试,测试结果如图所示。

从测试结果可见,方法 testOperation2 测试失败,方法 testOperation4 出现错误。

首先查看 testOperation2 的失败原因。

由图可见,测试失败的原因是实际资源变化和期望的资源变化不同。其中 5 角的数量在找零后应当变为 4,而实际变为了 6。

接着查看 testOperation4 的失败原因。

错误信息为 NullPointerException ,这是常见的空指针错误。

 

程序改进

首先根据路径测试的结果,对路径 S4 进行改进。根据代码注释,查看 SaleMachine.java 中路径 S4 附近相关代码,如图所示。

可看见路径 S4 要在 S3 不符合条件的情况才能执行,然而路径 S3 的判断条件为 countOfBeer >= 0 即啤酒数量为 0 时,售货机程序仍然卖出啤酒,而不是执行期望的 S4 路径,即输出 Failure Information Beer Shortage 啤酒缺货信息。因此只要将 S3 中 if(countOfBeer >= 0) 语句改为 if(countOfBeer > 0) 即可。

再次运行测试用例类 SaleMachinePathTest.java,结果如下所示,所有路径均通过测试,路径测试完成。

接着根据功能测试的结果,对程序进行进一步的改进。

根据上一节的测试结果分析,程序存在资源变化方面的错误,即找零的情况下,5 角的数量本应减 1,实际上却增 1,不符合程序的功能需求。因此要定位错误代码的位置,应当从涉及找零的部分入手。

检查代码,可发现错误位于路径 S3 附近,如下所示。

路径 S3 用于处理客户投入 1 元货币,找零 5 角的情况,此时变量 countOfFiveJiao 应当自减 1,但原程序中的语句为 countOfFiveJiao++,显然不符合实际需求,改为 countOfFiveJiao-- 即可。

最后的错误为上节提到的 NullPonterException,该错误对应的用例为参数 Money null 的情况,说明程序中缺乏对于输入为空的情况的判断,其中一种解决方法为增加对输入的判断。

如图所示,向 if (money.equalsIgnoreCase("5J")) else if (money.equalsIgnoreCase("1Y")) 这两个判断语句中添加对 money 的判断:money != null 即可。即改为 if (money != null && money.equalsIgnoreCase("5J")) else if (money != null && money.equalsIgnoreCase("1Y"))

保存以上修改,重新运行测试类,所有测试通过,如下所示。

 

 

出处:https://www.cnblogs.com/yyyyfly1/p/15958047.html

posted on 2022-03-18 16:08  jack_Meng  阅读(2768)  评论(1编辑  收藏  举报

导航