使用 JUnit 进行单元测试 - 第 2 部分

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

在使用 JUnit 进行单元测试系列的 第一部分 中,我们研究了使用 Maven 和 IntelliJ 创建单元测试。在这篇文章中,我们将研究一些核心单元测试概念,并使用 JUnit 构造来应用这些概念。我们将学习断言、JUnit 4 注释和测试套件。

JUnit 断言

断言,或简称断言,为程序员提供了一种验证代码预期行为的方法。例如,通过断言,您可以检查方法是否返回给定参数集的预期值,或者方法是否正确设置了一些实例或类变量。当您运行测试时,断言就会执行。如果被测方法的行为与您在断言中指定的完全一致,则您的测试通过。否则,将抛出 AssertionError

JUnit 通过 org.junit.Assert 类中的一组断言方法提供对断言的支持。在我们开始使用它们之前,让我们快速了解一下 Arrange、Act、Assert (AAA) 模式。这种模式是编写单元测试方法的推荐方法,您可以将方法分为三个部分,每个部分都有特定的用途:

  • 安排 :初始化对象并为被测方法设置输入数据。
  • Act : 调用被测方法传递设置好的参数。
  • 断言 :验证被测方法是否按预期运行。这是您编写断言方法的地方。

这是一个 Java 类,我们将编写一些 JUnit 单元测试来进行测试。

员工邮箱.java


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


在上面的 EmployeeEmail 类中,我们编写了一个 addEmployeeEmailId() 方法,该方法首先检查电子邮件 ID 的格式是否有效,然后将其添加到 Map 实现中。 isValidEmailId() 方法使用 正则表达式 执行电子邮件验证。我们还编写了一个 getEmployeeEmailId() 方法,在给定键的情况下从 Map 返回电子邮件 ID。

为了测试 EmployeeEmail 类,我们将创建一个测试类 EmployeeEmailTest 并向其添加测试方法。在这里,请记住要添加的测试方法的数量以及它们应该做什么取决于被测 EmployeeEmail 类的行为——而不是其中方法的数量。

首先,我们将使用两种测试方法测试 getEmployeeEmailId() 方法对于有效电子邮件 ID 是否返回 true ,对于无效电子邮件 ID 是否返回 false


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}

在上面的两种测试方法中,我们都将测试代码分成了 AAA 部分。在第一个测试方法中,我们使用了 assertTrue() 方法,因为我们期望 isValidEmailId() 为电子邮件 ID andy@testdomain.com 返回 true 。我们还想测试 isValidEmailId() 对于无效的电子邮件 ID 是否返回 false 。为此,我们编写了第二个测试方法并使用了 assertFalse()

这里有几件事要观察。在这两种断言方法中,我们都传递了一个 String 参数作为断言错误的标识消息。程序员通常会设置此消息来描述应满足的条件。相反,为了有意义,此消息应该描述不满足条件时出了什么问题。

此外,您可能会想“ 为什么要用两个单独的测试方法而不是一个方法同时使用两个断言方法? 》 在一个测试方法中有多个断言方法不会导致测试出错,你会经常遇到这样的测试方法。但是要遵循的一个好的规则是:“ 正确的单元测试应该完全出于一个原因而失败 ”,这听起来类似于 单一职责原则 。在具有多个断言的失败测试方法中,需要更多的努力来确定哪个断言失败。此外,不能保证所有断言都发生了。对于未经检查的异常,异常之后的断言不会执行,JUnit 会继续执行下一个测试方法。因此,通常最好的做法是对每个测试方法使用一个断言。

有了基础知识,让我们编写完整的测试类并使用以下断言:

  • assertEquals() assertNotEquals() :测试两个基元/对象是否相等。除了作为第一个参数传递的字符串消息之外,这些方法还接受期望值作为第二个参数和实际值作为第三个参数——这是一个经常被误用的重要顺序。
  • assertNull() assertNotNull() :测试对象是否为空。
  • assertSame() assertNotSame() :测试两个对象引用是否指向同一个对象。

EmployeeEmailTest.java


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


在上面的 EmployeeEmailTest 类中:

  • 第 38 行 :在通过 addEmployeeEmailId() 添加两个元素后,我们使用 assertEquals() 来测试集合大小。
  • 第 50 行 :我们使用 assertNotEquals() 来测试集合不允许通过 addEmployeeEmailId() 添加重复键。
  • 第 62 行 :我们使用 assertNotNull() 来测试 getEmployeeEmailId() 不会为集合中存在的电子邮件 ID 返回 null
  • 第 74 行 :我们使用 assertNull() 来测试 getEmployeeEmailId() 是否为集合中不存在的电子邮件 ID 返回 null
  • 第 89 行 :我们使用 assertSame() 来测试两个集合引用在通过 = 运算符将一个集合引用分配给另一个集合后是否指向同一个集合对象。
  • 第 103 行 :我们使用 assertNotSame() 来测试两个集合引用是否指向同一个对象。

当我们在 IntelliJ 中运行测试时,输出是:

从输出中可以看出,所有测试都按预期通过了。

注意 :不能保证 JUnit 执行测试方法的顺序,所以不要指望它。

如果您返回并查看测试类,您会注意到 Arrange 部分中的几行代码在测试方法中重复出现。理想情况下,它们应该在一个地方并在每次测试之前执行。我们可以通过使用 JUnit 注释来实现这一点,我们将在接下来进行研究。

