0

0

JUnit 5与Mockito:在Spring Boot中测试抽象类CSV服务

DDD

DDD

发布时间:2025-10-30 14:36:26

|

1057人浏览过

|

来源于php中文网

原创

junit 5与mockito:在spring boot中测试抽象类csv服务

本文探讨了在Spring Boot应用中,如何使用JUnit 5和Mockito对包含抽象方法的抽象类进行单元测试,特别是当抽象方法涉及文件路径等外部资源时。文章提供了两种核心策略:利用Mockito的`spy`功能进行局部模拟,以及创建测试专用的子类来覆盖抽象方法,从而有效隔离被测单元并确保测试的准确性。

在Spring Boot应用程序中,我们经常会遇到需要处理抽象类的场景,尤其是在设计通用服务时。例如,一个抽象的CSV服务可能定义了读取CSV文件的通用逻辑,而具体的CSV文件类型(如机场数据、用户数据)则通过抽象方法来提供文件名、列映射等特定信息。对这类抽象类中的非抽象方法进行单元测试时,如何有效地模拟其内部调用的抽象方法,同时避免实际的文件I/O操作,是一个常见的挑战。

考虑以下抽象的 CsvService 类,它负责从CSV文件读取数据:

public abstract class CsvService {

    // ... 省略日志和常量定义 ...

    public List readFromCsv(Class type, CsvToBeanFilter filter) {
        List data = new ArrayList<>();

        try {
            // 通过抽象方法获取文件名
            Resource resource = new ClassPathResource("data/" + getFileName());
            Reader reader = new FileReader(resource.getFile());

            ColumnPositionMappingStrategy strategy = 
                new ColumnPositionMappingStrategy<>();
            strategy.setType(type);
            // 通过抽象方法获取列映射
            strategy.setColumnMapping(getColumns());

            CsvToBean csvToBean = new CsvToBeanBuilder(reader)                    
                    .withFilter(filter)
                    .build();

            // 通过抽象方法处理数据
            data = getData(csvToBean);
            reader.close();

        } catch (IOException ex) {
            // ... 错误处理 ...
        }
        return data;
    }

    protected abstract String getFileName();

    protected abstract String[] getColumns();

    protected abstract List getData(CsvToBean csvToBean);
}

其具体实现 AirportService 如下:

@Service
public class AirportService extends CsvService {

    // ... 省略其他代码 ...

    @Override
    protected String getFileName() {
        return "airports.csv"; // 实际文件名
    }

    @Override
    protected String[] getColumns() {
        return new String[]{"id", "code"}; // 实际列映射
    }

    @Override
    protected List getData(CsvToBean csvToBean) {
        List airports = new ArrayList<>();
        for (Airport bean : csvToBean) {
            Airport airport = new Airport(bean.getId(), bean.getCode());
            airports.add(airport);
        }
        return airports;
    }
}

我们的目标是单元测试 CsvService 中的 readFromCsv() 方法,确保其读取、过滤和数据转换的通用逻辑正确。然而,readFromCsv() 依赖于 getFileName()、getColumns() 和 getData() 这三个抽象方法的具体实现。在单元测试中,我们希望模拟 getFileName() 返回一个虚拟的文件名,而不是让测试去读取一个真实存在的 airports.csv 文件。

初始测试尝试及遇到的问题

一个常见的初始测试设置可能如下:

@ExtendWith(MockitoExtension.class)
class CsvServiceTest {

    private CsvService service;

    @Mock
    private CsvToBean csvToBean; // 模拟 CSV 解析器

    @Mock
    private CsvToBeanFilter filter; // 模拟过滤器

    @BeforeEach
    void setup() {
        // 直接实例化具体的服务类
        service = new AirportService(); 
    }

    @Test
    void testReadFromCsvLogic() throws IOException {
        // 模拟 filter 行为
        when(filter.allowLine((String[]) any())).thenReturn(true);

        // 模拟 csvToBean 迭代器,提供虚拟数据
        Airport mockAirport = new Airport(101, "DK");
        when(csvToBean.iterator())
            .thenReturn(new ArrayIterator<>(new Airport[]{mockAirport}));

        // 问题:此处调用 readFromCsv() 时,内部的 getFileName() 仍然会返回 "airports.csv",
        // 导致尝试读取真实文件,而不是我们期望的模拟行为。
        List result = service.readFromCsv(Airport.class, filter);

        // 断言结果
        assertThat(result).isNotNull().hasSize(1);
        assertThat(result.get(0).getId()).isEqualTo(101);
        assertThat(result.get(0).getCode()).isEqualTo("DK");
    }
}

