java项目如何写单元测试

概述

本人以前在Java项目开发中有一大痛点就是写单元测试,因为部署上线时,在 CI/CD 流水线中在对代码行覆盖率有强卡点,代码行覆盖率必须达到90%才能继续推进部署。回想一下以前排斥写单元测试的主要原因有如下几点:

1、心理上排斥写单元测试,觉得很繁琐,为了代码行覆盖率去写测试

2、写单元测试没有比较好的实践经验,遇到不好覆盖的代码,不知道如何处理,也不知道如何重构代码

如何克服这两个问题?第一个是心理问题,需要真正认识到写单元测试的好处,这样才能够接受写单元测试是开发过程中的必要步骤。第二个是方法问题,需要学习写单元测试的方法和最佳实践,这样在写单元测试时才能知道如何下手。

本文主要从理论和实践两个方面介绍一下如何在 Java 项目中写单元测试,在理论篇中重点说明写单元测试的好处与规范,在实践篇中介绍一下写单元测试的一些实践方法,重点介绍在单元测试和集成测试中如何使用 Mock 对象。

通过本文的学习,你将了解到写单元测试的方法论和最佳实践,也许能克服前面提到的两个问题,也就不会排斥写单元测试了,并且能够写出更高效、更可靠的单元测试和集成测试,进而提高整体的软件质量。

理论篇

单元测试和集成测试

在Java项目中,单元测试和集成测试是软件测试方法中的两个重要组成部分,它们在测试的范围、目的和实现方法上有所区别:

单元测试 (Unit Testing)

  • 范围:单元测试通常关注在最小的代码单元级别上的测试,通常是类和方法。目的是验证单个组件的功能是否按预期工作。
  • 隔离性:在单元测试中,被测试的代码通常与其依赖项隔离,依赖项可能被模拟(Mocking)或桩(Stubbing)以确保测试的独立性。
  • 快速执行:单元测试应该快速执行,这使它们适合频繁运行,例如在持续集成环境中。
  • 工具:JUnit、TestNG、Mockito、EasyMock和PowerMock是常用的单元测试工具。
  • 代码覆盖率:单元测试通常用来提高代码覆盖率,确保每一行代码都被测试到。
  • 示例:测试一个方法是否返回正确的计算结果,或者模拟一个数据库接口来测试数据访问逻辑。

集成测试 (Integration Testing)

  • 范围:集成测试关注多个组件或系统的协同工作,它验证了组件间的接口和相互作用是否正确。
  • 系统性:集成测试通常在更接近生产环境的设置中执行,可能包括数据库、网络服务和其他应用程序接口。
  • 执行速度:集成测试通常比单元测试慢,因为它们涉及到更多的系统组件和配置。
  • 工具:Spring Test、TestContainers、JUnit、RestAssured和Selenium可以用于集成测试。
  • 测试真实性:集成测试更贴近用户的实际使用场景,可以捕捉到单元测试可能忽略的问题。
  • 示例:测试Web服务的REST API与数据库的交互,或者测试不同模块/服务之间的数据交换。

总的来说,单元测试和集成测试在测试策略中扮演着互补的角色。单元测试通过快速、频繁地验证小块功能来保证代码质量,而集成测试通过在更复杂的环境中验证组件协同工作的情形来确保整个系统的稳定性和可靠性。在现代软件开发实践中,单元测试和集成测试常常被结合起来使用,以实现更全面的测试覆盖。

编写测试的好处

单元测试和集成测试是软件开发过程中保证整个系统稳定性和可靠性的关键实践,它们带来了许多好处:

单元测试的好处

  1. 提高代码质量:单元测试有助于发现代码中的错误并使其更加健壯,这样可以降低生产环境中出现问题的风险。
  2. 促进设计:编写可测试的代码通常需要良好的设计。单元测试鼓励开发者遵循SOLID原则,比如单一责任原则和依赖倒置原则。
  3. 文档作用:单元测试可以作为代码的活文档,说明代码应如何被使用以及预期的行为。
  4. 简化重构:具有良好单元测试覆盖的代码库可以更加自信地重构,因为测试可以快速发现由于改动引入的任何问题。
  5. 提早发现错误:单元测试有助于在开发过程的早期发现问题,这时修复错误的成本比在生产环境中要低得多。
  6. 自动化测试:单元测试可以被自动化运行,它们是持续集成/持续部署(CI/CD)流程的重要组成部分。
  7. 减少调试时间:当出现问题时,单元测试可以帮助快速定位错误。

集成测试的好处

  1. 验证组件交互:通过集成测试可以确保不同系统组件(如数据库、网络层、API等)能够正确地协同工作。
  2. 发现接口问题:它可以捕捉到单元测试可能遗漏的接口不匹配、数据格式错误或通信问题。
  3. 检测系统级问题:集成测试帮助识别配置错误、环境问题、服务依赖问题等系统级别的问题。
  4. 真实的使用场景:集成测试更接近用户的实际使用场景,有助于保证用户体验的质量。
  5. 减少手动测试:自动化的集成测试可以减少对手动测试的依赖,节约时间和成本。
  6. 提高信心:通过在接近生产的环境中运行集成测试,团队可以对代码发布到生产环境的可靠性更有信心。
  7. 端到端流程验证:集成测试有助于验证应用程序的端到端工作流程和业务逻辑。

