Java后端开发单元测试

测试概览

测试是用于促进鉴定软件正确性、完整性、安全性和软件质量的过程。在开发的过程中测试是必不可少的,测试一般分为四个阶段:单元测试集成测试系统测试验收测试;对于后端开发人员而言,需要会单元测试和集成测试。测试的方法一般分为三种:白盒测试黑盒测试灰盒测试

白盒测试

白盒测试是清楚软件的内部结构,清楚其源代码逻辑,是用于验证源代码和代码逻辑的正确性的。其中,单元测试就属于白盒测试。

黑盒测试

黑盒测试是不清楚软件的内部结构,不清楚源代码逻辑,是用于验证软件的功能、和兼容性方面问题的。其中,系统测试和验收测试都属于黑盒测试。

灰盒测试

灰盒测试顾名思义,兼顾了白盒与黑盒测试的特点,既要关注软件的内部结构,又要考虑软件的外在表现。其中,集成测试就属于灰盒测试。

单元测试

单元测试:是针对程序的最小的功能单元(方法),编写测试代码对其正确性进行测试。

JUnit测试框架进行单元测试

JUnit是目前最流行的Java测试框架之一,提供了一些功能,方便程序进行单元测试(第三方公司提供);在使用JUnit这样的测试框架之前,我们一般是通过编写测试类通过main方法对代码进行测试的,这样做从功能上而言,是可以达到一样的效果,但实际上还是存在一些问题:

  1. 测试代码和源代码没有区分,写在一起,难以维护。
  2. main方法测试多个功能,假如一个功能测试失败,程序直接停止,影响后面的功能测试。
  3. 无法自动化测试,得到测试报告。

这些是用main方法测试的局限,但是使用了JUnit框架进行测试,就可以弥补这些不足:JUnit的测试代码和源代码是分开的,便于维护;JUnit可以根据需要自动化测试,功能测试之间相互不会影响;JUnit可以自动分析测试结果,产出测试报告,测试更加高效。 所以说推荐使用JUnit测试框架进行单元测试。

使用JUnit测试框架

JUnit是第三方提供的测试框架,在Maven项目中,需要在pom.xml文件中引入其maven坐标:

然后在test/java目录下,创建一个测试类,然后编写对应的测试方法(一般而言,测试类都叫xxxxxTest),并且在方法(方法一般叫testXxxxx)上使用@Test注解(这是JUnit提供的注解)表明这是一个测试方法。 注意,Junit中单元测试的方法必须声明为public void,否则无法测试! 根据以上规则,举一个例子:现在有一个UserService类,其中有两个方法,一个是根据用户的身份证判断用户的年龄,一个是根据用户的身份证判断用户的性别:

java 复制代码
public class UserService {
    /**
     * 根据身份证号码,计算出用户的年龄
     * @param idCard 身份证号码
     * @return 用户年龄
     */
    public Integer getAge(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            throw new IllegalArgumentException("无效的身份证号码");
        }
        String birthday = idCard.substring(6, 14);
        LocalDate parse = LocalDate.parse(birthday, DateTimeFormatter.ofPattern("yyyyMMdd"));
        return Period.between(parse, LocalDate.now()).getYears();
    }

    /**
     * 根据身份证号码判断用户性别
     * @param idCard 身份证号码
     * @return 性别
     */
    public String getGender(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            throw new IllegalArgumentException("无效的身份证号码");
        }
        return Integer.parseInt(idCard.substring(16, 17)) % 2 == 1 ? "男" : "女";
    }
}

编写一个基于JUnit测试框架的测试类和测试方法测试这两个方法:

  1. 首先在test/java中创建测试类:

    java 复制代码
    public class UserServiceTest {
    
        }
    }
  2. 在测试类中编写测试方法,并在方法上添加@Test注解,标识这是一个基于Junt测试框架的测试方法:

    java 复制代码
    public class UserServiceTest {
    
        @Test
        public void testGetAge() {
            UserService userService = new UserService();
            Integer age = userService.getAge("100000200410141011");
            System.out.println(age);
        }
    }
  3. 然后运行测试方法,获取测试结果:点击方法旁边的箭头,即可运行测试方法:

JUnit框架可以自动获取测试结果,绿色代表测试成功:

现在可以看到,UserService中的getAge方法已经绿了,并且还标明了测试通过,这是否意味着这个方法就没有问题,代表测试通过了呢?其实并不是这样的。

断言

当一个方法像上面的例子一样被标明测试通过,只能说明该方法不存在语法问题,没有抛出异常,但是并不意味着方法本身的逻辑没有问题,比如我故意将getGender方法的逻辑修改错误:

java 复制代码
/**
 * 根据身份证号码判断用户性别
 * @param idCard 身份证号码
 * @return 性别
 */
