20、测试网络通信:Mock socket API、测试TCP/UDP协议处理逻辑

网络通信这块,说实话,是嵌入式开发里最让人头疼的测试场景之一。你想想看,你的代码要跟远端服务器握手、收发数据包,但测试环境里哪有真正的网络?总不能每次跑单元测试都去连一台真实的服务器吧?

我早年做物联网网关项目时,就吃过这个亏。当时写了一个TCP重传逻辑,自测时一切正常,结果一上真实网络,丢包率一高,重传机制直接崩了。后来复盘发现,问题就出在——我从来没在单元测试里模拟过「网络异常」这个场景。

所以,今天我们就来聊聊:如何用Mock技术,把socket API给“骗”过去,让单元测试能独立、可控地验证TCP/UDP协议处理逻辑

为什么需要Mock socket API?

直接调用真实的socket()send()recv(),有几个硬伤:

  • 环境依赖:测试必须跑在有网络、有特定端口的机器上。
  • 不可控:你没法让网络在特定时刻“恰好”丢一个包。
  • 速度慢:一次真实的TCP握手要几十毫秒,跑几千个用例得等到猴年马月。
  • 副作用:测试可能真的把数据发到生产环境去了——别笑,我见过有人干过这事。

说白了,我们要的是确定性。Mock掉socket层,让被测代码以为自己在跟网络通信,实际上是在跟一个模拟的“假网络”交互。这样,丢包、超时、乱序、连接断开……所有你想测的异常场景,都能在毫秒级复现。

Mock策略:函数指针 vs 链接替换

在C语言里Mock socket API,主流做法有两种。我个人更推荐第二种,但先都说说。

1. 函数指针(运行时替换)

send()recv()等函数声明为函数指针,测试时指向Mock实现。优点是灵活,缺点是侵入性强——你得改原有代码的结构。

2. 链接时替换(Link-time substitution)

这是我最常用的方式。编译时,把真实的socket库链接换成我们自己写的Mock库。被测代码一行不改,链接器自动把send()调用指向我们的Mock函数。

核心思路:写一个mock_socket.c,里面实现socket()bind()listen()accept()send()recv()等函数的“假版本”。测试时,链接这个文件而不是真实的libsocket

实战:Mock一个简单的TCP客户端

假设我们有这样一个TCP发送函数:

// tcp_client.c
int tcp_send_data(const char *host, int port, const uint8_t *data, size_t len) {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) return -1;

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, host, &addr.sin_addr);

    if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        close(sock);
        return -2;
    }

    ssize_t sent = send(sock, data, len, 0);
    close(sock);
    return (sent == len) ? 0 : -3;
}

我们要测试这个函数,但不想真的去连接服务器。写一个Mock文件:

// mock_socket.c
#include <stdint.h>
#include <string.h>
#include <errno.h>

// 模拟socket描述符
static int mock_sock_fd = 100;
static int connect_result = 0;      // 0表示成功
static int send_result = 0;         // 0表示成功
static ssize_t send_bytes = 0;      // 实际发送的字节数

// 测试辅助函数:设置connect失败
void mock_set_connect_fail(int fail) {
    connect_result = fail ? -1 : 0;
}

// 测试辅助函数:设置send失败
void mock_set_send_fail(int fail) {
    send_result = fail ? -1 : 0;
}

// Mock实现
int socket(int domain, int type, int protocol) {
    // 返回一个假的fd
    return mock_sock_fd;
}

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
    if (connect_result != 0) {
        errno = ECONNREFUSED;
        return -1;
    }
    return 0;
}

ssize_t send(int sockfd, const void *buf, size_t len, int flags) {
    if (send_result != 0) {
        errno = EIO;
        return -1;
    }
    // 记录发送的数据,方便后续断言
    send_bytes = len;
    return len;
}

int close(int fd) {
    return 0;
}

测试用例就可以这样写:

// test_tcp_client.c
#include "unity.h"
#include "tcp_client.h"

void setUp(void) {
    // 每次测试前重置Mock状态
    mock_set_connect_fail(0);
    mock_set_send_fail(0);
}

void test_tcp_send_data_success(void) {
    uint8_t data[] = "Hello";
    int ret = tcp_send_data("192.168.1.1", 8080, data, 5);
    TEST_ASSERT_EQUAL(0, ret);
}

void test_tcp_send_data_connect_fail(void) {
    mock_set_connect_fail(1);
    uint8_t data[] = "Hello";
    int ret = tcp_send_data("192.168.1.1", 8080, data, 5);
    TEST_ASSERT_EQUAL(-2, ret);  // 预期返回连接失败码
}

void test_tcp_send_data_send_fail(void) {
    mock_set_send_fail(1);
    uint8_t data[] = "Hello";
    int ret = tcp_send_data("192.168.1.1", 8080, data, 5);
    TEST_ASSERT_EQUAL(-3, ret);  // 预期返回发送失败码
}

小技巧:我习惯在Mock里加一个“调用计数器”。比如mock_get_send_call_count(),用来验证被测代码是否真的调用了send(),以及调用了多少次。这在测试重传逻辑时特别有用。

UDP协议处理的Mock差异

UDP和TCP的Mock思路一样,但有几个细节要注意:

  • UDP没有connect(),所以Mock里不需要模拟连接状态。
  • UDP的sendto()recvfrom()需要额外处理目标地址参数。
  • UDP的“丢包”模拟更简单——直接让sendto()返回成功但recvfrom()不返回数据即可。

我曾经在一个项目中,需要测试UDP的“心跳保活”机制。心跳包每5秒发一次,如果连续3次没收到回应就切换备用通道。用Mock模拟丢包,我只需要让recvfrom()在前3次调用时返回-1(超时),第4次才返回正常数据。整个测试跑下来不到100毫秒,而如果真用网络测,至少得等15秒。

SVG:Mock socket测试架构图

Mock Socket API 测试架构 被测代码 (TCP/UDP逻辑) 调用 socket / send / recv 链接时替换:链接 mock_socket.o 而非 libsocket Mock Socket 实现 (模拟连接、丢包、超时、数据注入) 测试用例 测试辅助函数 mock_set_connect_fail() mock_get_send_call_count()

避坑指南:我曾经踩过的三个坑

坑一:忘记重置Mock状态

我曾经有一次,测试A用例设置了mock_set_connect_fail(1),但测试B用例的setUp()里忘了重置。结果B用例莫名其妙地失败了,排查了半小时才发现是Mock状态污染。所以,每个测试用例的setUp()里,一定要把所有Mock状态恢复到默认值

坑二:Mock函数签名不一致

有一次我Mock的send()函数,参数flags的类型写成了int,但真实系统里是unsigned int。链接时没报错,但运行时传参错位,数据全乱了。我的教训是:从系统头文件里把函数声明原封不动复制过来,不要手打

坑三:忽略了errno的模拟

很多网络错误码是通过errno传递的。如果你的被测代码会检查errno(比如EAGAINECONNRESET),那Mock里必须正确设置errno。我见过有人Mock的recv()返回-1但不设置errno,结果被测代码读到一个垃圾值,行为完全不可预测。

总结一下

Mock socket API的核心,就是用可控的假实现替换不可控的真网络。链接时替换是最干净的方式,不污染被测代码。UDP和TCP的Mock思路一致,但要注意UDP没有连接态,丢包模拟更直接。

嗯,最后说一句:别觉得Mock是“作弊”。恰恰相反,能把网络层Mock好,说明你对协议的理解足够深——你知道哪些行为是协议规定的,哪些是网络环境带来的。这才是真正的测试功底。


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