总体而言,单元测试和集成测试提供了一个强大的安全网,可以在整个软件开发生命周期中确保软件质量。它们有助于开发团队及时发现和解决问题,提高开发效率,减少后期维护的负担,并最终提供更稳定、更可靠的软件产品。

编写测试的最佳实践

编写单元测试和集成测试时遵循一些最佳实践可以提高测试的可维护性、有效性和效率。以下是一些最佳实践和建议:

单元测试最佳实践

  1. 遵循FIRST原则:测试应该是Fast(快速的)、Independent(独立的)、Repeatable(可重复的)、Self-validating(自我验证的)和Timely(及时的)。
  2. 测试单一功能:每个测试应该集中验证单一功能点或行为。
  3. 模拟依赖:使用模拟(Mocking)和桩(Stubbing)技术来隔离被测试的组件,确保测试的独立性和确定性。
  4. 描述性的测试名称:给测试方法起一个描述性的名称,说明它们验证的行为。
  5. 避免测试私有方法:专注于测试公共接口。私有方法的行为应该通过公共方法的测试来验证。
  6. 不要过度模拟:只模拟外部依赖和无法控制的部分,否则可能会隐藏真实的集成问题。
  7. 测试覆盖重要路径:确保测试覆盖代码的所有重要执行路径,包括边界条件和异常情况。
  8. 保持测试简单:测试代码应该简单直接,避免复杂的逻辑和控制流。

集成测试最佳实践

  1. 选择适当的粒度:集成测试不需要覆盖每个组件间的所有交互,而是应该集中在关键的集成点。
  2. 使用真实环境:尽可能地使用与生产环境类似的配置和依赖,包括数据库、网络和服务。
  3. 准备测试数据:为集成测试准备适当的测试数据,并确保它们在测试开始前正确地设置,并在测试结束后进行清理。
  4. 避免跨服务边界:在需要时模拟外部服务或使用契约测试,以避免由于外部服务不稳定导致的测试失败。
  5. 并行化和分离:尽可能并行化测试以提高执行速度,并将测试分离到不同的模块或管道阶段中。
  6. 测试失败时的诊断信息:确保测试失败时提供足够的诊断信息,方便快速定位问题。
  7. 定期维护和更新:随着系统演变,集成测试也应该进行定期的维护和更新。
  8. 遵循CI/CD的实践:集成测试应该集成到持续集成/持续部署流程中,确保代码变更后自动执行。

通用建议

  1. 编写可测试的代码:良好的设计通常会导致更容易测试的代码,考虑可测试性可以帮助优化设计。
  2. 定期运行测试:自动化测试应该频繁运行,最好是每次代码提交时都执行。
  3. 使用专业的测试工具:利用专业的单元测试(如JUnit、TestNG)和集成测试工具(如Spring Test、TestContainers)。
  4. 测试和代码一起演变:随着产品需求和代码的变化,测试也应该相应地更新和维护。
  5. 优先考虑测试的可读性:清晰的测试代码有助于其他开发者理解测试的意图,并在需要时进行修改。
  6. 复用和抽象测试代码:测试中的常用模式可以抽象成工具方法或测试辅助类,以便复用。

以下是写出容易被单元测试代码的建议:

  1. 遵循单一职责原则(SRP)。尽量将类或方法设计成只做一件事,这样可以减少对其他类或方法的依赖,方便进行单元测试。
  2. 采用依赖注入(DI)。使用依赖注入框架或手动注入依赖,可以使被测试类的依赖更容易模拟和替换,从而方便单元测试。
  3. 避免静态方法和全局变量。静态方法和全局变量会增加代码的耦合性,并且在单元测试中难以控制和替换,应该尽量避免使用。
  4. 编写可测试的代码。在编写代码时,要考虑测试的可行性和可扩展性。例如,尽量避免使用随机数、不可控制的时间戳等。
  5. 使用断言(Assertion)。在单元测试中,使用断言来验证代码的正确性,可以大大提高测试的可靠性和可维护性。
  6. 使用Mock对象。在单元测试中,使用Mock对象可以模拟依赖的行为,更加方便和高效地进行测试。
  7. 编写可读性高的代码。编写可读性高的代码可以方便其他人或自己进行单元测试和维护。

总之,编写容易被单元测试的代码需要注重代码的可测试性、可读性和可扩展性,遵循良好的设计原则和编码规范,使用合适的工具和技术。遵循这些最佳实践和建议可以帮助你编写出更高效、更可靠的单元测试和集成测试,进而提高整体的软件质量。

实践篇

静态方法如何写单测

在Mockito的早期版本中,模拟静态方法是不支持的,但从Mockito 3.4.0开始,通过使用mockito-inline模块,可以对静态方法进行模拟。假设有一个静态方法需要被模拟,下面是如何进行操作的示例:

