
本文探讨了在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() 方法。
解决方案一:使用 Mockito.spy 进行局部模拟
Mockito.spy 允许我们对一个真实对象进行“监视”或“部分模拟”。这意味着对象的大部分行为仍然是真实的,但我们可以选择性地模拟其某些方法。这非常适合我们的场景,因为我们希望 AirportService 的大部分逻辑保持不变,只模拟 getFileName() 方法。
创建 Spy 对象: 将 setup() 方法中的 service 实例化方式改为 Mockito.spy(new AirportService())。
模拟特定方法: 使用 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() 方法,使其返回一个虚拟或指向测试资源的路径。
创建内部测试类: 在测试类内部定义一个静态嵌套类,它继承自 AirportService。
重写抽象方法: 在这个测试子类中,重写 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 不会抛出异常。
总结与最佳实践
测试抽象类中的非抽象方法,同时控制其抽象方法的行为,是单元测试中的