JUnit 注解

您可以使用 JUnit 4 中引入的 JUnit Annotations 来标记和配置测试方法。我们已经使用 @Test 注释将 public void 方法标记为测试方法。当 JUnit 遇到用 @Test 注释的方法时,它会构造该类的一个新实例,然后调用该方法。我们可以选择向 @Test 提供 timeout 参数以指定以毫秒为单位的时间。如果测试方法的执行时间超过指定时间,则测试失败。当您根据时间测试性能时,这特别有用。此代码将方法标记为测试方法并将超时设置为 100 毫秒。


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


@Test 注解的另一个重要用途是测试异常。假设对于一个条件,代码抛出异常。我们可以使用 @Test 注解来测试代码是否确实在满足条件时抛出异常。此代码检查 getEmployeeEmailId() 方法在向其传递非字符串值时是否抛出 IllegalArgumentException 类型的异常。


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


除了 @Test 注解,其他的注解有:

  • @Before :使方法在类的每个测试方法之前运行。您通常使用此注释来分配资源、设置公共初始化代码以及加载测试方法所需的配置文件。
  • @After :使方法在类的每个测试方法之后运行。即使 @Before @Test 方法抛出异常,此方法也能保证运行。使用此注释清理初始化代码并释放在 @Before 中完成的任何资源分配。
  • @BeforeClass :使静态方法在类中的任何测试方法之前运行一次且仅运行一次。这在需要设置计算量大的资源(例如服务器连接、数据库,甚至管理嵌入式服务器以进行测试)的情况下很有用。例如,不是为每个 @Test 方法启动服务器,而是在 @BeforeClass 方法中为类中的所有测试启动一次。
  • @AfterClass :导致静态方法在类中的所有测试方法完成后运行一次。即使 @BeforeClass @Test 方法抛出异常,此方法也能保证运行。使用此方法释放在 @BeforeClass 中完成的一次性资源初始化。
  • @Ignore :导致 JUnit 忽略测试方法。当您有一段复杂的代码正在转换时,这会很有用,并且您可能希望暂时禁用某些测试,直到该代码准备就绪。大多数 IDE 的测试运行器将 @Ignore 测试报告为每次测试运行期间的提醒。这本质上是将测试标记为“有事情要做”,否则如果您注释掉测试方法或删除 @Test 注释,您可能会忘记。

下面是一个使用所有 JUnit 注释的示例。

EmployeeEmailAnnotationsTest.java


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


在 IntelliJ 中运行测试的输出是:

JUnit 测试套件

如果您有大量用于不同功能区域或模块的测试类,您可以将它们构建成测试套件。 JUnit 测试套件是测试类的容器,可以让您更好地控制测试类的执行顺序。JUnit 提供 org.junit.runners.Suite ,一个运行一组测试类的类。
创建测试套件的代码是:

EmployeeEmailTestSuite.java


 package guru.springframework.unittest.asserts;

import java.util.HashMap; import java.util.Map; import java.util.regex.*;

public class EmployeeEmail {

Map<String, String> hashMap = new HashMap<String, String>();

public  void addEmployeeEmailId(String key, String value){
    if(isValidEmailId(value)) {
        hashMap.put(key, value);
    }
}
public String getEmployeeEmailId(Object key){
    if (!(key instanceof String)) {
        throw new IllegalArgumentException("Object not type of String");
    }
    return hashMap.get(key);
}
public boolean isValidEmailId(String email){
    String regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
    Pattern pattern = Pattern.compile(regex);
    Matcher m = pattern.matcher(email);
    return m.matches();
}

}


在上面的测试套件类中,我们写了两个注解: @RunWith @SuiteClasses @RunWith 注释指示 JUnit 使用 Suite 运行器类, @SuiteClasses 指定 Suite 运行器类应运行的类及其顺序。测试套件类本身是空的,仅充当注释的占位符。

在 IntelliJ 中执行测试套件的输出是。

概括

JUnit 断言不仅使您的代码稳定,而且迫使您以不同的方式思考并思考不同的场景,最终帮助您成为更好的程序员。通过理解不同断言的目的并正确使用它们,测试变得有效。但问题是“ 每个测试方法有多少断言? ”。这一切都归结为被测方法的复杂性。对于具有多个条件语句的方法,应该对每个条件的结果进行断言,而对于执行简单字符串操作的方法,应该进行单个断言。当使用 JUnit 开发单元测试时,每个测试方法都测试一个特定条件被认为是最佳实践,这通常会导致每个测试方法一个断言。一个被测方法与多个测试方法关联的情况并不少见。
我没有在这篇文章中介绍的一个断言是 assertThat() 。这是一个重要的 JUnit 断言,我将 在下一篇关于 JUnit 的文章 中介绍它。

使用 Spring 框架进行单元测试

在使用 Spring 框架进行 企业应用程序开发 并对代码进行单元测试时,您将使用大量断言。除了断言常规方法行为之外,您还将断言 Spring bean 是否按照 Spring 应用程序上下文的预期注入,Spring bean 之间的依赖关系是否得到正确维护,等等。在创建这些测试时,请确保它们快速运行,尤其是当测试集成到构建周期中时。您将在编码时继续构建您的应用程序,因此您显然不希望您的构建等待长时间运行的测试完成。如果您确实有如此长时间运行的测试,请将它们放在单独的测试套件中。