测试文件I/O:模拟文件系统、使用临时文件进行读写测试

文件I/O测试,说实话,是很多嵌入式工程师容易忽视的一块。大家总觉得读写文件嘛,跑一遍不就知道了?但我在项目中吃过不少亏——有一次,一个文件写入的bug在开发环境死活复现不了,到了客户现场,文件系统一满,程序直接崩溃。从那以后,我对待文件I/O测试的态度就变了。

今天咱们聊聊怎么系统地测文件I/O。核心思路就两条:要么模拟文件系统,要么用临时文件。两条路各有各的玩法。

为什么文件I/O测试这么特殊?

文件I/O测试的难点在于——它依赖外部环境。你想想看,测试代码跑在开发机上,文件系统是PC的;但程序最终跑在嵌入式设备上,文件系统可能是FAT32、LittleFS或者SPI Flash上的裸分区。环境不同,行为就可能不同。

我个人的习惯是:能模拟就模拟,不能模拟就用临时文件隔离。这样测试既快又可靠,不会因为磁盘满了或者权限问题导致测试失败。

方案一:模拟文件系统

模拟文件系统,说白了就是自己写一套假的文件操作接口。把fopen、fread、fwrite这些函数替换成内存操作。这样测试跑起来飞快,而且完全可控。

来看一个简单的例子。假设我们有这样一个文件读写函数:

// 原始代码:从文件读取配置
int read_config(const char *path, Config *cfg) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return -1;
    
    size_t n = fread(cfg, sizeof(Config), 1, fp);
    fclose(fp);
    
    return (n == 1) ? 0 : -1;
}

要测试它,我们可以这样模拟文件系统:

// 测试代码:模拟文件系统
#include <setjmp.h>
#include <stdarg.h>
#include <stddef.h>
#include <cmocka.h>

// 模拟的fopen:从内存缓冲区读取
static FILE *mock_fopen(const char *path, const char *mode) {
    // 检查路径是否合法
    check_expected(path);
    check_expected(mode);
    
    // 返回一个模拟的文件指针
    return (FILE *)mock();
}

// 模拟的fread:从内存拷贝数据
static size_t mock_fread(void *ptr, size_t size, size_t nmemb, FILE *stream) {
    check_expected(ptr);
    check_expected(size);
    check_expected(nmemb);
    
    // 从mock数据中拷贝
    void *data = (void *)mock();
    size_t data_size = (size_t)mock();
    
    size_t to_copy = (data_size < size * nmemb) ? data_size : size * nmemb;
    memcpy(ptr, data, to_copy);
    
    return to_copy / size;
}

// 测试用例
static void test_read_config_success(void **state) {
    Config expected = {.version = 1, .baudrate = 115200};
    
    // 设置模拟行为
    will_return(mock_fopen, (FILE *)0x1234);  // 返回非NULL指针
    expect_string(mock_fopen, path, "/cfg.bin");
    expect_string(mock_fopen, mode, "rb");
    
    will_return(mock_fread, &expected);
    will_return(mock_fread, sizeof(Config));
    
    Config actual;
    int ret = read_config("/cfg.bin", &actual);
    
    assert_int_equal(ret, 0);
    assert_int_equal(actual.version, 1);
    assert_int_equal(actual.baudrate, 115200);
}

核心思路:用mock替换真实的文件操作,让测试在内存中完成。这样不需要真实的文件,测试速度极快,而且可以模拟各种边界情况——比如文件不存在、读取失败、数据损坏等。

方案二:使用临时文件

有些场景下,模拟文件系统太麻烦。比如你要测试文件系统的碎片整理、日志轮转、或者跨进程文件共享。这时候,用临时文件更实在。

我在项目中常用的做法是:每个测试用例创建独立的临时文件,测试结束后自动清理。这样测试之间不会互相干扰。

看一个用C标准库实现的例子:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 辅助函数:创建临时文件并写入数据
static FILE *create_temp_file(const char *content) {
    // 创建临时文件
    char template[] = "/tmp/test_XXXXXX";
    int fd = mkstemp(template);
    if (fd == -1) return NULL;
    
    FILE *fp = fdopen(fd, "w+");
    if (!fp) {
        close(fd);
        return NULL;
    }
    
    // 写入内容
    if (content) {
        fwrite(content, 1, strlen(content), fp);
        rewind(fp);
    }
    
    return fp;
}

