JUnit实战教程:Java单元测试实例详解
时间:2025-07-30 09:11:52 390浏览 收藏
编程并不是一个机械性的工作,而是需要有思考,有创新的工作,语法是固定的,但解决问题的思路则是依靠人的思维,这就需要我们坚持学习和更新自己的知识。今天golang学习网就整理分享《Java单元测试教程:JUnit实例与使用方法》,文章讲解的知识点主要包括,如果你对文章方面的知识点感兴趣,就不要错过golang学习网,在这可以对大家的知识积累有所帮助,助力开发能力的提升。
在Java中进行单元测试首选JUnit,它是行业标准工具,能独立测试代码最小单元,确保代码按预期工作。JUnit提供注解和断言机制,简化测试代码编写,支持@BeforeEach、@AfterEach等生命周期管理,提升测试效率。使用JUnit需在Maven或Gradle中添加依赖,创建对应测试类并编写测试方法。JUnit通过断言验证行为,如assertEquals、assertTrue、assertThrows等,确保代码逻辑正确。此外,JUnit支持测试套件和参数化测试,增强测试覆盖率。模拟框架如Mockito可与JUnit集成,用于隔离外部依赖,控制测试行为,提升测试速度和稳定性。单元测试是软件质量基石,能早期发现缺陷、支持重构、驱动良好设计,并作为活文档提升代码可维护性。使用时应确保测试独立、命名清晰、合理使用模拟对象,避免过度依赖实现细节。
在Java里,要进行单元测试,JUnit绝对是你的首选工具,几乎可以说它是行业标准了。它提供了一套框架,让你能独立地测试代码中的最小可测试单元,通常是一个方法或一个类,确保它们按预期工作。这就像给你的代码加了一道道“保险丝”,每次改动都能快速知道有没有搞砸什么。

解决方案
要在Java项目中使用JUnit进行单元测试,你首先需要在项目的构建文件中添加JUnit依赖。如果你用的是Maven,在pom.xml
里加上:
org.junit.jupiter junit-jupiter-api 5.10.0 test org.junit.jupiter junit-jupiter-engine 5.10.0 test
如果是Gradle,则在build.gradle
中:

dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' }
接下来,为你要测试的类创建一个对应的测试类。通常,测试类和被测试类在包结构上保持一致,但放在src/test/java
目录下。比如,如果你有一个Calculator
类:
// src/main/java/com/example/app/Calculator.java package com.example.app; public class Calculator { public int add(int a, int b) { return a + b; } public int subtract(int a, int b) { return a - b; } public double divide(double a, double b) { if (b == 0) { throw new IllegalArgumentException("Divisor cannot be zero"); } return a / b; } }
那么对应的测试类可能是这样:

