学习笔记:单元测试 Junit入门

48 阅读7分钟

一、JUnit 5 核心基础(入门必备)

1. 核心依赖(Maven/Gradle)

Maven(pom.xml)

xml

<dependencies>
    <!-- JUnit 5 核心依赖 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    <!-- 可选:参数化测试依赖 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-params</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle(build.gradle)

groovy

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.2'
}

test {
    useJUnitPlatform()
}

2. 基础代码示例

步骤 1:待测试的业务类(比如企业常见的订单金额计算)

java

运行

package com.example.demo.service;

/**
 * 企业常见场景:订单金额计算服务
 */
public class OrderAmountService {

    // 计算订单最终金额(原价 - 折扣 + 运费)
    public double calculateFinalAmount(double originalAmount, double discount, double freight) {
        // 边界校验:金额不能为负
        if (originalAmount < 0 || discount < 0 || freight < 0) {
            throw new IllegalArgumentException("金额不能为负数");
        }
        // 折扣不能超过原价
        if (discount > originalAmount) {
            throw new IllegalArgumentException("折扣不能超过原价");
        }
        return originalAmount - discount + freight;
    }

    // 判断订单是否为大额订单(金额≥1000)
    public boolean isLargeOrder(double finalAmount) {
        return finalAmount >= 1000.0;
    }
}

步骤 2:JUnit 5 测试类

java

运行

package com.example.demo.service;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

/**
 * OrderAmountService的单元测试类
 * 企业规范:测试类命名=待测试类+Test,测试方法命名=test+功能+场景
 */
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 整个测试类共用一个实例
public class OrderAmountServiceTest {

    private OrderAmountService orderAmountService;

    // 每个测试方法执行前初始化(@BeforeEach:每个方法执行前;@BeforeAll:类初始化时执行一次)
    @BeforeEach
    void setUp() {
        orderAmountService = new OrderAmountService();
    }

    // 1. 基础场景测试:正常金额计算
    @Test
    @DisplayName("测试正常订单金额计算") // 自定义测试名称,便于CI/CD报告阅读
    void testCalculateFinalAmount_NormalCase() {
        // given:测试前置条件
        double original = 500.0;
        double discount = 50.0;
        double freight = 10.0;

        // when:执行待测试方法
        double result = orderAmountService.calculateFinalAmount(original, discount, freight);

        // then:断言结果符合预期
        assertEquals(460.0, result, 0.001); // 0.001是浮点精度容差
    }

    // 2. 异常场景测试:折扣超过原价
    @Test
    @DisplayName("测试折扣超过原价时抛出异常")
    void testCalculateFinalAmount_DiscountExceedOriginal() {
        // given
        double original = 500.0;
        double discount = 600.0;
        double freight = 10.0;

        // when & then:断言抛出指定异常,且异常信息匹配
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> orderAmountService.calculateFinalAmount(original, discount, freight)
        );
        assertEquals("折扣不能超过原价", exception.getMessage());
    }

    // 3. 参数化测试:批量测试不同场景(企业高频用法,减少重复代码)
    @ParameterizedTest
    @CsvSource({
            "1000.0, true",  // 大额订单
            "999.9, false",  // 非大额订单
            "0.0, false",    // 零金额
            "1500.5, true"   // 带小数的大额订单
    })
    @DisplayName("参数化测试-判断是否为大额订单")
    void testIsLargeOrder(double amount, boolean expected) {
        boolean result = orderAmountService.isLargeOrder(amount);
        assertEquals(expected, result);
    }

    // 4. 禁用测试(临时跳过,企业用于未完成的功能)
    @Test
    @Disabled("待修复:浮点精度问题")
    void testCalculateFinalAmount_WithPrecision() {
        // 待实现的测试逻辑
    }

    // 测试完成后清理资源(企业用于关闭连接、释放内存等)
    @AfterEach
    void tearDown() {
        orderAmountService = null;
    }
}

3. 核心注解说明(企业面试必问)

注解作用
@Test标记测试方法
@DisplayName自定义测试名称,提升可读性(CI/CD 报告中更友好)
@BeforeEach每个测试方法执行前执行(初始化对象、准备数据)
@BeforeAll测试类初始化时执行一次(静态方法,初始化全局资源如数据库连接)
@AfterEach每个测试方法执行后执行(清理资源)
@AfterAll测试类所有方法执行完后执行一次(静态方法,关闭连接)
@Disabled禁用测试方法
@ParameterizedTest参数化测试入口
@CsvSource参数化测试的数据源(CSV 格式,企业常用)
@TestInstance控制测试实例的生命周期(PER_CLASS:一个实例;PER_METHOD:默认,每个方法一个实例)

二、企业实际应用场景

1. 核心业务逻辑验证

  • 场景:电商系统的订单计算、支付金额校验、库存扣减逻辑。
  • 作用:确保核心算法(如优惠计算、税费计算)在各种边界条件下正确,避免生产环境资损。
  • 企业实践:每个核心方法都要覆盖「正常场景 + 异常场景 + 边界场景」。

2. 工具类 / 通用方法测试

  • 场景:日期格式化、字符串处理、加密解密工具类。
  • 作用:保证通用方法的复用性和正确性,避免 “一处错处处错”。
  • 企业实践:结合参数化测试,批量覆盖不同输入(如空字符串、特殊字符、极值)。