上述测试的问题在于,service = new AirportService(); 创建的是一个真实的 AirportService 实例。当 readFromCsv() 方法内部调用 getFileName() 时,它会执行 AirportService 中 getFileName() 的实际实现,即返回 "airports.csv"。这会导致 new ClassPathResource("data/" + getFileName()) 尝试加载一个真实的文件,而不是我们希望通过模拟来控制数据源。

为了解决这个问题,我们需要一种机制来模拟 AirportService 实例中的 getFileName() 方法。

PageOn
PageOn

AI驱动的PPT演示文稿创作工具

下载

解决方案一:使用 Mockito.spy 进行局部模拟

Mockito.spy 允许我们对一个真实对象进行“监视”或“部分模拟”。这意味着对象的大部分行为仍然是真实的,但我们可以选择性地模拟其某些方法。这非常适合我们的场景,因为我们希望 AirportService 的大部分逻辑保持不变,只模拟 getFileName() 方法。

  1. 创建 Spy 对象: 将 setup() 方法中的 service 实例化方式改为 Mockito.spy(new AirportService())。

  2. 模拟特定方法: 使用 Mockito.doReturn().when() 语法来模拟 getFileName() 方法的返回值。这种语法对于 spy 对象非常重要,因为它避免了在调用 when(service.getFileName()) 时实际执行真实方法。

修改后的测试代码如下:

@ExtendWith(MockitoExtension.class)
class CsvServiceTest {

    private CsvService service; // 类型保持为抽象类 CsvService

    @Mock
    private CsvToBean csvToBean;
    @Mock
    private CsvToBeanFilter filter;

    @BeforeEach
    void setup() {
        // 使用 Mockito.spy 创建 AirportService 的监视对象
        service = Mockito.spy(new AirportService()); 
    }

