유닛 테스트
유닛 테스트 (Unit tests)
**유닛 테스트(unit test)**는 소프트웨어 프로그래밍에서 작은 코드 조각을 검증하듯, dbt의 SQL 모델링 로직을 소규모 정적 입력으로 검증하는 테스트 방식이에요. 데이터 테스트가 모델을 빌드한 뒤 입력 데이터나 결과 데이터셋의 품질을 평가하는 반면, 유닛 테스트는 풀 모델을 프로덕션에 materialize하기 전에 로직 자체를 검증해요. 테스트 주도 개발(TDD)을 가능하게 해 개발 효율과 코드 신뢰성을 높여주죠.
유닛 테스트는 버전 1.8부터 지원돼요. 목(mock) 데이터를 작게 정의해 기대 출력과 비교하는 방식이라, 복잡한 로직의 사소한 실수를 실제 웨어하우스 비용을 들이지 않고 미리 잡을 수 있어요.
출처: Unit tests
언제 모델에 유닛 테스트를 추가하나요
다음 같은 경우에 모델을 유닛 테스트하는 걸 권장해요.
- SQL에 복잡한 로직이 있을 때 — 정규식, 날짜 연산, 윈도우 함수,
when이 많은case when, 절삭(truncation) - 입력 데이터를 처리하는 커스텀 로직을 함수처럼 작성할 때
- 이전에 버그가 보고된 적 있는 로직
- 실제 데이터에서 아직 보지 못한 엣지 케이스를 처리하고 싶을 때
- 변환 로직을 리팩터링하기 전(특히 큰 리팩터링)
- '중요도'가 높은 모델(공개·컨트랙트된 모델이나 exposure 바로 위의 모델)
반대로 min() 같은 함수는 웨어하우스가 이미 충분히 테스트하므로 유닛 테스트를 권장하지 않아요. 문제가 생기면 함수보다는 기본 데이터 문제일 가능성이 크고, 픽스처 데이터가 유용한 정보를 주지 못하기 때문이에요.
언제 유닛 테스트를 실행하나요
dbt Labs는 유닛 테스트를 개발(development) 또는 CI 환경에서만 실행하길 강력히 권장해요. 입력이 정적이라 프로덕션에서 추가 컴퓨팅을 쓸 필요가 없어요. 프로덕션 빌드에서 유닛 테스트를 제외하고 컴퓨팅을 아끼려면 --exclude-resource-type 플래그나 DBT_EXCLUDE_RESOURCE_TYPES 환경 변수(1.11부터는 DBT_ENGINE_EXCLUDE_RESOURCE_TYPES)를 사용해요.
모델 유닛 테스트하기
is_valid_email_address 필드를 계산하는 dim_customers 모델이 있다고 해볼게요. 성장하는 조직에서는 이렇게 복잡한 로직을 검증하기 어려울 수 있어요.
유닛 테스트를 추가하면 이 로직이 모든 알려진 엣지 케이스를 잡는지 확인할 수 있어요 — 점(.)이 없는 이메일, @가 없는 이메일, 잘못된 도메인의 이메일 등이죠. dbt_project.yml에 유닛 테스트를 정의해봐요.
unit_tests:
- name: test_is_valid_email_address
description: "Check my is_valid_email_address logic captures all known edge cases - emails without ., emails without @, and emails from invalid domains."
model: dim_customers
given:
- input: ref('stg_customers')
format: dict
rows:
- {email: [email protected], email_top_level_domain: example.com}
- {email: [email protected], email_top_level_domain: unknown.com}
- {email: badgmail.com, email_top_level_domain: gmail.com}
- {email: missingdot@gmailcom, email_top_level_domain: gmail.com}
- input: ref('top_level_email_domains')
format: dict
rows:
- {tld: example.com}
- {tld: gmail.com}
expect:
format: dict
rows:
- {email: [email protected], is_valid_email_address: true}
- {email: [email protected], is_valid_email_address: false}
- {email: badgmail.com, is_valid_email_address: false}
- {email: missingdot@gmailcom, is_valid_email_address: false}
목 데이터는 인라인 dict 외에도 csv나 sql 형식으로 인라인 또는 별도 픽스처 파일로 정의할 수 있어요. 픽스처 파일은 테스트 경로의 fixtures 하위 디렉토리(예: tests/fixtures/my_unit_test_fixture.sql)에 저장해요. csv 형식 예시를 보면 다음과 같아요.
unit_tests:
- name: test_is_valid_email_address__csv
model: dim_customers
given:
- input: ref('stg_customers')
format: dict
rows:
- {email: [email protected], email_top_level_domain: example.com}
- {email: [email protected], email_top_level_domain: unknown.com}
- {email: badgmail.com, email_top_level_domain: gmail.com}
- {email: missingdot@gmailcom, email_top_level_domain: gmail.com}
- input: ref('top_level_email_domains')
format: csv
rows: |
tld
example.com
gmail.com
expect:
format: csv
fixture: valid_email_address_fixture_output
dict나 csv 형식을 쓰면 관련 컬럼의 목 데이터만 정의하면 되므로, 간결하고 특정한 유닛 테스트를 작성할 수 있어요.
유닛 테스트 대상 모델의 직접 부모(여기선 stg_customers, top_level_email_domains)는 실행 전에 웨어하우스에 존재해야 해요. 웨어하우스 비용을 아끼려면 --empty 플래그로 빈 버전의 모델을 만들어 실행할 수 있어요.
dbt run --select "stg_customers top_level_email_domains" --empty
또는 dbt build를 쓰면 계보 순서대로 유닛 테스트 실행 → 모델 materialize → 데이터 테스트 실행을 진행해요.
유닛 테스트 실행 명령의 선택 폭은 이렇게 돼요.
dbt test --select dim_customers—dim_customers의 모든 테스트 실행dbt test --select "dim_customers,test_type:unit"—dim_customers의 유닛 테스트만 실행dbt test --select test_is_valid_email_address— 이름이test_is_valid_email_address인 테스트 실행
dbt test --select test_is_valid_email_address
16:03:49 Running with dbt=1.8.0-a1
16:03:49 Registered adapter: postgres=1.8.0-a1
16:03:50 Found 6 models, 5 seeds, 4 data tests, 0 sources, 0 exposures, 0 metrics, 410 macros, 0 groups, 0 semantic models, 1 unit test
16:03:50
16:03:50 Concurrency: 5 threads (target='postgres')
16:03:50
16:03:50 1 of 1 START unit_test dim_customers::test_is_valid_email_address ................... [RUN]
16:03:51 1 of 1 FAIL 1 dim_customers::test_is_valid_email_address ............................ [FAIL 1 in 0.26s]
16:03:51
16:03:51 Finished running 1 unit_test in 0 hours 0 minutes and 0.67 seconds (0.67s).
16:03:51
16:03:51 Completed with 1 error and 0 warnings:
16:03:51
16:03:51 Failure in unit_test test_is_valid_email_address (models/marts/unit_tests.yml)
16:03:51
actual differs from expected:
@@ ,email ,is_valid_email_address
→ ,[email protected],True→False
,[email protected],False
...,... ,...
16:03:51 Done. PASS=0 WARN=0 ERROR=1 SKIP=0 TOTAL=1
여기선 정교한 정규식이 예상과 달리 [email protected]을 잘못된 이메일로 표시했네요. 정규식을 올바른 것으로 고치고 다시 실행하면 통과해요.
dbt test --select test_is_valid_email_address
16:09:11 Running with dbt=1.8.0-a1
16:09:12 Registered adapter: postgres=1.8.0-a1
16:09:12 Found 6 models, 5 seeds, 4 data tests, 0 sources, 0 exposures, 0 metrics, 410 macros, 0 groups, 0 semantic models, 1 unit test
16:09:12
16:09:13 Concurrency: 5 threads (target='postgres')
16:09:13
16:09:13 1 of 1 START unit_test dim_customers::test_is_valid_email_address ................... [RUN]
16:09:13 1 of 1 PASS dim_customers::test_is_valid_email_address .............................. [PASS in 0.26s]
16:09:13
16:09:13 Finished running 1 unit_test in 0 hours 0 minutes and 0.75 seconds (0.75s).
16:09:13
16:09:13 Completed successfully
16:09:13
16:09:13 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
이렇게 유닛 테스트가 dim_customers를 웨어하우스에 materialize하기 전에 SQL 로직의 문제를 잡아내면, 모델의 미래 신뢰성을 훨씬 잘 보장할 수 있어요.
Incremental 모델 유닛 테스트하기
매크로·변수·환경 변수의 출력을 override할 수 있어, incremental 모델을 'full refresh'와 'incremental' 두 모드로 유닛 테스트할 수 있어요. 유닛 테스트를 실행하거나 dbt build를 하기 전에 incremental 모델은 데이터베이스에 존재해야 해요. --empty 플래그로 빈 버전을 만들어 쓰면 비용을 아낄 수 있죠.
dbt run --select "config.materialized:incremental" --empty
incremental 모델을 테스트할 때 기대 출력은 materialization의 결과(병합/insert될 것)이지, 최종 테이블 자체가 아니라는 점을 기억해야 해요. 아래처럼 overrides.macros로 is_incremental을 조작해 두 모드를 각각 검증할 수 있어요.
unit_tests:
- name: my_incremental_model_full_refresh_mode
model: my_incremental_model
overrides:
macros:
# unit test this model in "full refresh" mode
is_incremental: false
given:
- input: ref('events')
rows:
- {event_id: 1, event_time: 2020-01-01}
expect:
rows:
- {event_id: 1, event_time: 2020-01-01}
- name: my_incremental_model_incremental_mode
model: my_incremental_model
overrides:
macros:
# unit test this model in "incremental" mode
is_incremental: true
given:
- input: ref('events')
rows:
- {event_id: 1, event_time: 2020-01-01}
- {event_id: 2, event_time: 2020-01-02}
- {event_id: 3, event_time: 2020-01-03}
- input: this
# contents of current my_incremental_model
rows:
- {event_id: 1, event_time: 2020-01-01}
expect:
# what will be inserted/merged into my_incremental_model
rows:
- {event_id: 2, event_time: 2020-01-02}
- {event_id: 3, event_time: 2020-01-03}
유닛 테스트 종료 코드
유닛 테스트의 성공·실패는 두 가지 종료 코드로 표현돼요 — 통과(0)와 실패(1). 데이터 테스트가 조건을 확인하는 쿼리이고 실패한 테스트 케이스당 행을 반환하는 것과 달리, 유닛 테스트는 각각이 하나의 '테스트 케이스'라서 결과가 몇 개의 레코드가 실패했든 상관없이 항상 0(통과) 또는 1(실패)이에요.