Mejores Prácticas de Testing en dbt para Datos Financieros
Las cuatro categorías de tests en dbt que detectaron 15 bugs silenciosos en un sistema financiero en producción — schema tests, data tests, unit tests y reconciliation tests — con ejemplos reales de cada una.

Punto Clave
Encontramos 15 bugs en un sistema financiero en producción durante una migración a dbt + Snowflake. No porque nuestro código estuviera mal — sino porque el sistema legacy nunca había sido probado contra una referencia independiente. Este post desglosa las cuatro categorías de testing que habrían detectado cada uno antes de producción, con ejemplos reales de YAML y SQL del proyecto.
Encontramos 15 bugs silenciosos en un sistema BI financiero en producción. No eran casos de borde ni problemas cosméticos. Un incremento de precios del 118% sin documentar. Un trimestre fiscal que abarcaba 15 meses. Cuentas de clientes que desaparecían silenciosamente de los reportes de ingresos. Todo en un sistema que había estado corriendo reportes financieros en producción durante años, mantenido por un equipo competente. Esto es lo que los habría detectado antes de llegar a producción.
Las cuatro categorías de tests que lo detectan todo
Cada bug que encontramos cae en una de cuatro categorías de testing. Ninguna categoría sola lo detecta todo. Juntas, cubren toda la superficie de un proyecto financiero en dbt.
1. Schema tests: las barreras que configuras una vez
Los schema tests son declaraciones YAML que corren contra tus modelos en cada build. Son los tests más baratos de escribir y los más fáciles de olvidar. La mayoría de los proyectos dbt arrancan aquí y se quedan aquí — por eso solo detectan las fallas más obvias.
En nuestro proyecto, los schema tests habrían detectado tres de los quince bugs directamente: las cuentas de clientes desaparecidas, las filas duplicadas de grain, y los registros regionales huérfanos.
Así se veía el schema test para nuestro modelo de ARR:
models:
- name: arr_by_customer
columns:
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: arr_amount
tests:
- not_null
- name: fiscal_quarter
tests:
- accepted_values:
values: ['Q1', 'Q2', 'Q3', 'Q4']
Ese test de relationships es el que la gente se salta. Verifica que cada customer_id en tu tabla de hechos realmente exista en tu tabla de dimensiones. Sin él, los registros huérfanos inflan o desinflan totales silenciosamente dependiendo de cómo están escritos tus joins downstream. El Bug #3 — donde las cuentas australianas desaparecieron del ARR trimestral — habría aparecido como una falla de integridad referencial aquí en lugar de como una varianza misteriosa de ingresos tres meses después.
La clave: los schema tests no validan lógica de negocio. Validan suposiciones estructurales. Piensa en ellos como aserciones sobre la forma de tus datos, no sobre la corrección de tus cálculos. En un proyecto financiero de dbt, el set mínimo es: not_null en cada columna de clave y monto, unique en tu primary key, relationships en cada foreign key, y accepted_values en cualquier columna categórica que alimente lógica condicional. Escríbelos antes de que tu primer modelo llegue a producción. Toman una hora. Los bugs que previenen toman días.
2. Data tests: SQL custom que codifica reglas de negocio
Los data tests son archivos SQL independientes en tu directorio tests/ que regresan filas cuando algo está mal. Cero filas significa que pasa. Cualquier fila significa falla. Es donde codificas las reglas de negocio que los schema tests no pueden expresar.
El data test más valioso que escribimos fue un grain check — un query que aserta exactamente una fila por combinación de claves esperada:
-- tests/assert_one_row_per_customer_month.sql
select
customer_id,
fiscal_month,
count(*) as row_count
from {{ ref('fct_revenue') }}
group by customer_id, fiscal_month
having count(*) > 1
Simple. Brutal. Este test habría detectado el Bug #6 (filas de grain duplicadas inflando totales trimestrales) y el Bug #9 (un mismatch de unidades que creó filas fantasma a un nivel de grain que no debería haber existido). Ambos bugs producían números que se veían plausibles. Los totales trimestrales estaban en el rango correcto. Pero el grain subyacente estaba mal, y todo lo downstream heredó el error.
También escribimos data tests para invariantes específicos del negocio: ingresos nunca deben ser negativos para estas líneas de producto, los totales mensuales no deben oscilar más de 30% mes-con-mes sin un evento de pricing conocido, y cada trimestre fiscal debe contener exactamente tres meses. Este último habría detectado el bug del trimestre fiscal de 15 meses en la primera corrida de CI.
-- tests/assert_fiscal_quarter_has_three_months.sql
select
fiscal_quarter,
count(distinct fiscal_month) as month_count
from {{ ref('dim_fiscal_calendar') }}
group by fiscal_quarter
having count(distinct fiscal_month) != 3
3. Unit tests: aislando la lógica de transformación
dbt introdujo unit tests nativos en v1.8. Te permiten definir inputs fijos, correr un modelo, y validar contra outputs esperados — sin tocar el warehouse. Para modelos financieros con lógica de transformación compleja, son la única forma de testear edge cases que aún no existen en tus datos de producción.
Usamos unit tests para nuestro macro de conversión de trimestres fiscales — el que tenía un typo mapeando Q2FY25 a 2023 en lugar de 2024. Un unit test con fechas frontera lo habría detectado inmediatamente:
unit_tests:
- name: test_fiscal_quarter_boundaries
model: fct_revenue
given:
- input: ref('stg_billing')
rows:
- {billing_date: '2024-05-01', amount: 100}
- {billing_date: '2024-04-30', amount: 200}
expect:
rows:
- {fiscal_quarter: 'Q2FY25', amount: 100}
- {fiscal_quarter: 'Q1FY25', amount: 200}
El test son seis líneas. El bug que detecta costó días de debugging y produjo valores de ARR trimestral incorrectos en todo el dataset. La economía es absurda: seis líneas de YAML versus varios días de un ingeniero senior reconciliando una varianza fantasma.
Los unit tests también detectaron nuestro problema con macros en dbt nativo de Snowflake (Bug #7) donde llamar un macro dentro de una cláusula PARTITION BY compilaba exitosamente pero producía resultados incorrectos. No lo habríamos encontrado con data tests porque el output se veía razonable — simplemente omitía tres trimestres de datos en silencio. Solo un unit test con inputs conocidos y outputs esperados habría sacado la discrepancia.
Un punto sobre dónde poner unit tests: enfócalos en lógica de transformación que tenga ramas condicionales, manejo de fechas frontera, o comportamiento específico de plataforma. No hagas unit tests de modelos passthrough simples o agregaciones directas — los data tests manejan eso más eficientemente. El sweet spot son modelos donde la transformación puede ser correcta para el 99% de los inputs e incorrecta para el 1% que cae en un edge case. Conversiones de calendario fiscal, lookups de pricing escalonado y lógica de multiplicadores regionales son candidatos ideales. Si un modelo tiene un CASE con más de tres ramas, probablemente merece un unit test.
4. Reconciliation tests: la opción nuclear
Los reconciliation tests comparan tu output de dbt contra una referencia independiente — un sistema legacy, un export, una hoja de cálculo validada manualmente. Son los tests más caros de escribir y mantener, y son los únicos que detectan bugs donde tu código hace exactamente lo que le dijiste pero le dijiste algo incorrecto.
Doce de nuestros quince bugs fueron encontrados por reconciliación. No schema tests. No unit tests. Reconciliación. La razón es simple: los schema tests validan estructura, los data tests validan reglas que ya conoces, los unit tests validan lógica que ya escribiste. La reconciliación valida contra la realidad.
El patrón central es un query de varianza con FULL OUTER JOIN:
select
coalesce(v1.customer_id, v2.customer_id) as customer_id,
coalesce(v1.fiscal_month, v2.fiscal_month) as fiscal_month,
v1.revenue as legacy_revenue,
v2.revenue as dbt_revenue,
abs(v1.revenue - v2.revenue) as variance,
case
when v1.revenue is null then 'MISSING_IN_LEGACY'
when v2.revenue is null then 'MISSING_IN_DBT'
when abs(v1.revenue - v2.revenue) > 0.01 then 'VARIANCE'
else 'MATCH'
end as status
from legacy_output v1
full outer join dbt_output v2
on v1.customer_id = v2.customer_id
and v1.fiscal_month = v2.fiscal_month
where v1.revenue != v2.revenue
or v1.revenue is null
or v2.revenue is null
El FULL OUTER JOIN es crítico. Un INNER JOIN esconde los registros faltantes — la categoría de bug más peligrosa. Si un cliente existe en el sistema legacy pero no en dbt, un INNER JOIN simplemente los omite de la comparación. Obtienes un pase limpio y una respuesta incorrecta.
Corrimos esto a tres niveles de grain: totales mensuales, por línea de producto, y por cliente. Una varianza que desaparece cuando profundizas es un problema de grain. Una varianza que persiste en cada nivel es un error de lógica. La metodología de validación explica el enfoque jerárquico completo.
La parte difícil de los reconciliation tests no es escribirlos. Es mantener los datos de referencia. Los outputs del sistema legacy cambian. Los exports manuales se vuelven obsoletos. El patrón más sostenible que encontramos fue hacer snapshot del output legacy en un punto validado — un cierre de mes verificado — y congelarlo como un seed de dbt o una tabla estática. El seed no cambia. El query de reconciliación corre contra él en cada build. Si tu modelo nuevo se desvía del snapshot, CI lo detecta. El trade-off es que estás validando contra un punto congelado en el tiempo en lugar de un sistema en vivo, pero para datos históricos que deberían ser inmutables, este es exactamente el comportamiento correcto.
El test que nos hubiera gustado escribir primero
Si pudiéramos regresar en el tiempo y escribir un solo test antes que cualquier otra cosa, sería el grain check en fct_revenue.
No los queries de reconciliación — esos requieren un sistema de referencia. No los unit tests — esos requieren saber qué macros tienen edge cases. El grain check. Cinco líneas de SQL.
La razón son los errores compuestos. Cuando tu tabla de hechos tiene filas duplicadas al grain incorrecto, cada modelo downstream hereda el problema. Las agregaciones cuentan doble. Los joins se multiplican. Los queries de varianza producen falsos positivos que te hacen perseguir bugs fantasma en lugar de reales. Pasamos casi un día completo investigando una varianza de ingresos que resultó ser un problema de grain en un modelo de staging, no un error de lógica en la capa mart. El grain check lo habría detectado en CI antes de que llegara a la capa mart.
Lo que lo hace caro es el efecto compuesto. Un bug en un modelo de staging se arregla en diez minutos. Un bug en un modelo de staging que se propagó a través de tres modelos downstream y produjo resultados de reconciliación engañosos es una investigación de un día completo. El grain check es un circuit breaker que previene la propagación.
Lo que agregaríamos la próxima vez
Estos son los tests que no escribimos en este proyecto pero incluiríamos desde el día uno en el siguiente proyecto financiero en dbt:
- Reconciliación basada en seeds para pricing. Cada tarifa hardcodeada debería vivir en un seed file con historial de cambios. Testea que los valores del seed coincidan con lo que realmente se aplica en la capa mart. El Bug #1 — el incremento invisible del 118% — existió porque la tarifa vivía en un row de base de datos sin historial de versiones.
- Tests de regresión basados en snapshots. Después de cada release validado, haz snapshot de los outputs de la capa mart. En el siguiente build, compara contra el snapshot. Cualquier varianza es un cambio intencional (documentado en el PR) o una regresión. Esto detecta bugs donde un cambio de modelo en el dominio A afecta silenciosamente al dominio B.
- Tests de source freshness con conciencia de calendario de negocio. El
dbt source freshnessestándar verifica si los datos llegaron. El freshness con conciencia de calendario verifica si llegaron los datos correctos — ¿se cargó el cierre de este mes, o seguimos corriendo con datos del mes pasado? - Validación de joins cross-domain. Cuando un modelo del dominio de pricing hace join con un modelo del dominio de clientes, testea que el join no elimine ni duplique filas. Nuestra arquitectura de aislamiento por dominio prevenía quiebres cross-domain a nivel de modelo, pero la capa de dashboards hacía joins entre dominios libremente. Un test de validación de joins en la capa de input del dashboard habría detectado el bug del filter scope (Bug #6) antes de que llegara a cualquier gráfica.
Si tu proyecto dbt tiene modelos pero no tests, podemos configurar el framework de testing en días, no semanas. Agenda una evaluación rápida.
Temas
Arturo Cárdenas
Fundador y Chief Data Analytics & AI Officer
Arturo es un consultor senior en analítica e IA que ayuda a empresas medianas y grandes a eliminar el caos de datos para desbloquear claridad, velocidad y ROI medible.