首先,确保你在项目中添加了正确的Mockito依赖。如果你使用Maven,你需要添加mockito-core和mockito-inline依赖:

xml 复制代码
<dependencies>
    <!-- 其他依赖 -->

    <!-- Mockito的核心库 -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>3.4.0</version> <!-- 或者更高版本 -->
        <scope>test</scope>
    </dependency>

    <!-- 支持模拟静态方法的库 -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-inline</artifactId>
        <version>3.4.0</version> <!-- 或者更高版本 -->
        <scope>test</scope>
    </dependency>
</dependencies>

然后,使用Mockito的try资源块来创建一个模拟的静态方法调用。下面是一个如何对静态方法进行模拟的例子:

java 复制代码
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;

class SomeClass {
    public static String staticMethod() {
        return "实际的静态方法调用";
    }
}

public class SomeClassTest {

    @Test
    public void testStaticMethodMocking() {
        // 开始模拟静态方法
        try (MockedStatic<SomeClass> mockedStatic = Mockito.mockStatic(SomeClass.class)) {
            // 指定静态方法的期望行为
            mockedStatic.when(SomeClass::staticMethod).thenReturn("模拟的静态方法调用");

            // 调用静态方法并验证模拟行为是否生效
            String result = SomeClass.staticMethod();
            assertEquals("模拟的静态方法调用", result);

            // 验证静态方法是否被调用
            mockedStatic.verify(SomeClass::staticMethod);
        }
        // 在资源块结束后,静态方法的模拟将会自动失效
    }
}

在这个例子中,我们使用Mockito的mockStatic方法来模拟SomeClass类的静态方法staticMethod。在try资源块中,我们设置了期望行为,然后调用了静态方法并验证了结果。当try资源块结束后,静态方法的模拟自动失效,恢复原始行为。

请注意,模拟静态方法时,应该只在必要时使用,因为这可能会隐藏代码中的设计问题。尽量通过重构来避免对静态方法的依赖,使代码更容易测试。

抽象方法如何写单测

在Mockito中,对抽象方法进行单元测试通常涉及创建一个抽象类的具体子类或模拟实例。以下是如何使用Mockito对抽象方法进行单元测试的基本步骤:

1. 直接创建匿名类

如果你的抽象类只有少数几个方法需要被模拟,你可以创建一个匿名子类,并在其中实现这些方法:

java 复制代码
@Test
public void testAbstractMethod() {
    // 创建抽象类的匿名实现
    AbstractClass testInstance = new AbstractClass() {
        @Override
        public String abstractMethod() {
            return "mocked response";
        }
    };

    // 使用testInstance进行测试
    assertEquals("mocked response", testInstance.abstractMethod());
}

在这个简单的例子中,我们重写了abstractMethod并返回了一个已经模拟的响应字符串。

2. 使用Mockito模拟

Mockito允许你直接模拟抽象类的具体实例,并为其抽象方法指定行为,你可以使用mock()方法创建一个模拟并使用when()来指定期望的行为。

java 复制代码
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.junit.Assert.assertEquals;
import org.junit.Test;

public abstract class AbstractClass {
    public abstract String abstractMethod();
}

public class AbstractClassTest {

    @Test
    public void testAbstractMethodWithMockito() {
        // 使用Mockito创建AbstractClass的模拟实例
        AbstractClass mockAbstractClass = mock(AbstractClass.class);

        // 配置模拟行为:当调用abstractMethod时返回"mocked response"
        when(mockAbstractClass.abstractMethod()).thenReturn("mocked response");

        // 测试模拟的方法
        assertEquals("mocked response", mockAbstractClass.abstractMethod());
    }
}

这种方式不需要实际创建一个子类实例。Mockito允许你模拟抽象方法,并定义方法被调用时的行为。

3. 使用Mockito的spy

如果你想要对抽象类的实例进行部分模拟(模拟一些方法,而其他方法则保持原有行为),你可以使用Mockito的spy方法。但是,这通常需要创建抽象类的一个具体子类实例。

java 复制代码
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.junit.Assert.assertEquals;
import org.junit.Test;

public abstract class AbstractClass {
    public abstract String abstractMethod();
    public String concreteMethod() {
        return "concrete response";
    }
}

public class AbstractClassTest {

    @Test
    public void testAbstractMethodWithSpy() {
        // 创建AbstractClass的匿名实现,并创建一个spy
        AbstractClass spyAbstractClass = spy(new AbstractClass() {
            @Override
            public String abstractMethod() {
                return "actual implementation";
            }
        });

        // 修改spy,使得abstractMethod返回"mocked response"
        doReturn("mocked response").when(spyAbstractClass).abstractMethod();

        // 测试模拟的方法
        assertEquals("mocked response", spyAbstractClass.abstractMethod());
        // 测试未被模拟的具体方法
        assertEquals("concrete response", spyAbstractClass.concreteMethod());
    }
}

在这个例子中,我们创建了AbstractClass的一个匿名实现,并对它进行了部分模拟(spy)。然后我们修改了abstractMethod的行为,而concreteMethod保持原有实现。