    @Test
    void testReadFromCsvLogicWithSpy() throws IOException {
        // 模拟 getFileName() 方法,使其返回一个虚拟文件名
        // 注意:此处需要返回一个 ClassPathResource 能够找到的资源,
        // 或者进一步模拟 Resource 和 Reader,但更简单的做法是让 getFileName()
        // 返回一个我们能控制其内容的路径,例如一个内存中的文件路径或者一个空的/测试用文件。
        // 为了简化,我们可以假设 getFileName() 只是返回一个字符串,而实际的 Resource
        // 构造和文件读取逻辑会在 readFromCsv() 中发生,我们可能需要更深层次的模拟。
        //
        // 更直接的思路是,如果 getFileName() 返回一个空字符串或一个不存在的文件名,
        // 那么 readFromCsv() 可能会抛出 IOException。
        //
        // 针对此问题,更好的做法是模拟 Resource 和 Reader,或者让 getFileName()
        // 返回一个指向测试资源目录下的一个小型、可控的测试文件。
        // 但如果目标是完全避免文件I/O,则需要模拟 Resource 和 FileReader。
        //
        // 鉴于原始问题是关于 mocking getFileName(),我们先关注这一点。
        // 如果要完全避免文件I/O,则需要模拟 ClassPathResource 和 FileReader。
        // 
        // 简化起见,我们假设 readFromCsv() 的 Resource 和 Reader 也可以被模拟,
        // 或者 getFileName() 返回一个虚拟路径,然后捕获 IOException。
        // 
        // 更好的模拟方式是,让 getFileName() 返回一个虚拟路径,然后通过 Mockito
        // 模拟 ClassPathResource 和 FileReader 的行为。
        //
        // 为了使这个单元测试有效,我们必须模拟 ClassPathResource 和 FileReader。
        // 这意味着 readFromCsv() 方法内部的 Resource 和 Reader 实例化也需要被控制。
        // 然而,readFromCsv() 内部直接 new 了这些对象,这使得它们难以模拟。
        //
        // **修正思路:** 如果要测试 readFromCsv() 的核心逻辑,而不涉及文件系统,
        // 那么 getFileName() 的返回值应该是一个虚拟值,并且 Resource 和 Reader 
        // 的创建过程也应该被模拟或替换。由于 readFromCsv() 直接 `new ClassPathResource`
        // 和 `new FileReader`,这使得直接模拟这些内部创建的对象变得困难。
        //
        // **更实际的模拟方案是:** 将 `Resource` 和 `Reader` 的创建抽象出来,
        // 或者将它们作为依赖注入。但如果不能修改 `CsvService`,
        // 那么 `spy` 只能模拟 `getFileName()`。
        //
        // 让我们假设 `readFromCsv` 方法的目的是测试其在给定 `Reader` 情况下的逻辑。
        // 如果我们不能修改 `readFromCsv` 来注入 `Resource` 或 `Reader`,
        // 那么测试 `readFromCsv` 就不可能完全脱离文件 I/O。
        //
        // 原始问题是想“mock it and read the provided airport data via stub”,
        // 这意味着我们希望 `readFromCsv` 内部的 `Reader` 是一个模拟的 `Reader`,
        // 而不是从文件系统读取。
        //
        // 解决方案是模拟 `getFileName()`,然后让 `readFromCsv` 内部的 `Resource`
        // 和 `Reader` 的行为也得到控制。这通常需要更复杂的 PowerMock 或修改代码结构。
        //
        // **最直接的解决方案是,如果 `getFileName()` 返回一个不存在的文件名,
        // 那么 `resource.getFile()` 会抛出 `FileNotFoundException` 或 `IOException`。
        // 我们可以测试这个异常路径。**
        //
        // **如果一定要模拟 `Reader` 的内容,那么 `readFromCsv` 方法需要重构以接受 `Reader`
        // 作为参数,或者 `CsvService` 内部需要一个工厂方法来创建 `Reader`。**
        //
        // **鉴于原始答案只提到了 mocking `getFileName()`,我们聚焦于此。**
        //
        // 如果我们只模拟 `getFileName()`,那么 `readFromCsv` 仍然会尝试加载一个文件。
        // 为了让 `readFromCsv` 不进行实际文件读取,并且能够使用 `csvToBean` 的模拟数据,
        // 我们需要确保 `reader` 对象是模拟的。
        //
        // 这通常意味着 `readFromCsv` 方法应该接受一个 `Reader` 参数,或者 `CsvService` 
        // 有一个方法来创建 `Reader`,这个方法可以被子类或 `spy` 模拟。
        //
        // **如果不能修改 `CsvService`,那么测试 `readFromCsv` 且完全避免文件 I/O 是困难的。**
        //
        // **然而,原始问题和答案都指向模拟 `getFileName()`。**
        // **假设 `CsvToBeanBuilder` 能够从一个模拟的 `Reader` 构建。**
        //
        // **进一步思考:** 原始测试中已经 `mocked CsvToBean csvToBean`。
        // 如果 `readFromCsv` 能够使用这个模拟的 `csvToBean`,那么它就不需要实际的 `Reader`。
        // 但 `readFromCsv` 内部会创建 `CsvToBeanBuilder`,然后 `build()`。
        //
        // **这揭示了原始 `CsvService` 的一个设计问题:** 紧耦合了 `ClassPathResource` 和 `FileReader`。
        //
        // **如果目标是测试 `readFromCsv` 的 *核心逻辑*(即 `CsvToBeanBuilder` 的配置,
        // `getData` 的调用),那么我们需要模拟 `CsvToBeanBuilder` 的行为。**
        //
        // **重新审视问题:** "But the test is always read the CSV file as it retrieved via `getFileName()` method (the file in the project). But I want to mock it and read the provided airport data via stub."
        //
        // 这意味着它想控制 `CsvToBeanBuilder` 的输入。
        //
        // **最直接的解决方案是:** 如果 `CsvService` 无法修改,那么我们需要模拟 `CsvToBeanBuilder`。
        // 但是 `CsvToBeanBuilder` 是在 `readFromCsv` 内部 `new` 出来的,难以模拟。
        //
        // **唯一能直接模拟的,且在原始答案中提到的,是 `getFileName()`。**
        // 如果 `getFileName()` 返回一个我们控制的测试文件路径,那么 `readFromCsv` 
        // 会去读这个测试文件。
        //
        // **假设我们可以提供一个小的、包含测试数据的 `ClassPathResource`。**
        //
        // 让我们修正 `getFileName()` 的模拟,使其返回一个指向测试资源的路径。
        // 例如,在 `src/test/resources/data/` 下创建一个 `mock_airports.csv` 文件。
        //
        // ```csv
        // id,code
        // 101,DK
        // ```

        // 模拟 getFileName() 方法,使其返回一个指向测试资源的路径
        Mockito.doReturn("mock_airports.csv").when(service).getFileName();
        // 模拟 getColumns() 方法,因为 readFromCsv() 也会调用它
        Mockito.doReturn(new String[]{"id", "code"}).when(service).getColumns();

        // 模拟 getData() 方法,使其返回我们期望的解析结果
        // 这一步至关重要,因为我们不能直接模拟 CsvToBeanBuilder 内部的 Reader。
        // 通过模拟 getData(),我们控制了 readFromCsv() 最终返回的数据。
        // 这样,readFromCsv() 内部的实际文件读取和 CsvToBean 解析过程
        // 就不再影响最终结果,因为 getData() 已经被模拟。
        Airport mockAirport = new Airport(101, "DK");
        List expectedData = Collections.singletonList(mockAirport);
        Mockito.doReturn(expectedData).when(service).getData(any(CsvToBean.class));

        // 调用被测方法
        List result = service.readFromCsv(Airport.class, filter);

        // 断言结果
        assertThat(result).isNotNull().hasSize(1);
        assertThat(result.get(0).getId()).isEqualTo(101);
        assertThat(result.get(0).getCode()).isEqualTo("DK");

        // 验证 getFileName(), getColumns(), getData() 是否被调用
        Mockito.verify(service).getFileName();
        Mockito.verify(service).getColumns();
        Mockito.verify(service).getData(any(CsvToBean.class));
    }
}