// src/test/java/com/example/app/CalculatorTest.java package com.example.app; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class CalculatorTest { @Test void testAdd() { Calculator calculator = new Calculator(); int result = calculator.add(2, 3); assertEquals(5, result, "2 + 3 应该等于 5"); } @Test void testSubtract() { Calculator calculator = new Calculator(); assertEquals(1, calculator.subtract(5, 4), "5 - 4 应该等于 1"); } @Test void testDivideByZero() { Calculator calculator = new Calculator(); // 预期会抛出 IllegalArgumentException assertThrows(IllegalArgumentException.class, () -> calculator.divide(10, 0), "除数为零时应抛出异常"); } @Test void testDivideNormal() { Calculator calculator = new Calculator(); assertEquals(2.5, calculator.divide(5, 2), 0.001, "5 / 2 应该等于 2.5"); // 0.001 是delta,用于浮点数比较 } }
在IDE(如IntelliJ IDEA或Eclipse)中,你可以直接右键点击测试类或测试方法来运行它们。构建工具(Maven或Gradle)也会在构建生命周期中自动执行测试。
为什么Java单元测试是软件质量的“基石”?它解决了哪些开发痛点?
在我看来,单元测试之所以被称为软件质量的“基石”,绝不仅仅是句口号。它真正解决了开发过程中那些让人头疼的问题,甚至能改变你的编码习惯。想想看,我们写代码,最怕的是什么?是改了一个地方,结果把另一个地方搞崩了,而且还不知道是哪里崩了。这种“牵一发而动全身”的恐惧,在没有单元测试时尤为明显。
单元测试首先解决的是早期缺陷发现。你写完一个功能,立刻就能跑测试,发现问题立马修复,这比等到集成测试甚至上线后才发现要便宜得多。就像盖房子,地基打歪了马上纠正,总比房子都盖到三层了才发现要好。
其次,它给了我们重构的勇气和信心。代码总会腐化,需要重构。但没有测试覆盖的代码,你敢动吗?我反正是不敢。每次重构都像在走钢丝,生怕哪里不小心就断了。有了单元测试,就像给钢丝下面铺了张网,你可以大胆地去优化结构、提升性能,因为你知道,只要测试通过,核心功能就没被破坏。这简直是开发者的“救命稻草”。
再者,它驱动更好的设计。当你想为某个类写单元测试时,如果发现这个类依赖项太多、逻辑太复杂、难以独立测试,那么恭喜你,你的设计可能就有问题了。测试性是衡量代码质量的一个重要指标,单元测试会“逼迫”你去思考如何让代码更模块化、更解耦,最终写出更易于维护、扩展的代码。我甚至会先写测试,再写业务代码,这种TDD(测试驱动开发)的实践,更是把单元测试的优势发挥到了极致。它也充当了活文档的角色,看一个测试用例,你就能明白这个功能在特定输入下应该有什么行为,比看那些过时的文档强太多了。
JUnit核心功能:那些@注解和断言,到底怎么用才算‘用对了’?
JUnit的核心魅力在于它提供了一套简洁而强大的API,尤其是那些注解和断言。用对了,你的测试代码会非常清晰,一眼就能看出测试意图。
常用注解:
@Test
: 这个是最基础的,标注一个方法是一个测试方法。JUnit运行时会找到所有带有这个注解的方法并执行。@BeforeEach
: 标记的方法会在每个@Test
方法执行之前运行。非常适合做一些测试前的初始化工作,比如创建被测试对象的新实例,确保每个测试方法都在一个干净的环境中运行。@AfterEach
: 与@BeforeEach
相反,标记的方法会在每个@Test
方法执行之后运行。常用于清理资源,比如关闭文件流、数据库连接等。@BeforeAll
: 标记的方法会在所有@Test
方法执行之前运行,但只执行一次。这个方法必须是静态的。适合做一些耗时的一次性初始化,比如启动一个内嵌数据库。@AfterAll
: 标记的方法会在所有@Test
方法执行之后运行,也只执行一次。同样必须是静态的。用于释放@BeforeAll
中分配的资源。@DisplayName("测试用例的友好名称")
: 给测试方法或测试类起一个更具可读性的名字,尤其是在测试报告中会显得很友好。@Disabled("原因说明")
: 暂时禁用某个测试方法或整个测试类。比如某个功能还在开发中,或者有已知问题暂时不想跑。
核心断言(org.junit.jupiter.api.Assertions
):
断言是单元测试的灵魂,它们用来验证实际结果是否符合预期。
assertEquals(expected, actual, [message])
: 验证两个值是否相等。对于浮点数,通常需要提供一个delta
参数来指定允许的误差范围,比如assertEquals(2.5, calculator.divide(5, 2), 0.001)
。assertTrue(condition, [message])
: 验证一个条件是否为真。assertFalse(condition, [message])
: 验证一个条件是否为假。assertNull(object, [message])
: 验证一个对象是否为null。assertNotNull(object, [message])
: 验证一个对象是否不为null。assertThrows(expectedType, executable, [message])
: 验证一个代码块是否抛出了预期的异常。这是测试异常处理逻辑的利器。assertAll(heading, executables...)
: 组合多个断言。如果其中任何一个断言失败,assertAll
会收集所有失败信息并一次性报告,而不是在第一个失败时就停止,这在某些场景下非常有用。
import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @DisplayName("用户服务测试") class UserServiceTest { private UserService userService; // 假设这是我们要测试的服务 @BeforeAll static void setupAll() { System.out.println("--- 所有测试开始前,执行一次全局设置,比如初始化数据库连接池 ---"); } @BeforeEach void setup() { // 每个测试方法执行前,都会创建一个新的UserService实例,确保测试隔离性 userService = new UserService(); System.out.println("每个测试方法开始前,初始化UserService"); } @Test @DisplayName("测试用户注册功能是否成功") void testRegisterUserSuccess() { boolean result = userService.registerUser("john.doe", "password123"); assertTrue(result, "用户注册应该成功"); // 还可以进一步断言,比如检查用户是否真的被添加到了某个存储中 // assertEquals(1, userService.getUserCount()); } @Test @DisplayName("测试注册已存在用户是否失败并抛出异常") void testRegisterExistingUserThrowsException() { userService.registerUser("jane.doe", "pass"); // 先注册一次 // 预期第二次注册会抛出 IllegalArgumentException assertThrows(IllegalArgumentException.class, () -> userService.registerUser("jane.doe", "pass"), "注册已存在用户时应抛出IllegalArgumentException"); } @Test void testLoginSuccess() { userService.registerUser("testuser", "testpass"); boolean loggedIn = userService.login("testuser", "testpass"); assertTrue(loggedIn, "用户登录应该成功"); } @AfterEach void tearDown() { // 每个测试方法结束后,清理资源,比如清除UserService内部的状态 System.out.println("每个测试方法结束后,清理资源"); } @AfterAll static void tearDownAll() { System.out.println("--- 所有测试结束后,执行一次全局清理,比如关闭数据库连接池 ---"); } } // 假设的UserService类 class UserService { // 简化实现,实际可能与数据库交互 private Mapusers = new HashMap<>(); public boolean registerUser(String username, String password) { if (users.containsKey(username)) { throw new IllegalArgumentException("Username already exists"); } users.put(username, password); return true; } public boolean login(String username, String password) { return users.containsKey(username) && users.get(username).equals(password); } }
关于“用对了”,我个人认为,一个好的单元测试方法应该只测试一个独立的逻辑单元,并且只针对一个特定的行为进行断言。虽然有时为了便利,一个测试方法里会有多个相关的断言,但如果测试失败,你希望能够快速定位是哪个行为出了问题。测试方法名也要有意义,能够清晰地表达它在测试什么场景。
模拟对象(Mocking)在单元测试中的作用:何时需要它,又该如何引入?
在真实的Java应用中,你的类很少是完全独立的,它们通常会依赖其他类、数据库、外部服务、文件系统等等。这时候,如果直接在单元测试中去调用这些真实的依赖,那你的测试就不是“单元”测试了,它更像集成测试,因为它包含了多个组件的协作。而且,这些外部依赖往往会使测试变得缓慢、不稳定,甚至需要复杂的环境配置。
这就是模拟对象(Mocking)登场的时候了。模拟(Mocking)的核心思想是:在测试一个类(我们称之为“被测单元”或“SUT - System Under Test”)时,用一个假的、可控的对象来替代它所依赖的真实对象。这个假对象被称为“模拟对象”或“Mock”。通过模拟,我们可以:
- 隔离被测单元: 确保我们只测试当前单元的逻辑,而不受其依赖项行为的影响。
- 控制依赖行为: 我们可以预设模拟对象的行为(比如调用某个方法时返回什么值,或者抛出什么异常),从而模拟各种复杂的场景,包括错误情况。
- 提高测试速度: 避免了真实的I/O操作、网络请求等耗时操作。
- 简化测试环境: 不需要搭建复杂的数据库或外部服务环境。
在Java生态中,Mockito是使用最广泛的模拟框架之一,它与JUnit配合得天衣无缝。
何时需要模拟?
- 当你的被测单元依赖于外部服务(如REST API调用)。
- 当依赖对象创建成本高昂或耗时(如数据库连接、文件I/O)。
- 当依赖对象行为不稳定或不可预测(如随机数生成器、时间)。
- 当你想测试被测单元在依赖对象抛出异常时的行为。
- 当你想验证被测单元是否正确地与它的依赖进行了交互(比如某个方法是否被调用了多少次,参数是什么)。
如何引入Mockito?
和JUnit一样,首先添加Maven或Gradle依赖:
Maven pom.xml
:
org.mockito mockito-junit-jupiter 5.6.0 test
Gradle build.gradle
:
dependencies { testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' }
Mockito基本用法示例:
假设我们有一个OrderService
,它依赖于一个PaymentGateway
来处理支付:
// src/main/java/com/example/app/PaymentGateway.java package com.example.app; public class PaymentGateway { public boolean processPayment(double amount, String cardNumber) { // 实际的支付逻辑,可能涉及网络请求、银行API等 System.out.println("Processing payment of " + amount + " with card " + cardNumber); // 简化:总是成功 return true; } } // src/main/java/com/example/app/OrderService.java package com.example.app; public class OrderService { private PaymentGateway paymentGateway; // 依赖注入,方便测试 public OrderService(PaymentGateway paymentGateway) { this.paymentGateway = paymentGateway; } public boolean placeOrder(double amount, String cardNumber) { if (amount <= 0) { throw new IllegalArgumentException("Order amount must be positive"); } // 调用支付网关处理支付 boolean paymentSuccess = paymentGateway.processPayment(amount, cardNumber); if (paymentSuccess) { System.out.println("Order placed successfully for " + amount); return true; } else { System.out.println("Payment failed, order not placed."); return false; } } }
现在,我们来测试OrderService
的placeOrder
方法,但我们不想真的去调用PaymentGateway
的真实支付逻辑:
// src/test/java/com/example/app/OrderServiceTest.java package com.example.app; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; // 导入Mockito的静态方法 @ExtendWith(MockitoExtension.class) // 启用Mockito JUnit 5 扩展 class OrderServiceTest { @Mock // 告诉Mockito创建一个PaymentGateway的模拟对象 private PaymentGateway mockPaymentGateway; @InjectMocks // 告诉Mockito把mockPaymentGateway注入到OrderService的构造器中 private OrderService orderService; @Test void testPlaceOrderSuccess() { // 设定mockPaymentGateway的行为:当调用processPayment方法时,返回true when(mockPaymentGateway.processPayment(anyDouble(), anyString())).thenReturn(true); boolean result = orderService.placeOrder(100.0, "1234-5678-9012-3456"); assertTrue(result, "订单应该成功下达"); // 验证mockPaymentGateway的processPayment方法是否被调用了1次,并且参数是100.0和任意字符串 verify(mockPaymentGateway, times(1)).processPayment(100.0, anyString()); } @Test void testPlaceOrderPaymentFailure() { // 设定mockPaymentGateway的行为:当调用processPayment方法时,返回false when(mockPaymentGateway.processPayment(anyDouble(), anyString())).thenReturn(false); boolean result = orderService.placeOrder(50.0, "invalid-card"); assertFalse(result, "支付失败时,订单不应该成功下达"); verify(mockPaymentGateway).processPayment(50.0, "invalid-card"); // 验证参数是否正确 } @Test void testPlaceOrderWithZeroAmountThrowsException() { // 预期当订单金额为0时,OrderService会抛出IllegalArgumentException assertThrows(IllegalArgumentException.class, () -> orderService.placeOrder(0, "any-card"), "订单金额为零时应抛出异常"); // 验证mockPaymentGateway的processPayment方法没有被调用,因为异常在调用之前就抛出了 verifyNoInteractions(mockPaymentGateway); } }
这里我们用了@Mock
来创建模拟对象,@InjectMocks
来将模拟对象注入到被测试类中。when().thenReturn()
用于定义模拟对象的行为,而verify()
则用于验证模拟对象的方法是否被调用,以及调用时的参数是否正确。这允许我们精确地控制测试场景,确保OrderService
的逻辑在各种支付网关响应下都能正确工作,而无需真正与外部系统交互。
当然,模拟对象也不是万能药,过度模拟有时会导致测试变得脆弱,因为它会紧密耦合到被测单元的实现细节。所以,何时模拟、模拟什么,这本身就是一门艺术,需要一些实践和经验来拿捏。一般来说,只模拟那些外部的、不可控的、或耗时的依赖,对于简单的POJO或值对象,通常不需要模拟。
今天带大家了解了的相关知识,希望对你有所帮助;关于文章的技术知识我们会一点点深入介绍,欢迎大家关注golang学习网公众号,一起学习编程~
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
346 收藏
-
448 收藏
-
482 收藏
-
471 收藏
-
126 收藏
-
237 收藏
-
484 收藏
-
365 收藏
-
103 收藏
-
182 收藏
-
360 收藏
-
472 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 511次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 498次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习