如何使用JUnit和Mockito测试Java项目

264 阅读11分钟

使用JUnit和Mockito测试Java项目

单元测试是最重要的测试形式。单元测试提供了一种方法来测试作为隔离单元的各个代码组件。单元测试的关键在于程序单元的隔离。

Mockito是Java项目中最流行的嘲弄框架之一。它提供了直观的匹配器,允许程序员对依赖关系的行为进行模拟。

本教程旨在指导读者如何使用Mockito和JUnit编写有效的单元测试。

前提条件

作为前提条件,读者应该具备以下条件。

  • 关于如何使用when-then 指令来执行基本的存根的基本知识。
  • 对如何使用Java有基本的了解。
  • 对如何使用[JUnit]进行单元测试有基本的了解。
  • 在您的电脑上安装了[Java开发工具包(JDK)]。
  • 安装[IntelliJ]代码编辑器。
  • 安装了[Maven]。

目标

读完本教程后,读者应该对以下内容有充分的了解。

  • 如何使用Mockito的参数匹配器。
  • 如何用Mockito存根无效方法。
  • 如何用Mockito测试异常。
  • 如何查询模拟对象的详细信息。

被测试的系统是针对一个宿舍管理系统,它支持以下操作。

  • 学生可以在系统上注册。
  • 学生可以申请床位。
  • 通过提供宿舍的名称,找到一个宿舍里的所有学生。
  • 通过提供房间ID,找到一个房间里的所有学生。

如何使用Mockito的参数匹配器

MockitoArgumentsMatchers 允许你编写通用指令,以响应更广泛的值,而不是硬编码特定的值。

考虑下面的测试案例--testSaveMethod ,其中我们硬编码了一个Student 对象--student ,我们的save 方法被期望作为一个参数接收。

使用ArgumentMatchers ,我们可以使我们的存根指令足够灵活,以接收任何符合我们的存根方法所期望的参数。

// some parts are skipped for the sake of brevity
@Mock
StudentRepo studentRepo;

@Test
void testSaveMethod(){
  Student student = Student.builder()
  .firstName("John")
  .lastName("Doe")
  .matricNo("MAT100419")
  .password("securedPassword")
  .registrationTime(LocalDateTime.now())
  .gender(Gender.MALE).build();
  when(studentRepo.save(any(Student.class))).thenReturn(student);
  }

student 参数匹配器any 指导我们的模拟studentRepo 对象在其save 方法被调用时返回Student 类型的任何参数。

为了在你的测试类中使用ArgumentMatchers ,你必须首先从Mockito中导入'ArgumentMatchers'类,如下所示。

import static org.mockito.ArgumentMatchers.*;

许多参数匹配器可以被定义在ArgumentMatcher 类中。其中一些描述如下。

ArgumentMatchers.any()

any 参数匹配器接收一个类型为Class 的对象作为参数。它对进入存根方法的任何参数执行类型检查。

如果该参数不属于存根方法所期望的类,Mockito会触发一个编译时异常。例如,如果我们的存根方法期望接收一个Student 对象,而我们传入一个Employee 对象。

any 参数匹配器的用法演示如下。

@ExtendWith(MockitoExtension.class)// add the Mockito Extension to JUnit
@Slf4j //add Lombok's logger
class StudentRepoTest {
  @Mock
  StudentRepo studentRepo;

  @Test
  void testSaveMethod() throws Exception {
    // create a student object using Lombok's builder
    Student student = Student.builder()
      .firstName("John")
      .lastName("Doe")
      .matricNo("MAT100419")
      .password("securedPassword")
      .registrationTime(LocalDateTime.now())
      .gender(Gender.MALE).build();
    when(studentRepo.save(any(Student.class), anyString())).thenReturn(student);
    Student returnedStudent = studentRepo.save(student, student.getName());
    log.info("Returned Student → {}", returnedStudent);
    assertThat(returnedStudent, hasProperty("firstName", equalTo("John")));
    assertThat(returnedStudent, hasProperty("lastName", equalTo("Doe")));
    assertThat(returnedStudent, hasProperty("matricNo", equalTo("MAT100419")));
    assertThat(returnedStudent, hasProperty("gender", equalTo(Gender.MALE)));
  }
}

