尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

DolphinDB单元测试实战:从断言到CI集成,打造稳健的时序数据计算

DolphinDB单元测试实战:从断言到CI集成,打造稳健的时序数据计算 1. 为什么数据库脚本也要写单元测试聊聊DolphinDB开发的真实痛点先说一个我自己的经历。前两年在做一套基于DolphinDB的量化因子计算平台核心逻辑全写在存储函数里。当时大家约定俗成的做法是写完函数直接跑一遍历史数据看看结果大概对就行。结果有一次修改了一个公共因子函数加了两个参数进去改完当天所有策略的结果曲线都正常但到了第二周做归因分析时才发现因子权重在某个边界场景下算错了——原因很简单参数默认值在重构时悄悄变了而所有下游调用都用了默认参数等于整个平台跑在一套错误的计算逻辑上。那次事故之后我开始认真研究DolphinDB的单元测试方案。和Java、Python生态里JUnit、pytest这种开箱即用的测试框架不同DolphinDB作为一个时序数据库加计算引擎官方虽然提供了test相关的函数和方法但整个社区的实践案例非常少文档也比较零散。更麻烦的是数据库脚本测试有个天然的心理门槛大多数人觉得SQL写对了就是对了没必要再套一层测试代码。但恰恰是这种心态让数据计算逻辑里的隐性Bug成了最大的风险源。这篇文章不会去复述官方文档里那点函数签名而是把我实际搭建一套DolphinDB单元测试体系的过程完整写出来。从最基础的断言怎么写到怎么组织测试文件、怎么集成到CI流程再到那些不踩一次坑根本不会注意到的细节。无论你是刚接触DolphinDB还是已经在生产环境跑了好几个项目这篇文章应该都能给你一些可以直接抄走的思路。和普通编程语言的单元测试相比DolphinDB的测试有几个完全不同的问题要处理。第一DolphinDB脚本通常强依赖数据库环境很多函数直接读写表对象不像Java可以随便mock一个对象第二时序数据天然有顺序和时间的敏感性测试数据构造比普通业务数据复杂第三DolphinDB脚本是解释执行运行时的错误信息往往不如编译型语言直观定位问题需要额外的手段。这些痛点不解决测试写得再多也只是自欺欺人。下面我就按自己搭建这套测试体系的实际顺序一层层讲清楚。2. 测试前的关键设计测试库与测试数据的隔离策略2.1 为什么必须用独立的测试库而不是直接连生产库这个问题听起来像废话但实际项目里真有人图省事直接连开发库跑测试。我自己第一次写DolphinDB测试时也干过这种事测试用例里直接读取线上正在用的因子表点运行看着结果通过就觉得万事大吉。这么做的后果是灾难性的。第一测试结果受数据变化影响同样的用例今天跑过明天可能就挂了但你根本分不清是代码逻辑变了还是底层数据变了第二测试用例里如果涉及写入、更新、删除操作会对开发库的真实数据造成污染一旦测试逻辑有Bug误删数据连恢复都很麻烦第三如果你跑的是性能测试高频读写还会拖慢其他同事的查询。正确做法是在DolphinDB里单独建一个测试库。我常用的建库逻辑是这样// 测试库路径dfs://test_unit login(admin, 123456) if (!existsDatabase(dfs://test_unit)) { db database(dfs://test_unit, VALUE, 2020.01.01..2023.12.31) }注意这里的分区方式。对于测试库来说时间分区不一定是最优选完全取决于你测试的代码逻辑。如果是针对某个特定业务场景的测试建议按照业务ID做HASH分区这样构造数据时更灵活如果测试的代码本身就涉及时间序列处理那就用时间分区但分区粒度要小一点避免每次测试都要跨全部分区扫描。我自己的经验是测试库尽量只用一张主表所有测试用例都往这张表里灌数据。好处是字段结构统一构造测试数据的helper函数不用反复重写坏处是不同用例之间可能互相干扰。这个问题后面讲用例隔离时再细说。2.2 测试数据的构造方法手工写死 vs 脚本生成构造测试数据是整个DolphinDB单元测试里最容易被低估的一步。很多人在测试库建完后就直接手写数据data table( 1..10 as id, rand(100.0, 10) as val, take(buy, 10) as side )这种写法在Demo里没问题但真实项目里几类数据是不能靠rand的边界值比如极小的数、极大的数、空值、NULL、NaN时间类数据跨月、跨年、闰年、时区边界、时间戳精度边界字符串类数据空字符串、超长字符串、特殊字符、中文编码正确的做法是把测试数据构造封装成helper函数按场景批量生成。举个例子如果你要测试一个因子计算的函数它的输入是某只股票过去N天的分钟K线那么你的数据构造函数大概长这样def prepareKLineData(code, startDate, endDate, barCount) { // 基于日期区间生成分钟级时间戳 ts interval(2023.01.04 09:30:00.000, 2023.01.04 15:00:00.000, 1000) // 再搭配随机价格、成交量等字段 ... }这样的好处是测试用例里只需要指定股票代码和时间范围就能得到一套可预期的、稳定的输入数据不用每个用例都写一大段table(...)。2.3 测试用例之间的数据隔离事务和表级清理DolphinDB脚本执行时如果没有显式开启事务一条语句的失败可能会导致部分写入生效。这就引出一个非常恶心的现实问题测试A往主表里插了10条数据测试B跑的时候发现数据多了10条结果不对。我的解决思路是分层处理。简单场景下每个测试用例跑完后强制清理自己写入的数据def cleanupData(code) { t loadTable(dfs://test_unit, mainTable) t.delete!(sql(, t, code code )) }复杂场景下直接把表drop掉重建。虽然慢一点但绝对干净而且测试用例之间彻底互不影响。考虑到DolphinDB的建表速度其实不慢我后来更多采用这种做法。提示对于涉及分布式表的测试data的可见性还受DolphinDB事务提交时机的影响。如果你在存储过程里写数据但不主动commit测试里读到的可能是旧快照。遇到这种诡异问题时先确认数据和读取是否在同一个会话里。3. DolphinDB单元测试的函数选型assert家族与自定义断言3.1 官方assert函数的用法和局限性DolphinDB内置的断言函数主要有assert、eqObj、eqFloat这几个。它们的用法很简单assert condition, error message如果condition为false会抛出异常并打印error message。eqObj用来比较两个对象是否相等eqFloat用来比较浮点数。但直接用这些函数写测试有两个尴尬的地方。第一错误信息不够友好。比如eqObj比较两个表内容时它只会告诉你两个对象不相等不会告诉你具体是哪一行哪一列不一样第二断言失败时缺少上下文信息你看到一个错误堆栈时根本不知道是哪个测试用例挂的。所以我在实际项目中会对这些内置函数再做一层包装。包装后的断言函数至少要达到三个标准失败时能输出用例名称和预期值/实际值对表对象的比较能定位到具体行列对浮点数的比较支持自定义精度3.2 自定义断言让测试失败信息不再像天书下面是我实际在用的一个简化版断言包装函数def assertEq(expected, actual, msg) { if (expected ! actual) { errorMsg msg \n Expected: expected.str() \n Actual: actual.str() throw error(errorMsg) } true }别小看这个简单的包装它解决了一个非常实际的问题——报错信息可读性。DolphinDB的原生assert抛出异常时如果表达式比较长错误信息会显示一堆语法上下文真正的逻辑差异反而被淹没。包装之后一眼就能定位问题。对于浮点数比较我再加一个精度参数def assertEqFloat(expected, actual, epsilon 0.0001, msg Float mismatch) { if (abs(expected - actual) epsilon) { errorMsg msg \n Expected: expected.str() \n Actual: actual.str() \n Diff: (abs(expected - actual)).str() throw error(errorMsg) } true }这里要特别说明一下为什么浮点数比较需要epsilon。DolphinDB的浮点数计算和其他语言一样存在精度损失问题。你算一个理论值可能是0.333333但实际计算结果是0.3333329999如果直接用比较测试必挂。设一个合理的epsilon是所有数值计算类测试的共识具体阈值取决于你的业务精度要求。做量化因子计算时我一般取1e-6做偏底层的高精度统计时可能需要更小。3.3 表对象的深度比较逐行逐列定位差异DolphinDB里最常见的测试对象是表。两个表是否相等在不同语义下判断标准完全不同——字段顺序要不要管行顺序重不重要时间精度算不算差异这些都需要在断言里明确。我封装的表比较函数核心逻辑如下def assertTableEqual(expectedTb, actualTb, msg, sortByCol ) { // 1. 检查行数列数是否一致 if (expectedTb.rows() ! actualTb.rows()) { throw error(msg : Row count mismatch. Expected expectedTb.rows().str() , got actualTb.rows().str()) } // 2. 检查列集合是否一致 expCols expectedTb.columnNames().sort() actCols actualTb.columnNames().sort() if (expCols ! actCols) { throw error(msg : Column set mismatch.) } // 3. 如果指定了排序列先排序再逐行比较 if (sortByCol ! ) { expectedTb select * from expectedTb order by sortByCol actualTb select * from actualTb order by sortByCol } // 4. 逐行比较 for (i in 0:expectedTb.rows()) { for (c in expCols) { if (expectedTb[i][c] ! actualTb[i][c]) { throw error(msg : Row i.str() col c mismatch. Expected expectedTb[i][c].str() , got actualTb[i][c].str()) } } } true }这个函数看起来长但实际用起来非常顺手。尤其是第3步的排序参数在实际项目中几乎是必须的——因为数据库查询结果的行顺序通常没有严格保证两个表内容一致但顺序不同不能算测试失败。如果对这一点没有清醒认识你会在测试里被各种顺序不一致的假失败搞到崩溃。4. 测试用例的组织方式从单个脚本到分级测试集4.1 一个测试文件只测一个功能模块DolphinDB的脚本文件组织比较自由可以在gui里直接运行也可以用dolphindb控制台加参数执行。这种自由既是优点也是缺点——自由过了头测试文件就会变成一团乱麻。我建议按模块来分文件。比如一个完整的量化因子平台测试目录大概长这样tests/ common/ assertUtils.dos dataUtils.dos factor/ test_momentumFactor.dos test_volatilityFactor.dos test_liquidityFactor.dos dataProcess/ test_klineClean.dos test_adjustFactor.dos validation/ test_portfolioCalc.dos每个测试文件里针对一个功能函数写一组相关用例。比如test_momentumFactor.dos里测试的就是动量因子计算函数在不同输入下的表现。这样当某个用例失败时你能第一时间缩小排查范围不用在一堆乱七八糟的用例里大海捞针。4.2 测试用例命名规范和输出效果DolphinDB没有像JUnit那样Test注解来标记测试方法所以测试用例的组织要靠规范和约定。我的做法是在main函数里逐个调用测试用例函数并用print输出每个用例的执行状态def test_momentum_basic() { // 基本场景 ... print(PASS: momentum basic case) true } def test_momentum_emptyInput() { // 空输入场景 ... print(PASS: momentum empty input case) true } def main() { test_momentum_basic() test_momentum_emptyInput() // ... }运行结果大致长这样PASS: momentum basic case PASS: momentum empty input case FAIL: momentum NaN case配合assertEq里自定义的报错信息测试失败时能迅速定位到具体用例和具体断言。4.3 如何做测试分组冒烟测试、回归测试、全量测试测试用例数量多了之后一个很实际的问题是每次代码改动都跑全量测试时间成本太高只跑冒烟测试又怕漏掉回归问题。我在自己的项目里将测试集分了三级冒烟级只跑主流程和核心边界大概几分钟内结束每次提交代码前必跑回归级覆盖一个模块内所有关键函数和主要边界条件代码合并前跑全量级所有测试文件的所有用例发布新版本前跑实现方式也很朴素每个测试文件都单独定义main()再写一个总入口脚本用include引入所有测试文件然后按需执行include tests/factor/test_momentumFactor.dos include tests/factor/test_volatilityFactor.dos // 冒烟测试只执行特定的几个函数 test_momentum_basic() test_volatility_basic() // 回归测试执行文件里所有main这种手动控制的方式虽然不如JUnit的注解灵活但在DolphinDB的生态里已经是足够清晰可靠的做法了。5. 从零跑通一个完整测试用例以因子计算函数为例5.1 被测函数一个简单的动量因子计算器为了把整个流程串起来我写一个极简的动量因子计算函数作为被测对象。它的逻辑很简单输入某股票过去N天的收盘价计算当前价格相对N天前的涨跌幅def momentumFactor(closePrices, n) { if (closePrices.size() n) { return NULL } pastPrice closePrices[n] currentPrice closePrices[closePrices.size() - 1] return (currentPrice - pastPrice) / pastPrice }这个函数虽然业务简单但非常适合演示测试要覆盖的各种场景。5.2 手写测试用例覆盖正常、边界、异常三类输入针对这个函数我会设计如下测试用例def test_momentum_normal() { prices [10.0, 10.5, 10.8, 11.0, 11.5, 12.0] result momentumFactor(prices, 3) assertEqFloat((12.0 - 10.8) / 10.8, result, 0.0001, Normal case failed) print(PASS: momentum normal case) true } def test_momentum_nLessThanLength() { prices [10.0, 10.5, 10.8] result momentumFactor(prices, 5) assertEq(NULL, result, n larger than length should return NULL) print(PASS: momentum nlength case) true } def test_momentum_zeroDivision() { prices [0.0, 0.0, 0.0, 0.0, 1.0, 1.0] result momentumFactor(prices, 3) // 分母为0应该返回NULL或者抛异常取决于函数定义 ... }第三个用例其实暴露了被测函数的一个设计缺陷当过去价格是0时做除法直接得到无穷大这在量化计算里是应该被提前拦截的。所以这个测试用例会倒逼你去修改被测函数——这就是单元测试驱动代码改进的真实过程。5.3 完整测试脚本的运行方式和结果解读把这些用例组织到一个文件里后在DolphinDB GUI里运行该文件或在命令行用dolphindb -script test_momentum.dos执行。输出大致如下PASS: momentum normal case PASS: momentum nlength case FAIL: momentum zeroDivision case Error message: Division by zero ...堆栈信息...看到Fail时第一件事不是去改测试而是先判断是测试用例的预期设置错了还是被测函数确实有bug。在这个例子里显然是被测函数该加一个空值和零值检查。这也是单元测试黄金法则测试失败时代码和测试都有嫌疑不能默认代码是错的或者测试是错的要去看具体断言信息来定位。6. 面向实际项目的测试框架搭建目录、入口脚本与DolphinDB脚本执行的CI集成6.1 一套可直接复用的测试目录结构和入口设计在项目里形成一套固定的目录和入口约定后团队里每个人写测试时不需要思考放哪怎么跑而是直接照模板写效率和规范性都会有明显提升。我的标准结构如下projectRoot/ code/ # DolphinDB脚本主目录 factors/ momentum.dos volatility.dos dataProcess/ klineClean.dos tests/ # 测试脚本目录 common/ assertUtils.dos dataUtils.dos factors/ test_momentum.dos test_volatility.dos runAllTests.dos # 总入口脚本在runAllTests.dos中按依赖顺序引入测试文件最后统一执行。这里有个在DolphinDB中容易踩的坑include的路径问题。如果你的测试脚本使用了相对路径那么执行时的工作目录不同include会失败。稳妥的做法是把测试根目录设为一个全局变量所有include基于这个变量拼接testRoot /home/user/project/tests/ include testRoot common/assertUtils.dos include testRoot factors/test_momentum.dos // ...6.2 如何嵌入CI流程命令行运行DolphinDB脚本CI集成这块DolphinDB其实比很多数据库产品要方便因为它提供命令行执行方式dolphindb -home ./server -script ./tests/runAllTests.dos -localExec 1在Jenkins或GitLab CI里可以直接把这个命令包装成一个构建步骤。关键是怎么判断测试通过还是失败。DolphinDB脚本执行时如果脚本抛出未捕获的异常进程会以非零状态码退出。所以我们的runAllTests.dos必须保证只要有一个测试用例失败就一定抛异常不能吞掉错误。我在runAllTests.dos末尾会加一个总判断allPassed true // 执行用例时把结果累加到allPassed if (!allPassed) { throw error(Unit test suite FAILED) } print(Unit test suite PASSED)这样CI脚本才能拿到正确的退出码。6.3 我对官方测试工具的补充日志输出和并行执行思路DolphinDB的test模块里也提供了类似测试框架的函数可以自动发现测试用例并执行但我在生产项目里用得不多主要是它在错误信息展示、批量执行顺序控制上还不够灵活。除非只是写几个最基础的冒烟脚本否则我都会用自建框架。日志输出上我会把每次测试运行的输出同时写到CSV文件里方便和上次运行做diff。命令很简单output exec result from resultsTable saveText(output, /data/test_logs/run_ now().format(yyyyMMdd_HHmmss) .csv)并行执行方面DolphinDB支持submitJob来并行跑任务。但单元测试的场景我不建议一开始就并行——先有序跑通等到用例量实在太多、单次执行时间过长时再考虑把互相独立的测试文件拆到不同的worker里执行。7. 踩坑实录我在DolphinDB单元测试中遇到过的四个典型问题7.1 事务可见性存储过程里写的数据外面读不到有一次我在测试一个K线清洗函数函数内部会对分布式表做update操作。测试里调用完函数后立即去读表结果读出来的还是旧数据。排查了大半天最后发现是DolphinDB的事务模型问题——写入未提交时其他会话或后续查询看不到变化。解决方案有两种一种是在被测函数内部显式commit或endTransaction另一种是在测试用例里等待事务提交完成后再读。对于单元测试来说我推荐后者因为前者会改变被测函数的生产行为风险太大。7.2 NULL值和空表的断言陷阱DolphinDB的空表、NULL值和空字符串在不同API和上下文中的表现非常不一致。比如从一个没有数据的表里select count(*)结果可能是0但如果你select * from tbl limit 10再用rows()取行数可能返回的又是一个特殊的空对象。这些细节你写测试时几乎必踩。我的建议是对NULL和空表的比较不要直接拿返回值做而是用isNull()、size()这类显式函数来判断避免各种隐式转换的坑。7.3 分布式表的局部数据一致性DolphinDB的分布式表是按分区存储的如果测试数据横跨多个分区读取时的顺序可能和你插入时的顺序不一致。前面提到的assertTableEqual之所以要加sort参数主要就是为了应对这个问题。另外如果你在测试里删除了分布式表的某些记录要注意删除操作可能存在异步性紧接着的查询不一定立刻反映结果。7.4 include顺序的循环依赖问题当测试脚本依赖多个工具文件时很容易出现A include BB include CC又反向include A的情况。DolphinDB对循环include的报错提示比较隐晦有时表现为找不到函数而不是循环依赖。实践中我会把工具函数断言、数据构造严格和测试用例文件分离工具文件之间互不依赖从根上避免这个问题。8. 把单元测试变成团队习惯一些切实可行的管理建议技术方案讲完最后聊一点软性的东西。单元测试这件事难点从来不在写代码而在让整个团队持续地、规范地执行。我的经验是三个必须第一测试必须和业务代码同时提交。如果约定提交业务代码时可以不带测试那测试的覆盖率只会越来越低。我在团队的PR模板里直接加了一栏本次变更的测试用例没填就cherry-pick提交被打回。强制两三次后大家就习惯了。第二CI里跑测试结果必须实时反馈。测试失败的信息要直接发到群里否则就算CI里挂了也没人会去看。反馈链路越长修复的成本越高。第三测试用例的维护必须纳入日常迭代。业务逻辑变了被测函数的预期结果也会变这时候测试代码不是要删掉而是要同步更新。如果团队里出现为了让测试通过而改测试的风气必须立刻纠正——这种情况通常意味着测试本身就失去了价值。根据我个人这段时间在DolphinDB上推广单元测试的体会最大的收益反而不是抓住多少Bug而是让整个团队养成了写代码前先想清楚输入输出的习惯。当你习惯性地为每个函数搭配几个边界用例时很多设计上的含糊之处在开发阶段就暴露了根本不需要等到测试阶段。如果你所在的项目也正在用DolphinDB做复杂的计算逻辑我强烈建议你别嫌麻烦尽早把这套东西搭起来。数据脚本的错误往往不是报错而是算得不对而单元测试是唯一能在不看盘、不看策略结果的情况下帮你判断算得对不对的可靠手段。
返回列表