资讯动态

DolphinDB单元测试实战:轻量框架设计与断言封装

发布时间:2026/9/9 15:56:48 来源:尧图企业网站定制
DolphinDB 这名字做量化或者搞物联网时序数据的同学应该不陌生。它是一个高性能分布式时序数据库内置了类似 SQL 的分析语言很多策略脚本、因子计算、数据清洗任务都直接跑在上面。但问题也随之而来逻辑越来越复杂函数越写越多改一个公共模块你到底怎么确认它没把别的地方改坏我见过不少团队DolphinDB 脚本一堆全靠线上跑批报错来反馈质量问题代价极高。与其等生产环境出事不如老老实实把单元测试补上。这篇内容不是讲理论是我把 DolphinDB 脚本测试从零搭起来、踩完坑之后的完整记录。你会看到一个能真正跑起来的轻量测试框架设计、一批能直接抄的断言写法以及围绕 I/O 和分布式表这种特殊场景的测试思路。适合刚接触 DolphinDB、被脚本调试折磨过的人也适合想系统性提升数据脚本质量的团队参考。1. DolphinDB 单元测试整体设计思路1.1 为什么不能直接照搬 JUnit 或 pytest很多人上来就想找一个类似 JUnit 的 DolphinDB 官方测试框架但实际用下来会发现DolphinDB 的脚本语言和 Java、Python 的测试生态差距很大。它没有内置的 test runner也没有被广泛使用的第三方断言库。你写一个函数想验证它返回结果对不对最原始的方式就是在命令行里手动执行几条语句眼睛瞟一眼输出。这种方式在脚本只有几十行的时候没什么问题但一旦因子库超过几百个函数纯粹靠肉眼检查就是灾难。我之前试图把 Python 的 pytest 风格硬套到 DolphinDB 上写了各种 fixture 和 setup/teardown结果发现这套思路走不通。原因有两个一是 DolphinDB 脚本的执行环境和 Python 进程隔离的概念不同DolphinDB 的会话session本身就是有状态的你很难像 pytest 那样做到完全的用例隔离二是 DolphinDB 脚本语言本身没有强类型约束和编译期检查很多错误要跑到那一行才炸出来。所以更务实的方案不是一个重量级的测试框架而是一套“轻量断言 结构化输出 统一入口”的测试脚本规范。1.2 我的目标一套能跑、能报错、能进 CI 的测试方案在设计这套方案之前我先明确了自己到底要什么。第一测试用例要能独立运行任何一个用例失败我必须能快速定位到是哪个函数、哪条数据、哪个预期结果出了问题。第二跑完一轮测试后我要能拿到一个清晰的汇总最好能直接对接后续的持续集成CI流程。第三DolphinDB 的特殊数据类型和分布式表操作必须被覆盖到不能只测无状态计算函数。基于这三个目标我最终选择了“一个测试主控脚本 N 个测试模块 统一报告输出”的组织方式。每个测试模块对应一个被测模块模块内每个函数是一个测试用例。主控脚本负责遍历执行所有测试模块收集每个用例的执行结果、耗时、错误信息最后按统一格式打印出来。这套方案的好处是零依赖不需要额外安装任何插件只要 DolphinDB 能跑 .dos 脚本测试就能跑。后面我会把整个框架代码展示出来。2. 断言体系和测试框架的搭建2.1 从 assert 到预期值与实际值的拆解DolphinDB 内置了一个assert函数格式是assert(condition, message)。条件为假时抛出异常并附带自定义的错误消息。这个函数是所有测试断言的基础但它的问题在于信息量太少。你assert(12, test failed)报错只会告诉你 test failed但实际值是多少预期值是多少没有上下文你还是得回脚本里打日志。所以我推荐自己封装一层断言函数核心思路是把“断言”拆成“实际值”和“预期值”两个概念。比如封装一个assertEq(actual, expected, msg)内部调assert但在失败信息里拼接上 actual 和 expected 的具体内容。这里有个很关键的点DolphinDB 的变量在拼接进字符串时要用string()函数显式转换否则遇到 NULL 值或特殊类型会输出一堆看不懂的编码。这个坑我踩过后面会细说。2.2 浮点数测试到底该不该用等号做量化的人天天跟浮点数打交道。你计算一个收益率一个价格均值结果很容易出现 0.30000000000000004 这种经典误差。在单元测试里直接用比较浮点数几乎是必挂的。我见过有同事因为这个问题直接把测试用例的断言全部改成print这就失去了自动化测试的意义。正确的做法是定义一个assertAlmostEq(actual, expected, epsilon)比较时取两个数的绝对误差。epsilon 给多大取决于业务场景。比如回测年化收益率误差在 1e-6 以内就算通过但如果是价格计算可能需要 1e-8 甚至更小。不能一个 epsilon 走天下这个需要团队内部约定。如果对比的是 vector还要先判断长度是否一致再逐个元素比较。DolphinDB 里还能用each或循环来实现但性能差异不大测试场景没必要过度优化可读性优先。2.3 手写一个 100 行以内的测试框架核心不依赖第三方库的情况下我写了一个大约 90 行的测试框架脚本核心就三件事注册用例、执行用例、汇总报告。注册用例我用了一个 vector 来存函数名每个测试模块末尾手动调用registerTest(testFunctionName)。执行用例时用run函数动态调用run是 DolphinDB 内置的根据字符串函数名执行函数的方法。每个用例包在try-catch里成功则统计耗时失败则捕获异常信息。汇总报告我设计了三个维度总用例数、通过数、失败数。每个失败用例要打印完整堆栈包括具体是哪个用例、哪一行断言失败、实际值和预期值各是多少。为了可读性我还给每个用例加了一个标签比如[module_name] testFunctionName passed这样 CI 日志里能直接按模块 grep。下面给出这段核心代码的简化版去掉了一些业务相关的细节方便你理解整体结构。// test_framework.dos testcaseNames [] testcaseCount 0 testcasePassed 0 testcaseFailed 0 def registerTest(name) { testcaseNames.append!(name) testcaseCount 1 } def runTestCase(name) { try { startTime now() run(name) cost now() - startTime testcasePassed 1 print([PASS] name (cost string(cost) ms)) } catch (ex) { testcaseFailed 1 print([FAIL] name) print( ex) print( ex.stackTrace()) } } def runAllTests() { print( DolphinDB Unit Test Start ) for (name in testcaseNames) { runTestCase(name) } print( DolphinDB Unit Test End ) print(Total: string(testcaseCount) , Passed: string(testcasePassed) , Failed: string(testcaseFailed)) }这段框架代码没有太多高深的地方但有几个细节值得注意。now()返回的是纳秒级时间戳相减后得到的是耗时我统一转成毫秒输出方便对比性能。异常对象ex直接放进字符串时会包含对用户友好的错误描述但 stackTrace 才是定位问题真正的钥匙所以两个都打印。如果后续希望测试失败时让进程返回非零退出码可以在runAllTests最后加一个if (testcaseFailed 0) exit(1)这样 CI 系统能感知到失败状态。3. 实际测试场景实现从函数计算到数据写入3.1 无状态函数测试拿一个真实的因子计算开刀有了框架就得拿真实业务场景练手。我挑了一个最常见的因子计算函数做例子它计算“过去 N 日收益率标准差”也就是波动率。这个函数接收一个价格向量和回看窗口大小返回对应的滚动标准差向量。写好函数后我构造了一组已知结果的价格数据手工算出预期结果然后用assertAlmostEq来做断言。这里有个数据构造的细节DolphinDB 的fixedLengthVector和普通 vector 在测试时行为一样但创建时要注意类型。价格我统一用DOUBLE类型避免INT类型精度问题。测试时我先构造了 5 个价格点窗口为 3那么前两个位置按业务约定返回 NULL后三个位置有实际数值。我的断言不仅检查计算结果还同时检查前面两个位置是否为 NULL这能提前发现“索引错位”这类隐蔽问题。// test_volatility.dos def calcVolatility(prices, window) { // 业务实现这里省略细节 } def test_calcVolatility_basic() { prices [10.0, 10.5, 10.3, 10.8, 11.2] window 3 result calcVolatility(prices, window) // 前两个位置应为 NULL assertIsNull(result[0], first value should be NULL) assertIsNull(result[1], second value should be NULL) // 后续数值手工计算后用 assertAlmostEq 检查 assertAlmostEq(result[2], 0.2494, 1e-3, volatility at position 2) assertAlmostEq(result[3], 0.2449, 1e-3, volatility at position 3) } registerTest(test_calcVolatility_basic)为什么手工计算预期值这么重要因为如果你用同一个函数中间过程的值来验证函数自身那永远测不出 bug这叫“用自己验证自己”。手工构造数据、手工推导结果即使算得慢一点也是测试的核心价值。实际工作中我一般会让写策略的人和写测试的人分开构造预期避免思维惯性。3.2 有状态代码测试会话变量污染是最隐蔽的坑DolphinDB 的会话是有状态的你在控制台定义一个变量后续执行的所有脚本都能看到它。单元测试里最怕的就是这种状态泄漏。比如你在test_a里定义了一个叫price的变量test_b里没定义但直接用了price它不会报错而是读取了test_a留下的残影导致test_b的测试结果完全失真。这个坑让我排查浪费了整整一个下午。为了解决这个问题我给自己定了一条铁律每个测试函数内部用到任何变量必须先定义或先赋值绝不依赖测试框架之外的全局状态。同时测试函数的命名要有明确前缀比如test_开头方便主控脚本扫描也让全局变量泄漏的排查变得更容易。万一你确实需要测试一些带状态的操作比如数据库连接、分布式表写入可以单独设计集成测试模块并且明确标注“需要完整环境”不要和无状态测试混在一起跑。3.3 数据库写入与读取的回环校验DolphinDB 的核心场景离不开数据库读写。单测数据库操作比纯函数计算复杂因为涉及磁盘、内存、分布式节点。我采用的模式是“临时表 写入 - 读取 - 比对 清理”的回环方案。测试开始前先创建一个带唯一后缀的临时表比如test_temp_20250320_01写入一批已知数据然后重新读取比对内容是否与预期一致最后无论测试是否通过都要在finally或catch里清理表。如果清理遗漏跑几次测试后测试环境就会堆满垃圾表这个问题在团队协作时尤其明显。举一个具体的例子测试一个批量写入函数writeTradeData(tbl, dbPath, tableName)。我会构造一个 10 行的 DataFrame写入分布式库再查询出来对比行数和关键字段。写入成功后用select count(*)获取行数再用select *抽查某一行具体值。如果有任一环节不一致打印完整的预期表与实际表。这个做法的挑战在于分布式表的可见性写入分布式表后查询可能因为节点间数据同步延迟而不一定能立即看到结果。遇到这种情形可以加一个sleep(1000)等一秒再查测试环境中通常够用。// test_write_trade_data.dos def test_writeTradeData_basic() { dbPath dfs://test_trade_db tableName trade_test_20250320 // 如果库或表已存在先删除幂等准备 if (existsTable(dbPath, tableName)) { dropTable(dbPath, tableName) } if (!existsDatabase(dbPath)) { createDatabase(dbPath, RANGE, [2020.01.01, 2021.01.01, 2022.01.01, 2023.01.01]) } inputData table(1..10 as id, rand(100.0, 10) as price) writeTradeData(inputData, dbPath, tableName) loaded loadTable(dbPath, tableName) cnt exec count(*) from loaded assertEq(cnt, 10, row count should be 10) firstRow select * from loaded limit 1 assertEq(firstRow.id[0], 1, first id should be 1) } registerTest(test_writeTradeData_basic)这段代码里有几个点值得说一说。existsTable和existsDatabase是 DolphinDB 的元数据函数用来判断对象是否存在。测试用例必须做到可重复执行所以写了幂等清理逻辑。rand生成的 10 个价格虽然是随机的但因为只是测试逻辑不测具体数值只测行数与首行 ID所以没问题。实际测试中我会把随机数种子固定下来例如rand(100.0, 10, seed42)保证每次测试的数据一致这样出现问题才方便复现。4. 测试执行与 CI 集成的实操细节4.1 命令行跑测试与退出码控制DolphinDB 提供了命令行执行模式你可以在 shell 里执行dolphindb -script test_all.dos。要让测试脚本的结果能够被 Jenkins 或 GitLab CI 识别最关键的一点就是退出码。刚才框架里提到如果失败用例数大于 0最后调用exit(1)否则exit(0)。如果没有这个逻辑即使所有用例失败DolphinDB 进程的退出码也是 0CI 会认为构建成功这是自动化测试的致命伤。我在 test_all.dos 主控脚本里除了执行runAllTests()还会捕捉任何未被框架捕获的异常。比如某个测试模块因为语法错误根本加载不出来这种情况不在用例框架的 try-catch 范围里但会导致整个脚本中断。所以我用try-catch包住模块的加载过程一旦失败打印清晰的错误信息并设置全局失败标志最终同样影响退出码。4.2 Jenkins 与 GitLab CI 中的流水线配置如果你用的是 GitLab CI一个.gitlab-ci.yml的片段大概长这样dolphindb-test: stage: test script: - /opt/dolphindb/dolphindb -home /opt/dolphindb -script test/test_all.dos artifacts: when: always paths: - test/report.txt这里有一个细节DolphinDB 很多情况下在容器里运行需要额外配置 license 和内存参数。如果你在 CI 环境碰到Failed to initialize DolphinDB之类的错误大概率是 license 文件路径变化或内存分配参数没设置。我建议在 CI 脚本里显式传入-home参数指定一个包含 license 的独立目录不要依赖环境变量。测试报告我直接使用 DolphinDB 的writeText函数输出到test/report.txtCI 系统会把它当作 artifact 归档方便后续查看。4.3 测试数据与生产数据隔离的规范性单元测试的数据绝不能写到生产数据库里这是一个基本的职业素养。我在测试框架里用一个统一的测试库前缀test_约束所有临时对象。代码审查时只要看到测试脚本中出现生产库表的名字直接打回。规范的约束能避免很多低级事故尤其是多人协作时。另外测试库建议使用独立的存储路径有条件的团队直接用独立的 DolphinDB 实例成本不高但隔离效果最好。每次跑完测试报告里除了通过/失败信息还应包含测试库表清理的结果。我把清理动作也注册成测试用例名字叫test_cleanup_temp_tables这个用例只负责扫出所有test_前缀的表并删除然后断言没有残留。它往往能暴露一些测试用例忘记清理导致的问题。5. 常见问题与排查技巧实录5.1 浮点数误差与 NULL 导致的断言失误浮点数问题前面已经提过这里再说一个更隐蔽的变种。假设你要比较两个向量一个来自分布式表查询结果一个是手工构造的预期值。即使数值在数学上相等由于存储顺序或类型不同也可能返回 false。我建议养成一个习惯比较前先float()或double()统一类型。NULL 的情况也值得留意DolphinDB 中 NULL 参与的结果不是 false而是 NULLassert(NULL NULL)会直接抛异常。所以必须写一个独立的assertIsNull函数不能用判断空值。问题现象解决方案浮点数比较失败0.1 0.2 ! 0.3使用assertAlmostEq自定义 epsilonNULL 参与断言assert(NULL NULL)抛异常使用assertIsNull单独判断类型不一致比较查询结果与手工构造结果不相等比较前统一转为相同数据类型向量长度不一致循环比较时下标越界先比对长度再逐元素比较5.2 测试函数不生效为什么 register 了却不执行这种情况多半是函数的定义和注册不在同一个模块或者函数名写错了。DolphinDB 的run函数只能执行当前会话中可见的函数。如果测试模块没有成功加载到会话注册信息也就不存在。我的排查顺序是先看主控脚本里的load或者include是否成功再看测试模块末尾有没有打印一行Loaded module xxx的确认日志最后检查函数名是不是多了空格或特殊字符。DolphinDB 对大小写敏感TestFunc和testfunc是两个完全不同的函数这一点很容易被忽略。还有一个我踩过的坑模块文件后缀。DolphinDB 的模块文件常见后缀是.dos但有些人用.txt或.dolphindb在主控脚本里加载时后缀没写对静默失败。加载模块后可以用names()函数查看当前会话中已定义的函数列表快速确认注册是否生效。5.3 性能太慢测试跑一轮要半小时怎么办测试跑得慢根源通常不是断言本身而是大量数据库写入和查询操作。在设计测试用例时数据量要尽可能小但不能小到失去代表性。比如测试分布式表写入10 行数据就能验证逻辑完全没必要生成 100 万行。另一个优化是并行执行测试模块。DolphinDB 支持submitJob提交并行任务可以把相互独立的测试模块丢到不同线程执行。但这需要额外处理测试报告的并发写冲突我给每个测试模块独立写报告文件最后再合并就绕开了锁竞争的问题。如果测试套件里有大量 I/O 密集操作还可以考虑把测试库放到 SSD 上实测能带来 5 到 10 倍的耗时缩减这比优化代码逻辑有效得多。还有一个容易被忽略的点DolphinDB 默认情况下每个会话占用的内存有限如果测试数据累积没释放越跑到后面越慢。我在测试框架里每跑完一个测试模块就执行一次undef(all)清理变量保持内存清爽。5.4 分布式表查询结果顺序不稳定DolphinDB 的分布式表在查询时如果不带order by返回顺序不保证和写入顺序一致。我第一次写分布式表测试时天真地以为查询结果的行序就是写入行序结果断言时偶发失败。这个问题的解决方案很直接任何涉及顺序的比对查询时显式加order by主键字段如果只是验证集合中存在某个值用in或distinct规避顺序问题。这条规则我写在团队测试规范里基本再没遇到顺序导致的偶发失败。5.5 日志输出不直观失败信息看不懂断言失败后如果只看到assert failed没有上下文你会怀疑人生。所以我在封装断言时强制要求传入 message 参数并且 message 里要包含业务含义。比如测试波动率函数时不能只写“value incorrect”要写“volatility at position 2 should be 0.2494, got 0.3”。为了做到这一点assertAlmostEq内部会自动拼接这个信息不需要每个用例手写。这份报告的质量直接决定了你排查问题的速度值得在这里花功夫。提示所有断言函数的第一个参数我统一约定为“实际值”第二个为“预期值”。这样阅读失败信息时左边是真实计算出的结果右边是期望结果一眼就能看出差别方向。如果反着传失败信息就会误导排查方向团队内部必须统一约定。这套方案在团队落地后的几点体会从个人写测试脚本到团队统一测试规范最大的变化不是代码量而是思考方式。当你开始为每个核心函数写断言就会被迫思考它的输入边界、异常分支和返回格式这比测试本身更能提升代码质量。我在实际项目中因为写单测而发现了一个隐藏在收益率计算函数中的索引越界 bug那个函数在生产环境已经跑了三个月影响范围比想象中要大。DolphinDB 的单元测试生态确实不如主流编程语言成熟但换个角度看这反而是机会。只要你愿意多写几十行封装代码就能搭建出一套贴合自己业务、零外部依赖的测试体系而这个体系带来的稳定性回报是长期的。这套轻量框架我们一直用到现在并且持续在扩展后面还打算把覆盖率统计加进去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价