在上面的代码片断中。

  • 首先,我们创建了一个StudentRepo 的模拟对象。
  • 接下来,在我们的测试案例中,我们使用Lombok的builder 模式创建了一个Student 对象student
  • 使用when-then 指令,我们在我们的模拟对象studentRepo 上存根了save 方法,以接受任何类型的参数Student 和类型String ,在它被调用时返回student
  • 最后,我们对保存方法进行了调用,并对save 方法的返回值进行了断言。

你可以像使用any 参数匹配器一样使用isA 参数匹配器。

isA 参数匹配器的功能与any 参数匹配器相同。

isA 参数匹配器的用法在下面演示。

@ExtendWith(MockitoExtension.class)
class StudentRepoTest {
  @Mock
  StudentRepo studentRepo;
  @Test
  void testSaveMethod() throws Exception {
    Student student = Student.builder()
      .firstName("John")
      .lastName("Doe")
      .matricNo("MAT100419")
      .password("securedPassword")
      .registrationTime(LocalDateTime.now())
      .gender(Gender.MALE).build();
    when(studentRepo.save(isA(Student.class))).thenReturn(student);
    Student returnedStudent = studentRepo.save(student);
    log.info("Returned Student → {}", returnedStudent);
    assertThat(returnedStudent, hasProperty("firstName", equalTo("John")));
    assertThat(returnedStudent, hasProperty("lastName", equalTo("Doe")));
    assertThat(returnedStudent, hasProperty("matricNo", equalTo("MAT100419")));
    assertThat(returnedStudent, hasProperty("gender", equalTo(Gender.MALE)));
  }
}

ArgumentMatchers.anyString()

就像any 参数匹配器一样,anyString 参数匹配器也对进入存根方法的任何参数执行类型检查。

如果一个非字符串的参数被传递给存根方法,Mockito会触发一个编译时错误。

为了演示anyString 参数匹配器的用法,让我们假设我们之前定义的save 方法需要一个字符串参数。那么,我们的测试案例将如下。