3. 异常处理逻辑验证

  • 场景:接口调用超时、参数非法、数据库连接失败。
  • 作用:确保异常被正确捕获和处理,避免系统崩溃。
  • 企业实践:用assertThrows断言异常类型和信息,覆盖所有异常分支。

4. 单元测试与 CI/CD 集成(企业核心流程)

  • 场景:Jenkins/GitLab CI 自动构建时执行单元测试。

  • 作用:代码提交后自动验证,提前发现问题,避免缺陷流入测试环境。

  • 企业实践:

    1. 配置 CI 脚本,要求单元测试通过率 100% 才能合并代码;
    2. 生成测试覆盖率报告(如 JaCoCo),要求核心代码覆盖率≥80%。

5. 测试替身(Mock)结合使用(企业高频)

  • 场景:待测试方法依赖数据库、第三方接口等外部服务。
  • 作用:用 Mock(如 Mockito)模拟外部依赖,避免测试受外部环境影响。
  • 示例(结合 Mockito):

java

运行

// 依赖注入的Service
@Service
public class OrderService {
    @Autowired
    private UserService userService; // 依赖的外部服务

    public boolean isVipOrder(Long orderId) {
        Long userId = getUserIdByOrderId(orderId);
        return userService.isVip(userId); // 调用外部服务
    }
}

// 测试类(Mock UserService)
@Test
void testIsVipOrder() {
    // 模拟UserService的返回值
    UserService mockUserService = Mockito.mock(UserService.class);
    Mockito.when(mockUserService.isVip(1L)).thenReturn(true);

    // 注入Mock对象
    OrderService orderService = new OrderService();
    ReflectionTestUtils.setField(orderService, "userService", mockUserService);

    // 测试
    boolean result = orderService.isVipOrder(1001L);
    assertTrue(result);
}

三、企业面试高频问题

1. 基础类问题

Q1:JUnit 4 和 JUnit 5 的区别?

  • 核心包:JUnit4 是org.junit,JUnit5 拆分为junit-jupiter-api(API)、junit-jupiter-engine(运行时);
  • 注解:JUnit4 的@Before→JUnit5 的@BeforeEach@After@AfterEach@Test(expected = Exception.class)→JUnit5 的assertThrows
  • 扩展性:JUnit5 支持 Lambda 表达式、参数化测试更灵活,支持 Java 8 + 特性;
  • 架构:JUnit5 是模块化设计,支持多种运行时(如 Vintage 引擎兼容 JUnit4)。

Q2:单元测试的原则(FIRST)?

  • Fast:测试要快(毫秒级),避免依赖外部服务;
  • Independent:测试之间相互独立,不依赖执行顺序;
  • Repeatable:可重复执行,无论环境如何结果一致;
  • Self-validating:自动断言结果,无需人工检查;
  • Timely:测试与代码同步编写(TDD:测试驱动开发)。

2. 实践类问题

Q3:如何保证单元测试的有效性?

  • 覆盖核心场景:正常流程、边界值(如金额 0、最大值)、异常流程;
  • 避免 “假测试”:不要只写assertTrue(true),要针对业务逻辑断言;
  • 测试粒度:单个测试方法只测一个功能点,命名清晰(如testXxx_场景_预期结果);
  • 隔离外部依赖:用 Mock 模拟数据库、接口等,避免测试受外部环境影响。

Q4:单元测试覆盖率越高越好吗?

  • 不是。覆盖率是参考指标,不是目标;
  • 核心代码(如订单计算、支付逻辑)必须高覆盖率(≥80%);
  • 非核心代码(如简单的 getter/setter)无需强制覆盖;
  • 避免为了覆盖率写 “无效测试”,重点是覆盖 “逻辑分支” 而非 “代码行数”。

Q5:如何测试私有方法?

  • 不建议直接测试私有方法(私有方法是内部实现,应通过公有方法间接测试);
  • 特殊场景:用反射(ReflectionTestUtils)调用私有方法,或把私有方法改为包级访问(不推荐);
  • 企业实践:如果私有方法逻辑复杂,建议拆分为独立的公有工具类,再测试工具类。

3. 进阶类问题

Q6:单元测试和集成测试的区别?

维度单元测试集成测试
测试粒度单个方法 / 类多个类 / 模块 / 服务
依赖处理Mock 外部依赖真实依赖(如数据库、接口)
执行速度快(毫秒级)慢(秒 / 分钟级)
目的验证逻辑正确性验证模块间交互正确性

Q7:TDD(测试驱动开发)的流程?

  1. 编写一个失败的单元测试(覆盖要实现的功能);
  2. 编写最少的代码让测试通过;
  3. 重构代码(优化结构,不改变功能);
  4. 重复上述步骤,逐步完善功能;
  • 企业价值:提前暴露设计问题,代码更易测试,减少后期重构成本。

四、企业最佳实践总结

  1. 测试命名规范:测试类 = 业务类 + Test,测试方法 = test + 功能 + 场景(如testCalculateFinalAmount_DiscountExceedOriginal);
  2. 覆盖核心场景:正常流程、边界值、异常流程、空值 / 非法参数;
  3. 避免测试耦合:不依赖外部服务,用 Mock 隔离;
  4. 参数化测试:批量覆盖多场景,减少重复代码;
  5. CI 集成:强制单元测试通过才能合并代码,生成覆盖率报告;
  6. 不测试无关逻辑:如 getter/setter、简单的日志打印(除非有特殊逻辑);
  7. 测试可维护:避免硬编码,用常量 / 配置管理测试数据,便于后期修改。