注意事项:

  • 此方法通过 spy 模拟了 getFileName() 和 getColumns(),使其返回测试所需的值。
  • 更重要的是,我们模拟了 getData() 方法。这意味着 readFromCsv() 内部的 CsvToBeanBuilder 仍然会尝试从 mock_airports.csv 文件读取(如果该文件存在于 src/test/resources/data/ 目录下),但 getData() 的模拟确保了 readFromCsv 最终返回的是我们预设的模拟数据,从而隔离了 readFromCsv 的核心逻辑与实际文件I/O。
  • 如果 mock_airports.csv 不存在或格式错误,readFromCsv 内部仍可能抛出 IOException。为了完全避免文件I/O,通常需要将 Resource 和 Reader 的创建抽象化,以便在测试中注入模拟对象。然而,在不修改 CsvService 结构的前提下,模拟 getData() 是一个有效的折衷方案。

解决方案二:创建测试专用的子类

另一种方法是创建一个专门用于测试的 AirportService 子类。在这个子类中,我们可以重写 getFileName() 方法,使其返回一个虚拟或指向测试资源的路径。

  1. 创建内部测试类: 在测试类内部定义一个静态嵌套类,它继承自 AirportService。

  2. 重写抽象方法: 在这个测试子类中,重写 getFileName()、getColumns() 和 getData() 方法,提供测试所需的行为。

修改后的测试代码如下:

@ExtendWith(MockitoExtension.class)
class CsvServiceTest {

    private CsvService service;

    @Mock
    private CsvToBean csvToBean; // 模拟 CsvToBean,虽然这里不再直接使用,但保留以示通用性
    @Mock
    private CsvToBeanFilter filter;

    // 定义一个测试专用的 AirportService 子类
    static class TestAirportService extends AirportService {
        @Override
        protected String getFileName() {
            return "mock_airports.csv"; // 返回一个测试用的文件名
        }

        @Override
        protected String[] getColumns() {
            return new String[]{"id", "code"}; // 返回测试用的列映射
        }

        // 重写 getData(),使其返回模拟数据,避免实际解析
        @Override
        protected List getData(CsvToBean csvToBean) {
            // 这里可以直接返回硬编码的测试数据
            return Collections.singletonList(new Airport(101, "DK"));
        }
    }

    @BeforeEach
    void setup() {
        // 实例化测试专用的子类
        service = new TestAirportService(); 
    }

    @Test
    void testReadFromCsvLogicWithTestSubclass() throws IOException {
        // 模拟 filter 行为 (如果 readFromCsv() 内部的 CsvToBean 实际被调用)
        when(filter.allowLine((String[]) any())).thenReturn(true);

        // 调用被测方法
        List result = service.readFromCsv(Airport.class, filter);

        // 断言结果
        assertThat(result).isNotNull().hasSize(1);
        assertThat(result.get(0).getId()).isEqualTo(101);
        assertThat(result.get(0).getCode()).isEqualTo("DK");
    }
}