public String getGender(String idCard) {
    if (idCard == null || idCard.length() != 18) {
        throw new IllegalArgumentException("无效的身份证号码");
    }
    return Integer.parseInt(idCard.substring(16, 17)) % 2 == 0 ? "男" : "女";
}

我国身份证的第17位是奇数的是男性,是偶数的是女性,此处故意将其改错,然后使用身份证号码"100000200410141011"进行测试,按理来说,这个身份证号码对应的应该是一个男性,但是当我们测试这个方法将会发现一些不对:

我们发现本来应该判断得男性的身份证号码经过我们编写的错误的getGender方法居然被判断为了女性,这说明我们的getGender方法是有问题的;但是JUnit测试居然通过了,说明只单纯依靠运行是无法正确测试的,此时就需要使用到断言来增加测试的可靠性。

JUnit框架提供了一些辅助方法,帮助我们确定被测试的方法是否按照预期的效果正常工作 ,这样的方式被叫做断言。在JUnit中实现断言,需要使用Assertions工具类中提供的静态方法,以下是一些常见的断言方法:

注:这些方法形参中的最后一个参数msg,表示错误提示信息,可以不指定。(Assertions中有对应的重载方法),通过断言可以增加测试的可靠性:

java 复制代码
@Test
public void testGetGenderWithAssert() {
    UserService userService = new UserService();
    String gender = userService.getGender("100000200410141011");
    Assertions.assertEquals("男", gender);
}

此时,根据身份证号码"100000200410141011",期望的gender是男,但是由于我们方法逻辑的错误,所以说实际得到的gender是女,assertEquals方法将报错,测试方法不通过:

根据控制台的输出,预期得到的gender是男,实际得到的gender是女,预期和实际不符所以说该方法报错,这样我们就应该去检查以下getGender方法的逻辑,修改逻辑错误后再次测试:

修改逻辑之后,预期和实际得到一样的结果,单元测试就通过了。用于断言的方法还有很多,此处就不一一演示。

JUnit常见注解

JUnit测试框架中还提供了一些注解,用于增强其功能,常见的一些注解如下图所示:

@Before...和@After...

首先先演示一下@BeforeEach和@AfterEach;@BeforeAll和@AfterAll这四个注解,由于@BeforeEach和@BeforeAll会在每一个测试方法之前执行一次(在所有测试方法之前执行一次),所以说这两个注解一般是用于资源准备或初始化工作的;同理@AfterEach和@AfterAll会在每一个测试方法之后执行一次(在所有测试方法之后执行一次),所以说这两个注解一般是用于资源释放或清理工作的:

java 复制代码
// @BeforeAll和@AfterAll只能修饰static方法
@BeforeAll
public static void beforeAll() {
    System.out.println("before all");
}

@AfterAll
public static void afterAll() {
    System.out.println("after all");
}

@BeforeEach
public void beforeEach() {
    System.out.println("before each");
}

@AfterEach
public void afterEach() {
    System.out.println("after each");
}

通过点击测试类旁边的箭头,运行整个测试类(测试类中的所有测试方法依次运行),查看这四个注解的运行结果:

和预想的一样:@BeforeAll方法是在所有的测试方法之前运行的,只会运行一次;@BeforeEach方法是在每一个测试方法运行前运行的,可以运行多次;@AfterEach方法是在每一个测试方法运行之后运行的,可以运行多次;@AfterAll方法是在所有的测试方法运行之后运行的,只会运行一次(其中有些方法是通过Assertions断言的,所以说没有输出,实际上是运行了该方法的)。

参数化测试注解@ParameterizedTest

参数化测试注解,可以让单个测试运行多次,每次测试时仅参数不同,这样就可以大大提高测试效率。参数化测试需要配合使用@ParameterizedTest注解和@ValueSource注解,@ValueSource注解为参数化测试提供参数来源。

java 复制代码
@ParameterizedTest
@ValueSource(strings = {"100000200410141011", "100000200410141031", "100000200410141051"})
public void testGetGenderWithAssertAndParameterized(String idCard) {
    UserService userService = new UserService();
    String gender = userService.getGender(idCard);
    Assertions.assertEquals("男", gender);
}

这个测试方法使用了参数化测试注解,并且在@ValueSource中提供了三个男性身份证号码(请特别注意:@ValueSource中提供的参数必须是同类的,比如全部是男或者全部是女,否则无论如何都无法通过Assertions断言!!!)

可以看到,通过一次测试,测试了三个数据,单个测试调用了多次,每一次只是参数不同,这样就极大的提高了我们的测试效率。

@DisplayName指定测试类、测试方法名

@DisplayName可以指定测试类、测试方法的名称(其默认名为类名和方法名),比如有些方法名字过长,那么就可以给其指定一个便于观察的名字(可以是中文):

