测试概览
测试是用于促进鉴定软件正确性、完整性、安全性和软件质量的过程。在开发的过程中测试是必不可少的,测试一般分为四个阶段:单元测试
,集成测试
,系统测试
,验收测试
;对于后端开发人员而言,需要会单元测试和集成测试。测试的方法一般分为三种:白盒测试
,黑盒测试
,灰盒测试
:
白盒测试
白盒测试是清楚软件的内部结构,清楚其源代码逻辑,是用于验证源代码和代码逻辑的正确性的。其中,单元测试就属于白盒测试。
黑盒测试
黑盒测试是不清楚软件的内部结构,不清楚源代码逻辑,是用于验证软件的功能、和兼容性方面问题的。其中,系统测试和验收测试都属于黑盒测试。
灰盒测试
灰盒测试顾名思义,兼顾了白盒与黑盒测试的特点,既要关注软件的内部结构,又要考虑软件的外在表现。其中,集成测试就属于灰盒测试。
单元测试
单元测试:是针对程序的最小的功能单元(方法),编写测试代码对其正确性进行测试。
JUnit测试框架进行单元测试
JUnit是目前最流行的Java测试框架之一,提供了一些功能,方便程序进行单元测试(第三方公司提供);在使用JUnit这样的测试框架之前,我们一般是通过编写测试类通过main方法对代码进行测试的,这样做从功能上而言,是可以达到一样的效果,但实际上还是存在一些问题:
- 测试代码和源代码没有区分,写在一起,难以维护。
- main方法测试多个功能,假如一个功能测试失败,程序直接停止,影响后面的功能测试。
- 无法自动化测试,得到测试报告。
这些是用main方法测试的局限,但是使用了JUnit框架进行测试,就可以弥补这些不足:JUnit的测试代码和源代码是分开的,便于维护;JUnit可以根据需要自动化测试,功能测试之间相互不会影响;JUnit可以自动分析测试结果,产出测试报告,测试更加高效。 所以说推荐使用JUnit测试框架进行单元测试。
使用JUnit测试框架
JUnit是第三方提供的测试框架,在Maven项目中,需要在pom.xml文件中引入其maven坐标:
然后在test/java目录下,创建一个测试类,然后编写对应的测试方法(一般而言,测试类都叫xxxxxTest),并且在方法(方法一般叫testXxxxx)上使用@Test注解(这是JUnit提供的注解)表明这是一个测试方法。 注意,Junit中单元测试的方法必须声明为public void,否则无法测试! 根据以上规则,举一个例子:现在有一个UserService类,其中有两个方法,一个是根据用户的身份证判断用户的年龄,一个是根据用户的身份证判断用户的性别:
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测试框架的测试类和测试方法测试这两个方法:
- 首先在test/java中创建测试类:
public class UserServiceTest { } }
- 在测试类中编写测试方法,并在方法上添加@Test注解,标识这是一个基于Junt测试框架的测试方法:
public class UserServiceTest { @Test public void testGetAge() { UserService userService = new UserService(); Integer age = userService.getAge("100000200410141011"); System.out.println(age); } }
- 然后运行测试方法,获取测试结果:点击方法旁边的箭头,即可运行测试方法:
JUnit框架可以自动获取测试结果,绿色代表测试成功:
现在可以看到,UserService中的getAge方法已经绿了,并且还标明了测试通过,这是否意味着这个方法就没有问题,代表测试通过了呢?其实并不是这样的。
断言
当一个方法像上面的例子一样被标明测试通过,只能说明该方法不存在语法问题,没有抛出异常,但是并不意味着方法本身的逻辑没有问题,比如我故意将getGender方法的逻辑修改错误:
/**
* 根据身份证号码判断用户性别
* @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中有对应的重载方法),通过断言可以增加测试的可靠性:
@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会在每一个测试方法之后执行一次(在所有测试方法之后执行一次),所以说这两个注解一般是用于资源释放或清理工作的:
// @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注解为参数化测试提供参数来源。
@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可以指定测试类、测试方法的名称(其默认名为类名和方法名),比如有些方法名字过长,那么就可以给其指定一个便于观察的名字(可以是中文):
@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位的字符串…… 这些都是比较典型的错误值,都是需要测试的;不单单需要考虑特殊值
,还需要考虑测试一些正常的男性(女性)身份证号码,看看是否能够通过测试:
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测试框架可以自动化测试,得到测试报告
,只需要在运行时选择使用覆盖率运行
,就可以自动生成测试方法的覆盖率了:
覆盖率也可以一定程度上反映测试的合理、可靠性,所以说后端开发在进行单元测试的时候,需要尽量将覆盖率提高,测试得更加完善,便于后续的集成测试和系统测试。