dbt Tests 数据质量测试全指南:Data Engineering Zoomcamp 项目中的五层测试体系实战
【免费下载链接】data-engineering-zoomcampData Engineering Zoomcamp is a free 9-week course on building production-ready data pipelines. Join the course here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/da/data-engineering-zoomcamp
仪表盘上的 KPI 出错、报表里的数字失真——追根溯源只有两类原因:底层数据本身不符合预期,或者你的 SQL 写错了。作为分析工程师,如果无法区分是哪一种,两种都算你的责任。测试(Tests)就是让你主动掌控这一局面的手段。dbt 内置了一整套相当完整的测试体系,从最简单的单一测试到模型契约(Model Contracts)一应俱全。本文以 Data Engineering Zoomcamp 课程笔记 4_5_2_dbt_tests.md 为骨架,结合仓库中真实的taxi_rides_nydbt 项目(dbt_project.yml、各层模型的schema.yml与sources.yml),系统讲解这五类测试的适用场景、配置写法与运行命令。读完你将掌握:何时用单一测试、如何做源数据新鲜度监控、四类内置通用测试与自定义通用测试的写法、单元测试的模拟数据技巧,以及用模型契约从源头拦截 Schema 漂移的完整实战方案。
为什么测试是分析工程师的底线
在深入工具之前,先建立一个共识:错误的 KPI 和错误的报表,只有两个来源——底层数据不符合预期,或 SQL 写得有问题。如果分析工程师无法判断问题出在哪一环,两种情况下他都需要负责。测试的存在,就是把"事后发现坏数字"变成"事前拦截坏数据"。
dbt 的测试体系覆盖了数据质量生命周期的不同阶段:
| 测试类型 | 拦截的时机 | 核心作用 |
|---|---|---|
| 单一测试(Singular) | 模型已产出后 | 验证组织特有的业务规则 |
| 源数据新鲜度(Source Freshness) | 数据进入前 | 监控上游数据是否按时到达 |
| 通用测试(Generic) | 模型已产出后 | 可复用的列级质量校验 |
| 单元测试(Unit Tests) | 模型运行前 | 用模拟数据验证 SQL 逻辑 |
| 模型契约(Model Contracts) | 模型构建前 | 强制输出结构与声明一致 |
下面逐一展开。
1. 单一测试(Singular Tests):写一条 SQL,就是一条测试
单一测试是最简单的一类测试:写一条普通 SQL 查询,放进tests/目录,它就是一条测试。不需要任何 YAML 声明,dbt 会自动发现并执行。
其判定逻辑非常直白:如果这条查询返回了任何一行数据,测试就失败。也就是说,你写这条 SQL 的目的就是"专门选中那些坏数据"。返回零行,说明一切正常。
以课程笔记中的例子为例,验证车费金额永远为正:
-- tests/assert_positive_fare_amount.sql -- Fare amounts should always be positive select tripid, fare_amount from {{ ref('fct_trips') }} where fare_amount <= 0这条查询使用{{ ref('fct_trips') }}引用事实表模型。只要存在任何fare_amount <= 0的记录,dbt test就会把该测试标记为失败,并展示这些坏行。
在仓库项目中,test-paths: ["tests"]已配置在 dbt_project.yml 中,taxi_rides_ny/tests/目录就是存放这类文件的位置。虽然仓库当前没有内置单一测试文件,但这份配置已经为你的自定义规则预留好了位置。
单一测试最适合那些组织特有、没有任何通用测试能覆盖的一次性业务规则——比如"绿色出租车不允许出现某类费率组合""夜间附加费与时段不匹配"等强业务约束。
2. 源数据新鲜度测试(Source Freshness Tests):监控上游数据是否"迟到"
新鲜度测试不在单独的 SQL 文件中,而是直接定义在 source 的 YAML 里。它的作用不是校验数据内容,而是回答一个问题:上游最后一次装载数据是什么时候?这个时间戳够新吗?
用法分两步:
- 在 source 定义中为某张表指定
loaded_at_field,声明哪个字段代表"数据最近一次装载时间"; - 通过
warn_after和error_after设置两条阈值——一个用来"告警",一个用来"真正失败"。
课程笔记中的完整示例:
version: 2 sources: - name: staging database: production schema: trips_data_all tables: - name: green_tripdata loaded_at_field: lpep_pickup_datetime freshness: warn_after: {count: 6, period: hour} error_after: {count: 12, period: hour} - name: yellow_tripdata loaded_at_field: tpep_pickup_datetime freshness: warn_after: {count: 6, period: hour} error_after: {count: 12, period: hour}warn_after与error_after由count(数量)和period(时间单位,如minute、hour、day)组成:green 与 yellow 出租车数据若超过 6 小时未更新则告警,超过 12 小时则直接报错。
仓库中的真实配置在 models/staging/sources.yml:两个原始表的config块都声明了loaded_at_field(green 用lpep_pickup_datetime,yellow 用tpep_pickup_datetime),并在 source 级别统一配置了warn_after: {count: 24, period: hour}与error_after: {count: 48, period: hour}——比课程示例宽松,适合数据每日批量到达的实际节奏。
执行时使用专门的命令:
dbt source freshness该命令只做新鲜度检查,不运行模型。需要注意的是,这个阈值设定需要与你的装载调度频率匹配:如果上游每天只更新一次,24 小时告警、48 小时失败就是合理的基线。
新鲜度测试不是每个项目都会用到,但对"数据延迟就会引发真实业务问题"的管道(如实时风控、库存预警)来说,它是一道关键的防线。
3. 通用测试(Generic Tests):dbt 项目中最常用的测试类型
通用测试是 dbt 测试体系里的重头戏,也是最常见的一类。它们直接定义在模型的 YAML 中,与列描述放在一起,是参数化、可复用的:逻辑只写一次,却能应用到任意多个模型、任意多列上。
3.1 dbt 内置的四种通用测试
dbt 内置且仅有四种:
- unique—— 该列不允许出现重复值;
- not_null—— 该列不允许出现 NULL;
- accepted_values—— 该列的值必须落在给定的取值列表内;
- relationships—— 该列的每个值必须存在于另一张模型表中(参照完整性,即外键约束)。
课程笔记的完整配置示例:
version: 2 models: - name: stg_green_tripdata description: Staged green taxi data columns: - name: tripid description: Primary key for trips tests: - unique - not_null - name: vendorid tests: - not_null - name: payment_type description: Payment method code tests: - accepted_values: values: [1, 2, 3, 4, 5, 6] - name: pickup_locationid description: Taxi zone where trip started tests: - relationships: to: ref('taxi_zone_lookup') field: locationid在这个例子中:tripid被声明为主键(同时施加unique+not_null);payment_type限定了六个合法取值;pickup_locationid通过relationships校验其取值必须存在于taxi_zone_lookup模型的locationid列中。
仓库中的真实应用遍布整个taxi_rides_ny项目,可以逐层对照:
- Staging 层(models/staging/schema.yml):
stg_green_tripdata与stg_yellow_tripdata的vendor_id、pickup_datetime均配置了data_tests: [not_null]。这与 staging SQL 里的where vendorid is not null过滤逻辑(stg_green_tripdata.sql)形成呼应——先清洗再测试验证。 - Intermediate 层(models/intermediate/schema.yml):
int_trips的trip_id(代理键)配置unique+not_null;service_type配置accepted_values,只允许'Green'与'Yellow'。 - Marts 层(models/marts/schema.yml):
fct_trips的trip_id同样unique+not_null;pickup_location_id与dropoff_location_id都通过relationships关联到dim_zones.location_id——这正是星型模型中事实表对维表的外键约束。
注意,仓库中采用 dbt 1.8+ 的新写法data_tests:而非旧版tests:,并且accepted_values和relationships的配置参数放在arguments:下(详见 3.4 节),这是新版本推荐的标准写法。
3.2 编写自定义通用测试
四种内置测试不可能覆盖所有场景。你可以写自己的通用测试:它们是一段放在tests/generic/目录下的 SQL 文件,使用 Jinja 的{% test %}代码块定义,dbt 会自动发现它们并像内置测试一样使用。
课程笔记的自定义测试示例:
-- tests/generic/test_positive_values.sql {% test positive_values(model, column_name) %} select * from {{ model }} where {{ column_name }} < 0 {% endtest %}定义好之后,在 YAML 中像内置测试一样引用:
models: - name: fct_trips columns: - name: fare_amount tests: - positive_values - name: trip_distance tests: - positive_values自定义通用测试的逻辑与单一测试一致:查到坏数据即失败。区别在于它通过model和column_name参数实现复用,同一份逻辑可以挂在任意模型的任意列上。
一个值得强调的实战建议:你实际需要手写的自定义测试,可能比想象中少得多。dbt 社区已经在开源包(如 dbt-utils、dbt-expectations 等)里沉淀了大量现成的通用测试,动手之前先去看看这些包是否已有你需要的能力。
3.3 用开源包扩展测试能力:仓库中的 dbt-utils 实例
仓库项目通过 packages.yml 引入了dbt-labs/dbt_utils(版本>=1.3.0, <2.0.0)和dbt-labs/codegen,其中 dbt-utils 就提供了一批现成的高价值通用测试。
在报表层模型 models/marts/reporting/schema.yml 中,fct_monthly_zone_revenue使用了一个内置四件套无法实现的关键校验——多列组合唯一性:
data_tests: - dbt_utils.unique_combination_of_columns: arguments: combination_of_columns: - pickup_zone - revenue_month - service_typeunique_combination_of_columns验证(pickup_zone, revenue_month, service_type)这一组合在按月分区聚合的报表中不重复。任何单一列的unique都做不到这一点——同一 zone 同一月可以有绿、黄两种服务类型,只有三者组合才能唯一标识一行。这正是"先查包、再手写"的典型收益:一行 YAML 就完成了原本要写复杂窗口函数才能校验的逻辑。
同样在 seeds/seeds_properties.yml 中,payment_type_lookup种子表的payment_type列也配置了unique+not_null,说明测试体系同样适用于种子数据(参考维度表)。
3.4 版本差异要点:data_tests、arguments与require_generic_test_arguments_property
如果你对比课程笔记中的旧写法与本仓库的实际写法,会发现两处关键差异,这正是 dbt 1.8 前后语法演进的体现:
- 键名
tests:→data_tests::仓库所有schema.yml都使用data_tests:键(dbt 1.8 起引入,用于与单元测试的unit_tests:区分)。旧版tests:键仍被兼容,但新项目应优先使用data_tests:。 - 参数收纳进
arguments::旧写法将测试参数平铺在测试名下(如accepted_values: values: [...]),而仓库统一使用accepted_values: arguments: values: [...]的嵌套写法。
这种新写法之所以被强制采用,与 dbt_project.yml 中显式启用的一个 flag 直接相关:
flags: require_generic_test_arguments_property: true该 flag 要求通用测试的参数必须放在arguments属性下,否则配置会报错。当你在较新版本的 dbt 中沿用课程笔记的旧式 YAML 时,若遇到参数解析报错,第一反应就应该是检查是否需要把参数迁移到arguments:下。
4. 单元测试(Unit Tests):不碰仓库的 SQL 逻辑验证
单元测试自dbt v1.8(2024 年中发布)起可用。它与前面所有测试的本质区别在于:它完全不需要命中数据仓库的真实数据,而是用一小撮模拟输入行来验证你的 SQL 逻辑。
原理是:你先定义一组模拟输入行(mock rows)和期望输出行,dbt 把模型 SQL 跑在这份模拟数据上,再比对实际输出与你的期望是否一致。这对复杂逻辑尤其有价值——滚动窗口、正则处理、边界情况——因为你可以测试真实数据里还没出现过的场景。
课程笔记的单元测试示例——验证支付类型编码到描述的映射:
version: 2 unit_tests: - name: test_payment_type_mapping description: Test that payment type codes map to correct descriptions model: stg_green_tripdata given: - input: source('staging', 'green_tripdata') rows: - {tripid: '1', payment_type: 1} - {tripid: '2', payment_type: 2} - {tripid: '3', payment_type: 5} expect: rows: - {tripid: '1', payment_type_description: 'Credit card'} - {tripid: '2', payment_type_description: 'Cash'} - {tripid: '3', payment_type_description: 'Unknown'}结构拆解:
model:被测试的模型(stg_green_tripdata);given.input:喂给模型的模拟源数据,可以是source(...)或ref(...);given.rows:模拟输入的具体行;expect.rows:模型 SQL 处理这些输入后应产出的行。
如果模型输出的行与expect不一致,单元测试失败。注意测试中的映射关系可以在仓库 seeds/payment_type_lookup.csv 与 models/marts/schema.yml 的payment_type/payment_type_description列描述中相互印证。
使用上有两个明确约束:
- 单元测试定义在
models/目录下的 YAML 中(而非tests/目录),目前仅支持 SQL 模型; - 因为输入是静态模拟数据,没有理由在生产环境运行它们——它们属于开发与 CI 场景。
截至 2026 年初,单元测试已发布约 18 个月,采用率正在上升,尤其适合拥有复杂转换逻辑或严格数据质量要求的团队。在 CI/CD 管道中,它的价值是在错误逻辑污染生产数据之前就把它抓出来:模型还没跑,逻辑已经先验证过了。
5. 模型契约(Model Contracts):在构建前就拦住 Schema 漂移
最后一类测试与前几类有本质区别。模型契约不是事后抓坏数据,而是阻止模型在不符合既定形态时被构建——它在构建动作发生之前就发挥作用。
用法分两步:
- 在 YAML 中为模型声明期望的列名、数据类型,以及(可选的)约束;
- 在模型配置中开启
contract: enforced: true。
从那一刻起,如果模型的输出与声明不匹配——列名错误、类型不对、列缺失——dbt 会在任何物化发生之前直接报错。
课程笔记的示例:
version: 2 models: - name: fct_trips config: contract: enforced: true columns: - name: tripid data_type: string constraints: - type: not_null - type: unique - name: pickup_datetime data_type: timestamp constraints: - type: not_null - name: service_type data_type: string - name: total_amount data_type: numeric仓库中的真实应用:models/marts/schema.yml 中的fct_trips正是这样配置的——contract.enforced设为true,且为 20+ 个列逐一声明了data_type(string、integer、timestamp、numeric、bigint等)。这意味着 models/marts/fct_trips.sql 中任何一次 SELECT 的改列、改名、改类型,只要与契约不符,构建就会在物化前失败。这个增量物化模型(materialized='incremental',incremental_strategy='merge')一旦被 Schema 漂移悄悄污染,后续合并的历史数据将极难修复——契约正是为这类"改错代价高"的模型兜底。
模型契约背后的理念源于数据契约(Data Contracts):与业务方坐下来,就输出数据集应有的形态(列名、类型、新鲜度预期)达成一致,然后用契约自动强制这份约定。任何人在修改模型时破坏了约定,他立刻就会知道。
需要留意契约的一个特性:声明了data_type时,要求模型中每个列都声明类型,且约束(constraints)仅支持部分数据平台(如not_null、unique的具体支持程度因适配器而异),跨平台使用时建议先验证目标仓库的约束能力。
6. 把测试跑起来:命令、选择器与 CI 工作流
测试定义得再好,也要正确运行。课程笔记对应的命令讲解在 4_6_1_dbt_commands.md,关键命令如下:
运行全部或部分测试:
dbt test # 运行所有测试 dbt test --select fct_trips # 只测指定模型及其相关测试 dbt test --select stg_green_tripdata # 用选择器精确圈定测试范围组合运行(推荐):
dbt builddbt build是最重要的命令,它智能组合了dbt run+dbt test+dbt seed+dbt snapshot。但它的价值不只是"顺序执行一遍"——它是 DAG 感知的:它知道正确的执行顺序,如果中途某个节点失败,会跳过该失败点下游的所有内容,而不是把计算浪费在注定要失败的模型上。
失败后的重试:
dbt retry如果dbt build或dbt run中途失败,不必从头重跑整个流程。dbt retry会读取上一次运行的run_results.json,自动识别失败节点,只重跑失败节点及其下游。
新鲜度检查:
dbt source freshness仅执行源数据新鲜度检查,不触发任何模型构建。
将以上能力组合起来,一条完整的质量保障流水线就清晰了:
- 开发/PR 阶段:
dbt build全量跑通(含dbt test),配合单元测试在 CI 中提前验证复杂 SQL 逻辑; - 生产调度阶段:
dbt build按 DAG 顺序构建,任一环节数据质量不达标立即中断下游; - 定时监控:
dbt source freshness按调度频率运行,上游数据迟到即告警/失败; - 事后兜底:模型契约在任何物化动作前拦截 Schema 漂移,
dbt retry让修复后的重跑精准而高效。
开发环境与生产环境通过--target区分(开发者用dev,生产用prod),测试的严格度可以按环境差异化配置。
结语:五层测试共同构成数据质量的纵深防线
回到开篇的论断——坏数字只有两个来源,而测试让你在每一层都能区分并拦截它们:
- 源数据新鲜度确认上游按时到达(数据源问题);
- 单一测试与通用测试确认模型产出的数据符合业务与结构预期(数据内容与 SQL 正确性问题);
- 单元测试在真实数据介入前验证逻辑正确性(纯 SQL 逻辑问题);
- 模型契约在构建前强制输出结构与声明一致(Schema 漂移问题)。
五层各司其职、互为补充:通用测试管"列级质量",单一测试管"业务规则",新鲜度测试管"数据时效",单元测试管"逻辑正确",契约管"结构稳定"。对于像taxi_rides_ny这样持续增量演进的事实表、不断增加的报表模型来说,把这张纵深防线搭起来,才是分析工程师对"数字可信"最有力的承诺。
本文核心思路来源于课程笔记 4_5_2_dbt_tests.md,全部配置示例均可在仓库 dbt 项目中找到对应实现;如需继续深入,可阅读 4_6_1_dbt_commands.md 了解命令全集,或直接查阅 taxi_rides_ny 项目下的schema.yml与sources.yml逐层对照。
【免费下载链接】data-engineering-zoomcampData Engineering Zoomcamp is a free 9-week course on building production-ready data pipelines. Join the course here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/da/data-engineering-zoomcamp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考