以上三种方法中,使用Mockito模拟抽象类是最常见和通用的方法,它不需要额外的类定义,也不需要实际实现抽象方法。但在某些情况下,如果你需要测试抽象类的方法实现,创建一个匿名子类或使用spy方法可能是更合适的选择。

异常如何写单测

在单元测试中测试异常通常涉及到两个方面:

  • 确保代码在特定条件下抛出预期的异常
  • 验证异常处理逻辑是否正确

以下是在Java中测试异常的几种方法:

方法1:try catch手动构造异常并捕获

java 复制代码
@Test
public void testDivide() {
    try {
        // 构造输入,并调用被测方法
        var output = testFunction(input);
        fail("no exception");
    } catch (Exception e) {
        assertTrue(expectedException);
        assertTrue(e.getMessage().contains("some message in exception"));
    }
}
@Test
public void testDivide() {
    try {
        int i = 1/0;
        fail("Expected an ArithmeticException to be thrown");
    } catch (ArithmeticException ae) {
        assertTrue(true);
    }
}

由于构造的单测目的就是为了测试抛出异常的正确性,所以没有抛出异常需要认为测试不通过,标识为fail。

方法2:使用@Test的expected属性捕获异常

JUnit 4 中你可以使用@Test注解的 expected 属性来指定预期抛出的异常类型。

java 复制代码
import org.junit.Test;

public class ExceptionTest {
    
    @Test(expected = IllegalArgumentException.class)
    public void whenExceptionThrown_thenExpectationSatisfied() {
        MyClass myClass = new MyClass();
        myClass.methodThatShouldThrowException();
    }
}

使用@Test注解的expected属性来指定期望抛出的异常类型为 IllegalArgumentException。当测试的方法抛出IllegalArgumentException异常时,测试将会通过。如果没有抛出异常或抛出了不同类型的异常,测试将会失败。方法2的不足是无法判断异常中e.getMessage()的具体信息内容。

当你使用Mockito框架进行模拟测试时,也可以轻松地测试抛出异常的情况,配置mock对象抛出异常:

java 复制代码
import org.junit.Test;
import org.mockito.Mockito;

public class ExceptionTest {

    @Test(expected = IOException.class)
    public void whenConfiguredMockException_thenThrow() throws IOException {
        MyCollaborator collaborator = Mockito.mock(MyCollaborator.class);
        Mockito.when(collaborator.doSomething()).thenThrow(new IOException());

        collaborator.doSomething(); // 这将抛出IOException异常
    }
}

在这个例子中,MyCollaborator是一个被模拟的协作类,我们配置了它的doSomething方法在调用时抛出IOException。然后我们尝试调用这个方法,并验证了是否抛出了异常。

方法3:使用 @Rule 和 ExpectedException 类捕获异常

java 复制代码
public class ExceptionTest {
    @Rule
    public ExpectedException exception = ExpectedException.none();

    @Test
    public void testDivide() {
        exception.expect(ArithmeticException.class);
        exception.expectMessage("cannot divide 0");
        int i = 1/0;
    }
}

声明了一个ExpectedException对象exception,并使用@Rule注解将它声明为测试规则。在测试方法中,利用exception.expect方法指定期望抛出的异常类型,利用exception.expectMessage方法指定期望抛出异常中包含的信息。

方法4: 使用assertThrows方法

JUnit 5提供了更灵活的异常测试机制,assertThrows 是JUnit 5中用于捕获和验证异常的方法。它可以验证异常的类型,并允许对异常对象进行进一步的断言。

java 复制代码
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class ExceptionTest {

    @Test
    public void whenDerivedExceptionThrown_thenAssertionSucceeds() {
        MyClass myClass = new MyClass();
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            myClass.methodThatShouldThrowException();
        });

        // 可选的额外断言,比如检查异常消息
        assertEquals("Expected message", exception.getMessage());
    }
}

assertThrows方法会返回捕获到的异常,这样你就可以对异常对象进行更详细的断言。

通过这些方法,你可以确保你的单元测试可以有效地验证异常情况,无论是确保方法在给定的条件下抛出正确的异常,还是验证异常处理逻辑的正确性,良好的异常测试覆盖可以显著提高代码的健壮性和质量。

使用Mock对象

在软件测试中为什么需要 mock 对象?mock 对象是用来模拟真实对象行为的假对象,mock 对象可以帮助我们在单元测试和集成测试中隔离测试的组件,确保测试的准确性和独立性。在不同类型的测试中使用 mock 对象的原理相似,但具体应用可能会有所不同。