java 复制代码
@ParameterizedTest
@ValueSource(strings = {"100000200410141011", "100000200410141031", "100000200410141051"})
@DisplayName("断言和参数化测试getGender方法")
public void testGetGenderWithAssertAndParameterized(String idCard) {
    UserService userService = new UserService();
    String gender = userService.getGender(idCard);
    Assertions.assertEquals("男", gender);
}

单元测试企业开发规范

原则:编写测试方法时,要尽可能覆盖业务方法中所有的情况,特别需要注意边界值和一些特殊值! 比如说我们需要用户传递一个正常的合法的18位身份证号码(String类型,先不考虑其是否存在),那么在测试的时候,我们就要多方面考虑有可能会传递的身份证号码:比如传递一个null值,传递一个空字符串,传递一个不足18位的字符串,传递一个超过18位的字符串...... 这些都是比较典型的错误值,都是需要测试的;不单单需要考虑特殊值,还需要考虑测试一些正常的男性(女性)身份证号码,看看是否能够通过测试:

java 复制代码
import com.wzb.service.UserService;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

/**
 * 用户测试类
 */
public class UserTest {
    private UserService userService;

    /**
     * 每一个测试方法运行前,都自动创建一个UserService对象
     */
    @BeforeEach
    public void setUp() {
        userService = new UserService();
    }

    @Test
    @DisplayName("获取性别:null")
    public void testGetGenderWithNull() {
        Assertions.assertThrows(IllegalArgumentException.class, new NamedExecutable() {
            @Override
            public void execute() throws Throwable {
                userService.getGender(null);
            }
        });
    }

    @Test
    @DisplayName("获取性别:空串")
    public void testGetGenderWithEmpty() {
        Assertions.assertThrows(IllegalArgumentException.class, new NamedExecutable() {
            @Override
            public void execute() throws Throwable {
                userService.getGender("");
            }
        });
    }

    @Test
    @DisplayName("获取性别,长度不足")
    public void testGetGenderWithShort() {
        Assertions.assertThrows(IllegalArgumentException.class, new NamedExecutable() {
            @Override
            public void execute() throws Throwable {
                userService.getGender("1000002004101");
            }
        });
    }

    @Test
    @DisplayName("获取性别,长度过长")
    public void testGetGenderWithLong() {
        Assertions.assertThrows(IllegalArgumentException.class, new NamedExecutable() {
            @Override
            public void execute() throws Throwable {
                userService.getGender("1000002004101410110000000");
            }
        });
    }

    @DisplayName("获取性别:男")
    @ParameterizedTest
    @ValueSource(strings = {"100000200410141011", "100000200410141031", "100000200410141051"})
    public void testGetGenderWithMan(String idCard) {
        String gender = userService.getGender(idCard);
        Assertions.assertEquals("男", gender);
    }

    @DisplayName("获取性别:女")
    @ParameterizedTest
    @ValueSource(strings = {"100000200410141021", "100000200410141041", "100000200410141061"})
    public void testGetGenderWithWoman(String idCard) {
        String gender = userService.getGender(idCard);
        Assertions.assertEquals("女", gender);
    }

    @AfterAll
    public static void end() {
        System.out.println("测试完毕");
    }
}

在这个测试类中,首先使用了@BeforeEach注解,在每一个测试方法运行之前,都会创建一个UserSerivice对象,避免重复创建对象,减少代码量;然后对于不同的特殊值(null,空,长度不同)进行测试,结合Assertions中的assertThrows方法对于这些错误值抛出的异常类型进行判断;最后结合@ParameterizedTest注解和@ValueSource注解进行参数化测试,分别对男女两种情况提供几组数据进行测试;最后在所有测试方法执行完毕后输出测试完毕进行提示。

所有的测试方法都是通过的,上文还提到,Junit测试框架可以自动化测试,得到测试报告,只需要在运行时选择使用覆盖率运行,就可以自动生成测试方法的覆盖率了:

覆盖率也可以一定程度上反映测试的合理、可靠性,所以说后端开发在进行单元测试的时候,需要尽量将覆盖率提高,测试得更加完善,便于后续的集成测试和系统测试。

相关推荐
三次拒绝王俊凯几秒前
java求职学习day11
java·开发语言·学习
Q_27437851098 分钟前
django基于Python的电影推荐系统
java·后端·python·django
ZERO空白20 分钟前
spring task使用
java·后端·spring
Bingjia_Hu34 分钟前
使用 Python 的 pyttsx3 库进行文本转语音
开发语言·python·pyttsx3
xiao--xin35 分钟前
LeetCode100之括号生成(22)--Java
java·开发语言·算法·leetcode·回溯
java1234_小锋38 分钟前
Redis是单线程还是多线程?
java·数据库·redis
sun_weitao41 分钟前
Flutter路由动画Hero函数的使用
java·服务器·flutter
雾里看山1 小时前
C语言之结构体
c语言·开发语言·笔记
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS企业级工位管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
百流1 小时前
scala基础学习(数据类型)-集合
开发语言·学习·scala