注意事项:

  • 这种方法通过创建测试专用的子类,完全控制了 getFileName()、getColumns() 和 getData() 的行为,无需使用 Mockito.spy。
  • getData() 的重写同样是关键,它确保了 readFromCsv() 最终返回的是我们预设的模拟数据,从而避免了实际的文件解析过程。
  • 这种方法的好处是测试代码更加清晰,没有运行时模拟的开销,且更符合面向对象的设计原则(通过继承来改变行为)。
  • 同样,mock_airports.csv 文件仍然需要在 src/test/resources/data/ 目录下存在,以便 new ClassPathResource 不会抛出异常。

总结与最佳实践

测试抽象类中的非抽象方法,同时控制其抽象方法的行为,是单元测试中的

相关专题

更多
spring框架介绍
spring框架介绍

本专题整合了spring框架相关内容,想了解更多详细内容,请阅读专题下面的文章。

104

2025.08.06

spring boot框架优点
spring boot框架优点

spring boot框架的优点有简化配置、快速开发、内嵌服务器、微服务支持、自动化测试和生态系统支持。本专题为大家提供spring boot相关的文章、下载、课程内容,供大家免费下载体验。

135

2023.09.05

spring框架有哪些
spring框架有哪些

spring框架有Spring Core、Spring MVC、Spring Data、Spring Security、Spring AOP和Spring Boot。详细介绍:1、Spring Core,通过将对象的创建和依赖关系的管理交给容器来实现,从而降低了组件之间的耦合度;2、Spring MVC,提供基于模型-视图-控制器的架构,用于开发灵活和可扩展的Web应用程序等。

389

2023.10.12

Java Spring Boot开发
Java Spring Boot开发

本专题围绕 Java 主流开发框架 Spring Boot 展开,系统讲解依赖注入、配置管理、数据访问、RESTful API、微服务架构与安全认证等核心知识,并通过电商平台、博客系统与企业管理系统等项目实战,帮助学员掌握使用 Spring Boot 快速开发高效、稳定的企业级应用。

68

2025.08.19

Java Spring Boot 4更新教程_Java Spring Boot 4有哪些新特性
Java Spring Boot 4更新教程_Java Spring Boot 4有哪些新特性

Spring Boot 是一个基于 Spring 框架的 Java 开发框架,它通过 约定优于配置的原则,大幅简化了 Spring 应用的初始搭建、配置和开发过程,让开发者可以快速构建独立的、生产级别的 Spring 应用,无需繁琐的样板配置,通常集成嵌入式服务器(如 Tomcat),提供“开箱即用”的体验,是构建微服务和 Web 应用的流行工具。

34

2025.12.22

Java Spring Boot 微服务实战
Java Spring Boot 微服务实战

本专题深入讲解 Java Spring Boot 在微服务架构中的应用,内容涵盖服务注册与发现、REST API开发、配置中心、负载均衡、熔断与限流、日志与监控。通过实际项目案例(如电商订单系统),帮助开发者掌握 从单体应用迁移到高可用微服务系统的完整流程与实战能力。

114

2025.12.24

软件测试常用工具
软件测试常用工具

软件测试常用工具有Selenium、JUnit、Appium、JMeter、LoadRunner、Postman、TestNG、LoadUI、SoapUI、Cucumber和Robot Framework等等。测试人员可以根据具体的测试需求和技术栈选择适合的工具,提高测试效率和准确性 。

436

2023.10.13

java测试工具有哪些
java测试工具有哪些

java测试工具有JUnit、TestNG、Mockito、Selenium、Apache JMeter和Cucumber。php还给大家带来了java有关的教程,欢迎大家前来学习阅读,希望对大家能有所帮助。

299

2023.10.23

毒蘑菇显卡测试网站入口 毒蘑菇测试官网volumeshader_bm
毒蘑菇显卡测试网站入口 毒蘑菇测试官网volumeshader_bm

毒蘑菇VOLUMESHADER_BM测试网站网址为https://toolwa.com/vsbm/,该平台基于WebGL技术通过渲染高复杂度三维分形图形评估设备图形处理能力,用户可通过拖动彩色物体观察画面流畅度判断GPU与CPU协同性能;测试兼容多种设备,但中低端手机易卡顿或崩溃,高端机型可能因发热降频影响表现,桌面端需启用独立显卡并使用支持WebGL的主流浏览器以确保准确结果

2

2026.01.21

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
最新Python教程 从入门到精通
最新Python教程 从入门到精通

共4课时 | 9.5万人学习

Rust 教程
Rust 教程

共28课时 | 4.6万人学习

Git 教程
Git 教程

共21课时 | 2.8万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号