以下是使用 mock 对象的一些具体原因:

  1. 隔离测试:Mock 对象可以帮助将被测试的单元与其依赖项隔离开来,这样可以确保单元测试只关注于被测试单元的功能,而不受外部系统变化或不稳定性的影响。
  2. 控制测试环境:使用 mock 对象可以让测试者完全控制测试环境。这意味着可以精确地模拟特定的条件和情况,例如异常情况、边界情况或罕见事件。
  3. 减少测试成本:与真实的依赖项(如数据库、网络服务等)交互可能需要额外的资源和设置。Mock 对象可以避免这些成本,因为它们在内存中运行并可以快速配置。
  4. 提高测试速度:访问实际的外部资源、服务或数据库通常需要显著的时间。Mock 对象通常在内存中执行,不需要网络调用或磁盘 I/O,这可以显著提高测试的执行速度。
  5. 简化测试:有些外部系统可能极其复杂,要在测试中设置和管理它们可能非常困难。Mock 对象允许模拟这些复杂系统的行为,而无需实际与它们交互。
  6. 可预测的行为:真实系统可能会因为多种原因而表现出不稳定的行为,而 mock 对象可以提供一致的、可预测的响应,帮助编写稳定的测试。
  7. 测试无法访问的代码路径:有些代码路径可能很难通过真实的输入和依赖来测试,例如错误处理代码或特定的异常情况。Mock 对象可以轻松地模拟这些情况,确保这些代码路径得到有效的测试。
  8. 并发测试:在多线程环境中,真实的依赖可能会因为竞争条件而导致不确定的结果。Mock 对象可以用来创建一个更可控的环境来测试并发代码。
  9. 避免对外部系统的影响:直接在生产级的服务或数据库上进行测试可能会导致数据污染或其他问题。Mock 对象消除了这种风险,因为它们与真实的系统完全隔离。
  10. 法律或安全限制:某些情况下,对真实的数据或系统进行测试可能受到法律或安全限制。Mock 对象可以模拟敏感数据,而不会有泄露真实数据的风险。

使用 mock 对象是一个在软件开发过程中广泛采用的最佳实践,尤其是当采用测试驱动开发(TDD)或行为驱动开发(BDD)方法时。然而,mock 对象应该谨慎使用,因为它们可能隐藏真实环境中的问题,因此在完成单元测试和集成测试后,仍然需要在真实环境中进行系统测试和验收测试。

接下来我将介绍一下在单元测试和集成测试中如何使用 mock 对象。

单元测试中使用 Mock 对象

单元测试通常聚焦于测试系统中的一个单一组件,如一个类或者方法。在这个层面上,你可能会使用 mock 对象来模拟该组件依赖的其他组件的行为。这样做可以确保你的测试仅关注于当前组件的行为,并且不会受到外部依赖的影响。在单元测试中使用 mock 对象的步骤如下:

  1. 识别依赖:首先明确当前测试单元依赖了哪些外部组件或服务。
  2. 创建 Mocks:使用 mock 框架(如 Mockito、Moq、JMock 等)创建这些依赖的 mock 版本。
  3. 配置 Mock 行为:设定 mock 对象的预期行为,包括确定当调用特定方法时它们应该返回的值或者抛出的异常。
  4. 注入 Mocks:将 mock 对象注入到测试单元中,替代真实的依赖。
  5. 执行测试:运行你的测试,此时测试单元将与 mock 对象交互而非真实的依赖。
  6. 验证 Mocks:最后,验证 mock 对象是否如预期那样被调用了(例如,检查是否调用了特定的方法或方法被调用的次数)。

Mockito的基本使用

Mockito是一个流行的Java单元测试框架,它允许你创建和配置模拟对象,用于隔离需要测试的代码。以下是一些基本的Mockito使用方法。

首先,确保添加Mockito依赖到你的项目中。如果你使用Maven,可以在pom.xml中添加如下依赖:

xml 复制代码
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.11.2</version>
    <scope>test</scope>
</dependency>

以下是一些基本的Mockito使用方法:

1. 创建模拟对象

在使用Mockito创建模拟对象时,有几种常用的方法:

1、使用mock()方法直接创建

java 复制代码
// 创建一个模拟的List对象
List mockedList = mock(List.class);

2、使用注解@Mock

在测试类中,你可以声明一个带有@Mock注解的字段。为了初始化这些注解,你需要在测试初始化时调用MockitoAnnotations.initMocks(this),或者使用MockitoJUnitRunner运行测试类。

java 复制代码
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.junit.Before;
import org.junit.Test;

public class ExampleTest {

    @Mock
    private List mockedList;

    @Before
    public void initMocks() {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void testMethod() {
        // 使用模拟的mockedList对象进行测试
    }
}

如果你使用 JUnit 4 ,可以用@RunWith(MockitoJUnitRunner.class)代替MockitoAnnotations.initMocks(this)。

java 复制代码
import static org.mockito.Mockito.*;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.junit.runner.RunWith;
import org.junit.Test;

@RunWith(MockitoJUnitRunner.class)
public class ExampleTest {

    @Mock
    private MyCollaborator collaborator;

    @Test
    public void testMethod() {
        // 配置模拟对象
        when(collaborator.someMethod()).thenReturn("expected response");

        // 调用被测试的方法
        MyClass myClass = new MyClass(collaborator);
        myClass.performAction();

        // 验证方法是否被调用
        verify(collaborator).someMethod();
    }
}

在这个例子中,我们不需要显式调用MockitoAnnotations.initMocks(this),因为MockitoJUnitRunner会帮我们完成模拟对象的初始化。@RunWith(MockitoJUnitRunner.class) 注解告诉JUnit使用Mockito提供的测试运行器MockitoJUnitRunner来运行测试。这个运行器提供了一些有用的功能,可以简化Mockito在测试中的使用,并确保更好的测试实践。

以下是使用MockitoJUnitRunner的一些好处:

  1. 自动初始化模拟对象 :在测试类中使用@Mock注解创建模拟对象时,不需要显式调用MockitoAnnotations.initMocks(this)方法来初始化这些模拟对象。MockitoJUnitRunner会负责在每个测试方法执行前自动初始化所有@Mock注解的字段。
  2. 检查未使用的存根:运行器会在测试结束后检查所有的存根(stub)是否被使用过。如果有任何未使用的存根,它会告知你,这通常是一个坏味道,因为你可能创建了一些不必要的测试设置。
  3. 简化测试代码 :使用MockitoJUnitRunner可以减少编写初始化代码的需要,让测试代码更加简洁。
  4. 验证框架的正确使用 :运行器会对一些Mockito的错误使用进行检查,比如当一个不合法的参数传递给when()方法时,它可能会抛出有用的错误信息。

如果你正在使用JUnit 5 ,那么你不需要 @RunWith 注解,因为 JUnit 5 有一个内建的扩展模型。在JUnit 5中,你可以使用@ExtendWith(MockitoExtension.class)注解来达到类似的效果。

java 复制代码
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;

@ExtendWith(MockitoExtension.class)
public class ExampleTest {

    @Mock
    private List mockedList;

    @Test
    public void testMethod() {
        // 使用模拟的mockedList对象进行测试
    }
}

2. 指定模拟对象的行为

java 复制代码
// 配置模拟对象返回特定的值
when(mockObject.myMethod("some input")).thenReturn("expected output");

3. 验证测试结果

验证方法调用:

java 复制代码
// 验证myMethod是否被调用了一次
verify(mockObject).myMethod("some input");

// 验证myMethod是否从未被调用
verify(mockObject, never()).myMethod("some input");

// 验证myMethod是否至少被调用了一次
verify(mockObject, atLeastOnce()).myMethod("some input");

// 验证myMethod是否被调用了指定次数
verify(mockObject, times(2)).myMethod("some input");

验证返回值:

java 复制代码
// then
Assert.assertNotNull(result);
Assert.assertEquals(result.getResponseCode(), 200);

// 其他常用的断言函数
Assert.assertTrue(...); 
Assert.assertFalse(...);
Assert.assertSame(...);   
Assert.assertEquals(...);   
Assert.assertArrayEquals(...);

4. 模拟抛出异常

java 复制代码
// 配置模拟对象在调用myMethod时抛出异常
when(mockObject.myMethod("some input")).thenThrow(new RuntimeException());

5. 参数匹配

Mockito提供了参数匹配器,允许灵活地指定输入参数,这些匹配器可以是any(), eq(), anyInt()等。

java 复制代码
// 使用anyString()匹配器来匹配任何String类型的输入
when(mockObject.myMethod(anyString())).thenReturn("expected output");

// 使用eq()匹配器来匹配特定的值
when(mockObject.myMethod(eq("specific input"))).thenReturn("expected output");

6. 模拟void方法

对于没有返回值的方法(void方法),你可以使用doNothing()、doThrow()、doAnswer()来进行模拟。

java 复制代码
// 配置void方法什么都不做
doNothing().when(mockObject).myVoidMethod("some input");

// 配置void方法抛出异常
doThrow(new RuntimeException()).when(mockObject).myVoidMethod("some input");

7. 连续调用

thenReturn()方法和thenThrow()方法可以链式调用,以设置连续调用的行为。

java 复制代码
// 第一次调用返回值"first call",第二次调用返回值"second call"
when(mockObject.myMethod("input")).thenReturn("first call").thenReturn("second call");

8. 模拟真实调用(部分模拟)

有时,你可能想调用真实的方法实现,而不是返回模拟的结果。这可以通过spy()来实现。使用spy时,除非你显式指定了模拟的行为,否则调用对象的方法都会执行真实的逻辑。

java 复制代码
// 创建一个"间谍"对象
MyClass spyObject = spy(new MyClass());

// 配置间谍对象的特定方法调用真实方法
doCallRealMethod().when(spyObject).myMethod("some input");

集成测试中使用 Mock 对象

集成测试通常涉及到多个组件的相互作用,目的是验证它们能够协同工作。在这个层面上,mock 对象可以用来模拟外部系统或服务,例如数据库、网络服务或消息队列。这样做可以提供一个可控的环境来测试组件的集成。在集成测试中使用 mock 对象的步骤如下:

  1. 定义集成点:确认哪些外部系统或服务需要被集成,并且可能需要模拟。
  2. 创建 Mocks 或 Stubs:有时在集成测试中,可能会更多地使用 stubs(提供固定响应的简单实现),而不是 mocks。使用合适的工具创建这些外部依赖的 mock 或 stub。
  3. 配置 Mock 行为或 Stubs:设置 mock 或 stub 对象的预期行为,以模拟外部系统的响应。
  4. 集成 Mocks 或 Stubs:将 mock 或 stub 对象集成到你的测试环境中,以替代实际的外部依赖。
  5. 执行集成测试:运行集成测试,确保组件能够与 mock 或 stub 对象合理交互。
  6. 验证结果:检查系统的最终状态或返回值,确保它们符合预期。

在使用 mock 对象时,重要的是要理解它们并不是替代完整的集成测试或系统测试,而是作为测试策略中的一部分。Mock 对象能够帮助我们在不受外部环境影响的情况下测试代码,但它们不能完全模拟真实世界的复杂性。因此,在测试周期的后期,还需要执行含有真实依赖的测试,以确保系统在真实环境下的表现。

Spring Boot中的Mock对象

Spring Boot 包含一个 @MockBean 注解,可用于在 ApplicationContext 中为 bean 定义 Mockito 模拟 ,可以使用注解来添加新 bean 或替换单个现有 bean ,@MockBean可以直接用于测试类、测试中的字段或 @Configuration 类和字段。

在Spring Boot的测试中,当你使用@SpringBootTest注解时,它会加载完整的应用程序上下文并自动启用 Mock 的功能,如果在你的测试类中没有使用 @SpringBootTest ,则必须手动添加 @TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class }) 示例如下:

java 复制代码
import org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener;
import org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;

@ContextConfiguration(classes = MyConfig.class)
@TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class })
class MyTests {
    // ...
}

@MockBean 不能用于模拟在应用程序上下文刷新期间执行的 bean 的行为,因为在执行测试时应用程序上下文刷新已经完成,现在配置模拟行为为时已晚。在这种情况下,我们建议使用 @Bean 方法来创建和配置模拟。

1. 使用Mockito模拟Bean

你可以使用@MockBean注解来添加一个模拟到Spring应用程序上下文中。这个Mock会替换任何现有的同类型的Bean,因此当你的服务尝试使用该Bean时,它会使用你的Mock版本,而不是实际的实例。

java 复制代码
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit4.SpringRunner;
import org.junit.runner.RunWith;
import org.junit.Test;
import org.mockito.Mockito;

@RunWith(SpringRunner.class)
@SpringBootTest
public class ExternalServiceTest {

    @MockBean
    private ExternalService externalService; // 需要模拟的外部服务

    @Autowired
    private ServiceUnderTest serviceUnderTest; // 测试的目标服务

    @Test
    public void testServiceMethod() {
        // 设置模拟行为
        Mockito.when(externalService.callExternalService()).thenReturn("Mock Response");

        // 调用服务方法,它会使用模拟的外部服务
        serviceUnderTest.serviceMethod();

        // 验证外部服务是否被调用
        Mockito.verify(externalService).callExternalService();
    }
}

在这个例子中,ExternalService是我们想要模拟的外部服务,而ServiceUnderTest是我们正在测试的服务。

然而,由于@MockBean是基于对 Bean 的完整声明周期进行 Mock,为了保证不同测试用例之间被 Mock 的 Bean 不会互相干扰,使用了不同 @MockBean 注解的测试用例之间不再复用 Spring 上下文,从而导致整个集成测试执行期间会启动多次 Spring 上下文,这会带来一些负面问题:

  • 整个集成测试的完整执行时间变长;
  • 一旦 Spring 上下文执行过程中存在一些 JVM 级别的不可重入逻辑(例如通过 static 变量实现不可重入逻辑),多次启动的 Spring 上下文将加载失败,导致测试用例执行失败;

如何解决这个问题,可以参考 InjectorMockTestExecutionListener.java。它的原理就是:

  1. 在测试类开始执行前,先解析相关注解确定需要生成哪些 Mock/Spy 以及对应的注入目标(可能是 Bean 或者 SOFA 服务)。

  2. 在测试方法执行前,会将目标中的相应字段替换成 Mock/Spy,并执行测试方法。

  3. 在测试类执行完毕后,会将注入的 Mock/Spy 重置回原来的值,保证 Spring 上下文不被污染,因此 Spring Test 可以直接复用缓存的上下文。

spy 和 mock 的区别

在Mockito框架中,mock和spy是用于创建测试的两种不同的方法,它们在单元测试中有着不同的应用场景。

Mock

使用mock方法创建的是一个完全的模拟对象,这种模拟对象没有任何与原始类相关的行为,每个非void方法的默认行为都是返回相应类型的默认值(比如0、false、null等),而void方法则不执行任何操作。你需要为这个模拟对象手动设置所有希望在测试中调用的方法的期望行为。

使用mock的场景是你想完全控制一个类的行为,通常是因为这个类很复杂,或者它的行为依赖于外部系统,如数据库或网络服务。

java 复制代码
List mockedList = mock(List.class);
when(mockedList.size()).thenReturn(100);

在上面的代码中,mockedList对象是一个完全的模拟对象,其size()方法的行为被指定为返回100。

Spy