//some parts are skipped for the sake of brevity
@Test
void testSaveMethod() throws Exception {
  Student student = Student.builder()
  .firstName("John")
  .lastName("Doe")
  .matricNo("MAT100419")
  .password("securedPassword")
  .registrationTime(LocalDateTime.now())
  .gender(Gender.MALE).build();
  when(studentRepo.save(anyString())).thenReturn(student);
  Student returnedStudent = studentRepo.save("John Doe");
  log.info("Returned Student → {}", returnedStudent);
  assertThat(returnedStudent, hasProperty("firstName", equalTo("John")));
  assertThat(returnedStudent, hasProperty("lastName", equalTo("Doe")));
  assertThat(returnedStudent, hasProperty("matricNo", equalTo("MAT100419")));
  assertThat(returnedStudent, hasProperty("gender", equalTo(Gender.MALE)));

其他一些参数匹配器可以以类似于any,isAanyString 参数匹配器的方式使用,包括。

  • anyInt() - 存根方法接受任何限定为整数的参数。
  • anyBoolean() - 存根方法接受任何符合布尔值的参数。
  • anyByte() - 存根方法接受任何有资格作为字节的参数。
  • anySet() - 存根方法接受任何有资格作为集合的参数。
  • anyIterable() - 存根方法接受任何有资格作为迭代器的参数(迭代器是任何可以返回迭代器的数据结构,如列表、集合、堆栈、队列或地图)。
  • anyCollection() - 存根方法接受任何符合集合条件的参数(集合是一种数据结构,作为相关或不相关项目的聚合而存在。队列、堆栈、地图、列表和集合也是集合)。

在使用参数匹配器时,必须注意以下两点。

  1. 你不能使用一个参数匹配器作为一个存根方法的返回值。存根方法的返回值必须始终是特定的。换句话说,你不能这样做。

when(studentRepository.save(any(Student.class))).thenReturn(any(Student.class));

试图使用参数匹配器作为存根方法的返回值会诱导存根方法返回一个null

  1. 如果你决定用参数匹配器来存根一个需要一个以上参数的方法,那么所有的参数都必须用匹配器来表示。如果你希望你的存根指令中的一个参数由一个硬编码的值来表示,那么你必须使用eq 匹配器。

为了解释这一点,让我们说我们的save 方法需要两个参数。

public Student save(Student student, String name) throws Exception {
  if (student == null) {
  throw new NullPointerException("student object cannot be null");
  }
  log.info("Student {} saved into the database", name);
  database.put(student.getId(), student);
  return student;
  }

我们不能使用参数匹配器来表示其中一个参数,这就是存根指令。

when(studentRepo.save(any(Student.class), "John Doe")).thenReturn(any(Student.class))

如果用一个参数匹配器来表示存根指令中的一个参数,那么其他所有的参数都必须用参数匹配器来表示。

想象一下,上面的save 方法需要一个Student 类的对象和一个字符串作为参数。现在,我们想指示save 方法在任何Student 对象和一个等于 "John Doe "的字符串作为参数被传递时执行一个动作。

演示使用eq 参数匹配器的测试案例将如下所示。

when(studentRepo.save(any(Student.class), eq("John Doe"))).thenReturn(any(Student.class))

现在,我们指定传入存根的save 方法的第二个参数必须等于 "John Doe"。如果我们在方法调用中传递 "John Mash "作为参数而不是 "John Doe",我们的存根方法将返回一个null 对象。

@Test
void testSaveMethod() throws HostelManagementException {
  Student student = Student.builder()
  .firstName("John")
  .lastName("Doe")
  .matricNo("MAT100419")
  .password("securedPassword")
  .registrationTime(LocalDateTime.now())
  .gender(Gender.MALE).build();
  when(studentRepo.save(any(Student.class), eq("John Doe"))).thenReturn(student);
  Student returnedStudent = studentRepo.save(student, "John Mash");
  System.out.println(returnedStudent);
  }

输出。

null

上面的代码片段打印了一个null ,因为在我们的when-then 存根指令中,我们指定当save 方法被调用到我们的模拟studentRepo ,参数是任何Student 对象和一个等于 "John Doe "的字符串。

但是,由于我们将 "John Mash "作为第二个参数传入,因此when-then 不适用。

如何用Mockito存根无效方法

您可以使用以下Mockito方法来存根无效方法。

  • doNothing - 指令是所有void方法的默认行为。 指令通常与mockito的 或mockito的 方法一起使用。doNothing doNothing ArgumentCaptor verify

与ArgumentCaptor一起使用doNothing()

让我们说,我们想把我们的save 方法存根化,该方法定义如下。

public void save(Student student, String name) throws Exception {
  if (student == null) {
  throw new NullPointerException("student object cannot be null");
  }
  log.info("Student {} saved into the database", name);
  database.put(student.getId(), student);
  }

save 方法需要两个参数 - 一个Student 对象和一个String

如果Student 对象是null ,那么就会触发一个NullPointerException 。如果Student 对象不是空的,那么String 对象会被记录下来,然后Student 对象会被保存在一个针对其id 字段的地图中。

为了给我们的save 方法写一个测试用例,首先,我们必须导入ArgumentCaptor 类,如下所示。

import org.mockito.ArgumentCaptor;

接下来,我们为我们要捕获的对象的类创建一个参数捕捉器。

@Captor
ArgumentCaptor<Student> studentArgumentCaptor;

在上面的片段中,我们为Student 类创建了一个参数捕捉器。

现在我们可以继续在我们的测试案例中使用doNothing 指令,如下。

@Test
void testDoNothingDirective() throws Exception {
    Student student = Student.builder()
                .firstName("John")
                .lastName("Doe")
                .matricNo("MAT100419")
                .password("securedPassword")
                .gender(Gender.MALE)
                .registrationTime(LocalDateTime.now())
                .build();
    doNothing().when(studentRepo).save(studentArgumentCaptor.capture(), stringArgumentCaptor.capture());
    studentRepo.save(student, student.getName());
    Student capturedStudent = studentArgumentCaptor.getValue();
    String capturedString = stringArgumentCaptor.getValue();
    assertThat(capturedStudent, hasProperty("firstName", equalTo("John")));
    assertThat(capturedStudent, hasProperty("lastName", equalTo("Doe")));
    assertThat(capturedStudent, hasProperty("matricNo", equalTo("MAT100419")));
    assertThat(capturedStudent, hasProperty("gender", equalTo(Gender.MALE)));
    assertThat(capturedString, equalTo("John Doe"));
}

读作--doNothing().when(studentRepo).save(studentArgumentCaptor.capture(), stringArgumentCaptor.capture()); 的那一行被解释为 "被模拟的studentRepositoryany 学生对象一起被调用时,什么都不做"。

使用doNothing()verify

doNothing 指令也可以与Mockito的verify 方法一起使用。

下面的代码片段演示了如何使用Mockito的verify 方法和doNothing 指令。

@Test
void testDoNothingDirectiveWithVerify() Exception {
    Student student = Student.builder()
                .firstName("John")
                .lastName("Doe")
                .matricNo("MAT100419")
                .password("securedPassword")
                .gender(Gender.MALE)
                .registrationTime(LocalDateTime.now())
                .build();
    doNothing().when(studentRepo).save(any(Student.class), anyString());
    studentRepo.save(student, student.getName());
    verify(studentRepo, times(1)).save(student, student.getName());
}

首先,我们指示模拟对象studentRepo ,当它被调用的参数与任何Student 对象和任何String 相匹配时,它不做任何事情。

Mockito的verify 方法被用来验证当实际调用save 方法时,save 方法被调用。

做抛出

每当你向你的存根方法传递某个参数时,doThrow 指令就会触发一个异常。

为了演示如何使用doThrow 指令,让我们考虑下面这个方法接收一个student 对象和一个string 对象作为参数。

如果student 对象为空,save 方法会抛出一个NullPointerException

public void save(Student student, String name) throws Exception {
  if (student == null) {
    throw new NullEntityException("student object cannot be null");
  }
  log.info("Student {} saved into the database", name);
  database.put(student.getId(), student);
}
thenThrow

测试你的应用程序在遇到特定错误条件时抛出异常是至关重要的。Mockito有thenThrow()doThrow() 指令,允许我们指导我们的模拟对象在满足特定条件时抛出异常。

为了演示theThrow 指令,让我们编写thenThrow 版本的测试案例,其中我们使用了doThrow 指令。

下面的片段演示了thenThrowwhen 指令的用法。

@Test
void testDoThrow() throws Exception {
  doThrow(NullPointerException.class).when(studentRepo).save(any(), anyString());
  assertThrows(NullPointerException.class, ()->studentRepo.save(null, "John Doe"));
}
doAnswer

当你想在与模拟对象的交互点上返回一个值时,你会使用doAnswer stubbing指令。下面演示了doAnswer 指令的用法。

Student student = Student.builder()
               .firstName("John")
               .lastName("Doe")
               .matricNo("MAT100419")
               .password("securedPassword")
               .registrationTime(getTime())
               .gender(Gender.MALE).build();

doAnswer(answer -> {
                    Student arg = answer.getArgument(0);
                    assertThat(arg, equalTo(student));
                    return null;
}).when(studentRepository).save(student);
studentRepository.save(student);

我们可以通过实现Answer接口来覆盖任何方法的答案,Answer接口有一个方法getArgument ,可以让我们访问调用时传递给存根方法的参数。

在我们上面的测试案例中,我们知道我们的参数是一个Student 对象。

由于我们知道在调用时只有一个参数被传入我们的save 方法,我们使用answer.getArgument(0) 方法检索参数的值(0 指的是第一个参数,1 指的是第二个参数,以此类推)。我们断言检索的参数的值等于测试案例中早先定义的student 的值。

如何查询模拟对象的详细信息

Mockito的静态方法mockingDetails 是用来确定一个模拟对象是一个模拟还是一个间谍。

了解我们测试中使用的模拟对象的细节是很重要的,这样你就知道如何很好地使用它们。这种技能在小组或公司项目中很有用。

比方说,我们定义一个模拟对象和一个间谍对象如下。

@Mock
StudentRepoMock studentRepoMock
@Spy
StudentRepoSpy studentRepoSpy
//some parts are skipped for brevity sake

@Test
void demonstrateMockingDetails() {
  System.out.println("Is a mock object?- " + mockingDetails(studentRepository).isMock());
  System.out.println("Is a spy object?- " +mockingDetails(hostelRepository).isSpy());
}

输出。

Is a mock object?- true
Is a spy object?- true

结论

在本教程中,我们已经演示了以下内容。

  • 如何使用Mockito的参数匹配器。
  • 如何使用Mockito来测试错误条件。
  • 如何使用Mockito测试无效方法。
  • 如何确定模拟对象的细节。