// 测试用例:读取文件内容
static void test_read_file_content(void) {
    const char *test_data = "Hello, File I/O Test!";
    FILE *fp = create_temp_file(test_data);
    assert_non_null(fp);
    
    char buffer[64] = {0};
    size_t n = fread(buffer, 1, sizeof(buffer) - 1, fp);
    buffer[n] = '\0';
    
    assert_string_equal(buffer, test_data);
    
    fclose(fp);
    // 注意:这里需要删除临时文件
    // 实际项目中可以用atexit注册清理函数
}

我的经验:临时文件的路径最好用环境变量控制。比如设置TMPDIR环境变量,这样在CI环境里可以统一管理。我曾经遇到过测试在开发机上跑得好好的,上了CI就失败——就是因为临时文件路径写死了。

两种方案的对比

维度 模拟文件系统 临时文件
测试速度 极快(纯内存操作) 较慢(涉及磁盘I/O)
环境依赖 无(完全隔离) 需要文件系统支持
边界测试 容易模拟各种异常 需要构造真实场景
代码侵入性 需要设计可mock的接口 无侵入
适用场景 单元测试、逻辑验证 集成测试、系统测试

避坑指南:我踩过的几个坑

坑一:忘记清理临时文件

我曾经有一个测试套件,每次跑完留下一堆临时文件。一个月后,磁盘满了,整个CI构建都挂了。教训是:一定要用atexit或者RAII机制确保清理。在C语言里,我习惯这样写:

// 注册清理函数
static char temp_files[10][256];
static int temp_file_count = 0;

void cleanup_temp_files(void) {
    for (int i = 0; i < temp_file_count; i++) {
        remove(temp_files[i]);
    }
}

// 在main函数开始注册
int main() {
    atexit(cleanup_temp_files);
    // ... 测试逻辑
}

坑二:文件名冲突

多线程测试时,多个线程同时创建临时文件,文件名可能冲突。解决方案是用mkstemp或者tmpfile,它们能保证唯一性。我自己写测试时,永远不用硬编码的文件名

坑三:权限问题

在嵌入式环境里,文件系统可能是只读的。测试时如果没考虑到这一点,代码在开发机上跑得好好的,部署到设备上就挂了。我的建议是:在测试中至少有一个用例验证只读文件系统的行为

知识体系总览

下面这张图总结了文件I/O测试的核心思路和决策流程:

文件I/O测试核心思路 文件I/O测试 方案一:模拟文件系统 方案二:临时文件 用mock替换fopen/fread/fwrite 内存操作,速度极快 mkstemp/tmpfile创建唯一文件 测试结束自动清理 根据场景选择:单元测试用模拟,集成测试用临时文件

实战建议

说了这么多,最后给几条实在的建议:

  • 单元测试阶段:优先用模拟文件系统。速度快,可控性强,能覆盖各种边界情况。
  • 集成测试阶段:用临时文件。这时候需要验证真实的文件系统交互。
  • 永远不要:在测试中依赖特定的文件路径或文件内容。测试应该是自包含的。
  • 记得测试异常路径:文件不存在、权限不足、磁盘满、文件被锁定——这些场景在真实环境中都会遇到。

特别提醒:如果你在嵌入式环境下做测试,一定要注意文件系统的差异。比如FAT32不支持符号链接,LittleFS的写入次数有限。我见过一个团队,在Linux上测试通过了,部署到RTOS上文件写入就失败——因为RTOS的文件系统不支持同时打开多个文件句柄。

文件I/O测试,说白了就是让你的代码在各种文件系统环境下都能稳定工作。掌握了模拟和临时文件这两把刷子,大部分场景都能覆盖。嗯,今天就聊到这儿,下次咱们聊聊网络通信的测试——那又是另一番天地了。


公众号:蓝海资料掘金营,微信deep3321