使用spy方法创建的是一个部分模拟的对象,这种对象的默认行为是调用实际的方法,但你可以覆盖某些方法的行为来满足测试需求。它基于一个已经存在的实例,可以让你在保持大部分原有行为的基础上,只修改其中一部分方法。

Spy通常用于那些不方便或不需要完全模拟的场景。比如,当你想测试一个类的某个功能,而这个功能依赖于类中其他已经被良好测试和验证的方法时。

java 复制代码
List list = new ArrayList();
List spyList = spy(list);
doReturn(100).when(spyList).size(); // 正确的使用方式
// when(spyList.size()).thenReturn(100); // 错误的使用方式,size()会被调用

在上面的代码中,spyList是基于list的一个spy对象,它的大部分行为都和list一样,但是size()方法的行为被修改为返回100。

使用Mockito中的Spy时要注意

  • 你应该尽可能避免使用Spy,因为它们引入了真实对象的状态,这可能会使测试变得复杂和脆弱。
  • 在使用Spy时,覆盖方法行为时必须使用doReturn()/doThrow()/doAnswer()等语法,而不是when()/thenReturn()/thenThrow()/thenAnswer()等语法,因为后者会首先调用一次真实方法,然后再设置存根。
java 复制代码
doReturn(100).when(spyList).size(); // 正确的使用方式
// when(spyList.size()).thenReturn(100); // 错误的使用方式,size()会被调用

综上所述,mock主要用于完全模拟对象,而spy用于在需要时只覆盖部分方法的部分模拟对象。在单元测试中,通常推荐使用mock,因为这可以保持测试的独立性和可预测性。Spy则在需要对现有实例进行微调时使用。

问题记录

@MockBean mock的 bean 为 null

如果你在使用@MockBean进行单元测试,但是发现mock的bean为null,这通常意味着Spring的测试上下文没有正确设置或者@MockBean没有被正确应用。下面是一些可能导致这种情况的原因以及如何解决它们:

1. 确保包含Spring Boot测试依赖

首先,确认你的项目中已经包含了Spring Boot测试相关的依赖。

对于Maven,应该包括以下依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

对于Gradle,应该添加以下依赖:

arduino 复制代码
testImplementation 'org.springframework.boot:spring-boot-starter-test'

2. 使用正确的测试注解

确保你使用了正确的测试注解,如@SpringBootTest、@DataJpaTest、@WebMvcTest等,这取决于你的测试类型。

例如:

less 复制代码
@RunWith(SpringRunner.class)
@SpringBootTest
public class YourTest {
    // ...
}

对于JUnit 5,使用以下注解:

less 复制代码
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class YourTest {
    // ...
}

3. 在测试类中使用@MockBean

确保@MockBean注解是被添加在测试类中,而不是在测试方法或其他地方。

kotlin 复制代码
@SpringBootTest
public class YourTest {
    
    @MockBean
    private YourService yourService;
    
    // ...
}

4. 确保使用了Spring的测试运行器

当使用JUnit 4时,确保你的测试类使用了@RunWith(SpringRunner.class)或@RunWith(SpringJUnit4ClassRunner.class)。

5. 正确初始化Mockito

如果你不使用@SpringBootTest,而是用@ExtendWith(MockitoExtension.class)来进行普通的单元测试,那么你不能使用@MockBean,而应该使用@Mock和@InjectMocks。

6. 避免循环依赖

如果你的测试中出现了循环依赖,它可能会导致@MockBean无法正确工作。检查你的应用配置和Bean之间的依赖关系,确保没有循环依赖。

7. 清理缓存的测试上下文

有时候,缓存的测试上下文可能会产生问题。尝试在IDE中清除构建并重新运行测试,或者在命令行中使用Maven或Gradle的清理命令。

8. 检查测试配置文件

如果你的项目中有多个测试配置文件,确认没有其他配置覆盖了你的MockBean。

如果以上步骤都无法解决问题,还可以尝试查看测试日志输出和Spring的调试日志(通过设置logging.level.org.springframework=DEBUG)来获取更多关于Bean初始化过程的信息。如果问题仍然存在,可能需要更详细地查看你的测试代码和配置,检查是否有其他配置或代码影响了Spring的正常工作。

9. 检查是否指定了 TestExecutionListeners

如果没有使用 @SpringBootTest,则需要手动开启Mockito的 Listener,执行依赖注入和reset操作,否则 @MockBean 注解的字段为 null。

python 复制代码
import org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener;
import org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;

@ContextConfiguration(classes = MyConfig.class)
@TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class })
class MyTests {
    // ...
}

参考文档

7 Popular Unit Test Naming Conventions

Power Use of Value Objects in DDD

Spring Boot @MockBean Example

Spring boot Mocking and Spying Beans

相关推荐
system_error_x12 分钟前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
驭渊的小故事42 分钟前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客1 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1881 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn2 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su2 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
我命由我123453 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
苜蓿muxu4 小时前
Java 字符串拼接:+、StringBuilder、StringBuffer的区别
java
摇滚侠4 小时前
《Java并发编程之美》第 4 章 Java 并发包中原子操作类原理剖析 阅读笔记 7
java·笔记·python