Módulo 4: Testing Your Models
Leyendo el reporte de `dbt test`
Descripción
En las seis lecciones anteriores viste PASS decenas de veces, y FAIL unas cuantas —siempre provocado a propósito, siempre revertido después—. Pero el reporte de dbt test tiene, en total, cuatro estados posibles, no dos, y confundir uno con otro te puede llevar a ignorar un problema real o a perseguir uno que no existe. Esta lección se detiene en el reporte en sí: qué significa cada estado, cómo se relacionan entre ellos cuando una parte del proyecto falla, y qué hacer frente a cada uno.
Conexión con el módulo. Todas las lecciones anteriores de este módulo declararon un test y lo corrieron. Esta lección da un paso atrás y mira la salida con más cuidado — el mismo tratamiento que la lección 7 del módulo 3 le dio a dbt ls, después de que las lecciones anteriores ya lo hubieran usado varias veces sin pausa. Es la última pieza conceptual antes de la lección 8, donde vas a leer un reporte con fallos reales, mezclados con pruebas que pasan, sobre la suite completa del proyecto.
Los cuatro estados de un data test
PASS -> la consulta del test devolvio 0 filas. Todo bien, no requiere ninguna accion.
WARN -> la consulta devolvio 1+ filas, pero el test esta configurado como "advertencia"
(severity: warn). El problema existe, dbt lo reporta, pero NO detiene nada mas
en la corrida.
ERROR -> la consulta devolvio 1+ filas, y el test esta configurado como "error"
(el default, severity: error). Este es el "FAIL" que ya conoces de las
lecciones anteriores -- el reporte lo etiqueta ERROR en el resumen final,
aunque la linea de progreso individual del test diga literalmente "FAIL N".
SKIP -> el test nunca llego a correr, porque algo de lo que depende (el modelo
que prueba, u otro recurso mas arriba en el DAG) fallo antes.
Fíjate en algo que puede confundir a primera vista: durante la ejecución, ves FAIL N en la línea de progreso de un test individual (1 of 1 FAIL 1 assert_no_negative_revenue, como ya viste en la lección 6) — pero el resumen final de conteos usa la palabra ERROR, no FAIL (Done. PASS=0 WARN=0 ERROR=1 SKIP=0). Son el mismo estado, con dos nombres distintos en dos partes distintas del mismo reporte: FAIL es el verbo que describe qué pasó con ese test en particular ("esta prueba falló"), ERROR es el sustantivo que cuenta cuántos tests terminaron en ese estado, en el resumen agregado.
Ejemplo trabajado: WARN, el estado que todavía no viste
Hasta ahora, cada data_tests: que declaraste usó el comportamiento por omisión: si la consulta devuelve filas, el test termina en ERROR. Pero puedes pedirle a un test que, en cambio, solo advierta — útil para reglas que quieres vigilar sin que detengan el resto del proyecto todavía, por ejemplo mientras investigas si de verdad son un problema. Prueba esto, temporalmente, sobre unique en fact_orders.order_id:
# models/marts/_models.yml (fragmento, SOLO para esta demostracion -- revierte despues)
- name: order_id
data_tests:
- unique:
config:
severity: warn
- not_null
Fíjate en la forma: config: es una clave distinta de arguments: — arguments: le pasa parámetros a la lógica del test (como ya viste con values: o to:/field:), config: ajusta el comportamiento del test en sí (severidad, entre otras opciones), sin importar qué test sea. Ahora, agrega temporalmente un duplicado, con el mismo patrón de archivo nuevo que ya usaste en lecciones anteriores:
-- raw_data/kiosko/orders_2026-08-11.csv (temporal)
order_id,store_id,product_id,quantity,unit_price,order_ts
ORD-1001,S01,P001,1,0.55,2026-08-11T08:00:00
(Fíjate en el order_id: ORD-1001 ya existe en orders_2026-08-03.csv, así que esta fila nueva crea un duplicado real, sin necesitar ningún UNION ALL en el SQL.)
dbt run --select fact_orders
dbt test --select fact_orders
Qué esperar.
1 of 7 START test accepted_values_fact_orders_product_id__P001__P002__P003__P004 [RUN]
2 of 7 START test assert_no_negative_revenue ................................... [RUN]
3 of 7 START test is_positive_fact_orders_quantity__True ....................... [RUN]
4 of 7 START test is_positive_fact_orders_unit_price__False .................... [RUN]
1 of 7 PASS accepted_values_fact_orders_product_id__P001__P002__P003__P004 ..... [PASS in 0.06s]
2 of 7 PASS assert_no_negative_revenue ......................................... [PASS in 0.06s]
3 of 7 PASS is_positive_fact_orders_quantity__True ............................. [PASS in 0.06s]
4 of 7 PASS is_positive_fact_orders_unit_price__False .......................... [PASS in 0.06s]
5 of 7 START test not_null_fact_orders_order_id ................................ [RUN]
6 of 7 START test relationships_fact_orders_store_id__store_id__ref_dim_store_ . [RUN]
7 of 7 START test unique_fact_orders_order_id .................................. [RUN]
5 of 7 PASS not_null_fact_orders_order_id ...................................... [PASS in 0.03s]
6 of 7 PASS relationships_fact_orders_store_id__store_id__ref_dim_store_ ....... [PASS in 0.03s]
7 of 7 WARN 1 unique_fact_orders_order_id ...................................... [WARN 1 in 0.03s]
Completed with 1 warning:
[WARNING]: in test unique_fact_orders_order_id (models/marts/_models.yml)
[WARNING]: Got 1 result, configured to warn if != 0
compiled code at target/compiled/kiosko_analytics/models/marts/_models.yml/unique_fact_orders_order_id.sql
Done. PASS=6 WARN=1 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=7
Lee el mensaje con cuidado: Got 1 result, configured to warn if != 0 — el mismo formato de mensaje que ya conoces (Got N result(s), configured to fail if != 0), solo que ahora dice warn en vez de fail. Y el resumen final: Completed with 1 warning: en vez de Completed with 1 error, ...: — el comando dbt test termina con código de salida exitoso cuando solo hay WARN, a diferencia de ERROR, que sí hace que el comando termine con un código de salida de fallo (relevante, por ejemplo, si algún día encadenas dbt test dentro de un script que decide si continuar según ese código).
Deshaz ambos cambios antes de seguir —el severity: warn temporal y el archivo con el duplicado—:
rm raw_data/kiosko/orders_2026-08-11.csv
dbt run --select fact_orders
Y revierte models/marts/_models.yml a la versión sin config: severity: warn (la del final de la lección 5). Confirma con dbt test que vuelves a PASS=15.
Ejemplo trabajado (continuación): ERROR que se propaga en SKIP
Ya viste SKIP en el módulo 3 —cuando stg_orders fallaba, fact_orders completo aparecía en SKIP durante dbt run—. El mismo mecanismo aplica a los tests, y vale la pena verlo con dbt build —el comando, todavía no formalmente presentado en esta guía, que corre modelos y tests juntos, respetando el orden del DAG entre ambos, el mismo comando central del módulo 8—. Rompe stg_orders.sql temporalmente, con un error real dentro del SELECT (no un source() mal escrito, que detiene todo el comando antes de arrancar; un error que sí deja correr el resto del DAG):
-- models/staging/kiosko/stg_orders.sql (temporal, con una columna que no existe)
select
order_id,
store_id,
product_id,
cast(quantity as integer) as quantity,
cast(unit_price as decimal(10, 2)) as unit_price,
cast(order_ts as timestamp) as order_ts,
this_column_does_not_exist
from {{ source('kiosko_raw', 'orders') }}
dbt build --select stg_orders+
Qué esperar.
Found 7 models, 15 data tests, 4 sources, 501 macros
Concurrency: 4 threads (target='dev')
1 of 11 START sql view model main.stg_orders ................................... [RUN]
1 of 11 ERROR creating sql view model main.stg_orders .......................... [ERROR in 0.04s]
2 of 11 SKIP test not_null_stg_orders_order_id ................................. [SKIP]
3 of 11 SKIP test unique_stg_orders_order_id ................................... [SKIP]
4 of 11 SKIP relation main.fact_orders ......................................... [SKIP]
5 of 11 SKIP test accepted_values_fact_orders_product_id__P001__P002__P003__P004 [SKIP]
6 of 11 SKIP test assert_no_negative_revenue ................................... [SKIP]
7 of 11 SKIP test is_positive_fact_orders_quantity__True ....................... [SKIP]
8 of 11 SKIP test is_positive_fact_orders_unit_price__False .................... [SKIP]
9 of 11 SKIP test not_null_fact_orders_order_id ................................ [SKIP]
10 of 11 SKIP test relationships_fact_orders_store_id__store_id__ref_dim_store_ [SKIP]
11 of 11 SKIP test unique_fact_orders_order_id .................................. [SKIP]
Finished running 1 table model, 9 data tests, 1 view model in 0 hours 0 minutes and 0.11 seconds (0.11s).
Completed with 1 error, 0 partial successes, and 0 warnings:
[ERROR]: in model stg_orders (models/staging/kiosko/stg_orders.sql)
Runtime Error in model stg_orders (models/staging/kiosko/stg_orders.sql)
Binder Error: Referenced column "this_column_does_not_exist" not found in FROM clause!
Candidate bindings: "store_id", "product_id", "unit_price"
LINE 12: this_column_does_not_exist
^
compiled code at target/compiled/kiosko_analytics/models/staging/kiosko/stg_orders.sql
Done. PASS=0 WARN=0 ERROR=1 SKIP=10 NO-OP=0 REUSED=0 TOTAL=11
1 ERROR, 10 SKIP — lee la cascada completa, porque cuenta una historia exacta. stg_orders falla al construirse (ERROR), con un Binder Error de DuckDB —la columna this_column_does_not_exist no existe en el resultado de source('kiosko_raw', 'orders'), y DuckDB hasta te sugiere qué columnas sí existen (Candidate bindings)—. A partir de ahí, todo lo que depende de stg_orders, directa o indirectamente, se marca SKIP, sin que dbt siquiera lo intente: los dos tests declarados sobre stg_orders (not_null, unique), fact_orders en sí (marcado SKIP relation, porque depende de stg_orders vía ref()), y los siete tests declarados sobre fact_orders —los seis de columna más el singular—, porque todos dependen, en última instancia, de que fact_orders exista.
flowchart TD
A["stg_orders\nERROR (columna inexistente)"] --> B["not_null_stg_orders_order_id: SKIP"]
A --> C["unique_stg_orders_order_id: SKIP"]
A --> D["fact_orders: SKIP relation"]
D --> E["6 tests de columna\nsobre fact_orders: SKIP"]
D --> F["assert_no_negative_revenue: SKIP"]
dim_store, dim_date, stg_events, stg_products y stg_stores —que no aparecen en absoluto en esta salida, porque --select stg_orders+ solo incluye a stg_orders y todo lo que depende de él (el operador +, que ya conoces del módulo 3)— seguirían construyéndose sin ningún problema si corrieras dbt build sin ese filtro: SKIP nunca significa que todo el proyecto se detuvo, significa que la parte específica del DAG que depende del recurso roto no pudo continuar, mientras que cualquier rama independiente sigue su curso normal.
Deshaz el cambio antes de continuar:
# restaura stg_orders.sql a la version sin la columna inventada (leccion 3 en adelante)
dbt build
Qué esperar. Done. PASS=22 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=22 — 7 modelos más 15 data tests, todos en verde otra vez.
Profundización: --store-failures, para inspeccionar las filas rotas sin repetir la consulta a mano
Cada vez que provocaste un FAIL en este módulo, copiaste el SELECT del test a mano, con dbt show --inline, para ver las filas exactas que fallaron. dbt tiene una forma de guardar esas filas automáticamente, sin que repitas la consulta:
dbt test --select assert_no_negative_revenue --store-failures
Cuando un test falla con esta bandera, dbt crea (o reemplaza) una tabla con las filas exactas que violaron la regla, en un esquema separado (main_dbt_test__audit, con el prefijo del esquema principal del proyecto, main), y el mensaje de error te dice exactamente dónde consultarla:
See test failures:
--------------------------------------------------------------------------
select * from "kiosko"."main_dbt_test__audit"."assert_no_negative_revenue"
--------------------------------------------------------------------------
Esto es más útil cuanto más grande es el proyecto: en vez de reconstruir la consulta del test de memoria (algo trivial en un archivo de una línea como assert_no_negative_revenue.sql, pero mucho menos trivial en un test genérico con varias CTEs), simplemente consultas la tabla que dbt ya dejó lista. Esta guía no usa --store-failures como parte del flujo normal de las lecciones —para un proyecto del tamaño de Kiosko, dbt show --inline con la misma consulta es suficiente y no deja tablas adicionales en el warehouse—, pero vale la pena conocerlo para el día en que trabajes con un proyecto real, más grande.
Errores comunes
Tratar cualquier WARN como si fuera seguro ignorarlo para siempre. Qué pasa: alguien configura un test con severity: warn "para no romper el pipeline", y nunca vuelve a revisar esa advertencia. Por qué pasa: como WARN no detiene nada ni hace fallar el comando, es fácil que se pierda entre el resto de la salida de la terminal. Cómo detectarlo: si tu proyecto acumula WARNs que nadie revisó en semanas, revisa si esos tests deberían, en cambio, ser ERROR —la regla que describen probablemente sí importa— o si deberían borrarse directamente, porque nadie los está mirando de verdad. Cómo corregirlo: usa severity: warn con un propósito temporal y explícito —por ejemplo, mientras confirmas si una regla nueva es real antes de que bloquee el proyecto—, no como una forma permanente de silenciar un problema.
Confundir SKIP con "este test pasó". Qué pasa: alguien ve una lista larga de SKIP en la salida y, al no ver ningún ERROR en esas líneas específicas, asume que está todo bien. Por qué pasa: SKIP no tiene el color rojo alarmante de ERROR, así que puede pasar desapercibido en una salida larga. Cómo detectarlo: repasa siempre el resumen final (Done. PASS=X WARN=X ERROR=X SKIP=X) antes de dar por buena cualquier corrida — cualquier número distinto de cero en SKIP significa que hay tests que no se evaluaron en absoluto, no que pasaron. Cómo corregirlo: un SKIP siempre significa "busca el ERROR real más arriba en el DAG" — nunca lo trates como una señal de que esa parte del proyecto está bien, exactamente la misma lección que ya dejó el módulo 3 con dbt run.
No revisar el resumen final, y quedarte solo con las líneas de progreso. Qué pasa: alguien mira la lista de líneas START/PASS/FAIL mientras corre, pero no espera ni lee el bloque final (Completed successfully o Completed with N errors..., y la línea Done. PASS=...). Por qué pasa: en una terminal con mucho scroll, las primeras líneas desaparecen de la vista antes de que termine el comando. Cómo detectarlo: si alguna vez no estás seguro de si una corrida terminó bien, busca siempre la última línea (Done. PASS=X WARN=X ERROR=X SKIP=X TOTAL=X) — es el resumen completo y confiable, sin importar cuántas líneas de progreso hayas visto pasar antes. Cómo corregirlo: acostúmbrate a leer siempre esa última línea antes de decidir si un módulo (o un cambio cualquiera) quedó cerrado — es el mismo hábito que ya construiste con PASS=N en dbt run, desde el módulo 1.
Ejercicios
Ejercicio 1 — Predice el resumen antes de correrlo. Sin ejecutar nada todavía: si rompieras dim_date.sql (por ejemplo, con una columna inventada, igual que hiciste con stg_orders en esta lección) y corrieras dbt build, ¿qué recursos esperarías ver en SKIP? Usa el DAG que ya conoces del módulo 3 (fact_orders depende de stg_orders, dim_store y dim_date).
Ver solución
dim_date terminaría en ERROR. Todo lo que depende de dim_date —que, en este proyecto, es únicamente fact_orders— terminaría en SKIP, junto con los siete tests declarados sobre fact_orders (los seis de columna más assert_no_negative_revenue). A diferencia de cuando rompiste stg_orders, esta vez dim_store no aparecería en SKIP —dim_store no depende de dim_date en absoluto, así que se construiría sin problema—, y tampoco aparecerían not_null_stg_orders_order_id ni unique_stg_orders_order_id, porque esos dos tests viven sobre stg_orders, que sigue intacto.
Ejercicio 2 — Verifica tu predicción. Rompe dim_date.sql temporalmente (agrega una columna que no existe al final del SELECT, como en el ejemplo trabajado de esta lección) y corre dbt build --select dim_date+. Compara el resultado real contra tu predicción del ejercicio 1.
Ver solución
-- models/marts/dim_date.sql (temporal, con una columna que no existe -- NO uses esta version)
select
cast(strftime(calendar_date, '%Y%m%d') as integer) as date_key,
calendar_date,
strftime(calendar_date, '%A') as day_of_week,
extract(month from calendar_date) as month,
extract(quarter from calendar_date) as quarter,
extract(year from calendar_date) as year,
extract(dow from calendar_date) in (0, 6) as is_weekend,
this_column_does_not_exist
from date_spine
(Nota: esta versión rota omite a propósito el with recursive date_spine as (...) del modelo real por brevedad — en tu archivo real, agrega la columna extra al final del SELECT final, dejando la CTE intacta.)
dbt build --select dim_date+
El resultado confirma la predicción: dim_date en ERROR, fact_orders en SKIP relation, y los siete tests de fact_orders en SKIP — ni dim_store ni los tests de stg_orders aparecen en esta salida filtrada, porque ninguno depende de dim_date. Restaura dim_date.sql a su versión original antes de continuar, y confirma con dbt build que el proyecto completo vuelve a PASS=22.
Ejercicio 3 — Argumenta por qué SKIP es preferible a que dbt simplemente intente correr todo de todas formas. En 2-3 frases, explica qué problema evita dbt al marcar como SKIP los tests que dependen de un modelo roto, en vez de intentar correrlos de todas formas contra los datos que hubiera de una corrida anterior.
Ver solución
Si dbt intentara correr not_null_fact_orders_order_id después de que fact_orders falló al reconstruirse, ese test correría contra una versión vieja de la tabla —la de la última corrida exitosa—, y un PASS en esas condiciones sería engañoso: te diría "esto está bien" sobre datos que ya no reflejan el estado actual del código del proyecto. SKIP es la forma honesta de decir "no sé si esto está bien, porque no pude verificarlo con los datos correctos" — evita que un PASS falso te dé una falsa sensación de seguridad sobre un problema que, en realidad, sigue sin resolverse en otra parte del DAG.
Resumen y siguiente paso
En esta lección aprendiste a leer el reporte completo de dbt test (y de dbt build, que combina modelos y tests): los cuatro estados posibles —PASS, WARN, ERROR/FAIL, SKIP—, cómo se configuran (severity: warn frente al default error), y cómo se propaga un ERROR en cascada de SKIP hacia todo lo que depende del recurso roto, sin detener las ramas independientes del DAG. Viste, con una demostración real, que el número final de la línea Done. PASS=X WARN=X ERROR=X SKIP=X es la única fuente de verdad confiable sobre el resultado completo de una corrida.
Antes de avanzar deberías poder: explicar la diferencia entre WARN y ERROR, y cuándo elegirías el primero sobre el segundo; y predecir, sin ejecutar nada, qué recursos terminarían en SKIP si un modelo específico del proyecto fallara al construirse.
La lección 8 cierra el módulo con el proyecto completo: vas a agregar el lote de órdenes con tres filas rotas a propósito que ya usaron data-engineering-foundations-guide y data-modeling-for-analytics-guide, correr la suite de 15 tests sobre ese lote, y leer el reporte de FAIL real que produce — aplicando, con datos concretos, exactamente lo que esta lección te enseñó a interpretar.
Recursos
- dbt Developer Hub — "Add data tests to your DAG", sección de configuración de severidad (
config: severity:), la base de la primera parte de esta lección. docs.getdbt.com/docs/build/data-tests. En inglés. - dbt Developer Hub — "About dbt build", que documenta el orden de ejecución (modelos, luego tests, respetando el DAG) y el comportamiento de
SKIPen cascada, usado en la segunda parte de esta lección. docs.getdbt.com/reference/commands/build. En inglés. - dbt Developer Hub — "
--store-failures", la referencia de la bandera usada en la Profundización de esta lección para guardar las filas de un test fallido en una tabla consultable. docs.getdbt.com/reference/resource-configs/store_failures. En inglés.