Mutation coverage for a dbt model's tests - #467
Open
marcociav-exmergo wants to merge 5 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #232.
Context
A dbt model can carry unit, generic and singular tests, pass all of them, and still
be wrong in a way none of them can detect. Test count is not test strength, and
nothing in the dbt ecosystem measures the difference. #232 asks dex to answer "do
these tests actually test anything":
inner join here"), not as diffs.
Both dependencies are closed:
transform: scaffold dbt unit tests from a model's inputs #215:transform test --scaffold, the unit-test skeleton.transform build --verify: run the sweep over the nodes just built #231:transform build --verify, whose build-and-check machinery this reuses.The design holds the safety spine:
Outcome:
transform test --mutate <model>on every connector, dogfooded on DuckDB,BigQuery and Snowflake. A model with a strong suite reports few survivors, and a
not_null-only model reports most surviving.Decisions taken
transform test --mutate <model> [--max-mutants N] [--target T].--mutateand--scaffoldare a mutually exclusive argparse group.--targetbehaves as it does onbuild.MAX_MUTANTS = 20.--max-mutantsmay only lower it. A value above 20is refused, naming the ceiling, the same way
--scopenarrows and never widens.DexEngine.test_mutations(model, *, max_mutants=None, target=None),mapped in the CLI parity table.
data goes to a follow-up issue, drafted here and opened only after you approve its
text.
Design
How a mutant runs: in a copy, as an ephemeral model, under
dbt testshadow_parse(
transform/build.py:297-317) into a context managershadow_project(project, edits=()).shadow_parsekeeps its exact behavior on top of it.ShadowRun, also intransform/build.py. It uses class DI because it ownsstate (the temp dir and dbt's partial-parse cache across N+1 invocations).
write(rel_path, text), which usescontained_path;strip_run_hooks();parse();compile(select); andtest(select, exclude=()), which returns_summarize(...).--project-dir <copy> --profiles-dir <real> --target-path <copy>/target --log-path <copy>/logs.path:against cwd, exactly astransform builddoes.--indirect-selection eager --no-defer --no-favor-state --no-fail-fast.DBT_TARGET_PATH, DBT_LOG_PATH, DBT_STATE, DBT_DEFER_STATE, DBT_STORE_FAILURES, DBT_WARN_ERROR, DBT_WARN_ERROR_OPTIONS, DBT_RESOURCE_TYPES, DBT_EXCLUDE_RESOURCE_TYPES, DBT_SELECTOR, DBT_EMPTY, DBT_FULL_REFRESH.DO_NOT_TRACK=1, because otherwise dbt writes.user.ymlinto the profiles dir._build_env(connector, paradigm, ceiling)aremerged in.
the model file with this header:
{{ config(materialized='ephemeral', contract={'enforced': false}, access='protected') }}.config()beats YAML (dbt/context/context_config.py:165-173).access='protected'stops apublicmodel failing parse(
dbt/parser/manifest.py:1437-1447).dbt test --select <model>, neverbuildorrun:dbt/graph/selector.py:22-33).__dbt__cte__<model>(dbt/compilation.py:539-591).raw_codewhatever its materialization(
dbt/parser/unit_tests.py:61-86).dbt testcannot run model DDL, so even an ignored override could neveroverwrite your dev relation.
dbt buildwould be wrong here: one failing unit test marks the model skipped,and that skip cascades onto its data tests (
dbt/task/build.py:33-38,134-148).ephemeral,otherwise the command fails closed.
store_failures.dbt_project.ymlloseson-run-start/on-run-end, so hooks do notrun N+1 times. A note says so.
store_failureswould write mutant rows into the warehouse. It ispassed as
--excludeand reported excluded with that reason.dev schema, the same as
dbt buildtoday. This is documented.dev_target.checkruns once, against the real project, first, as incommands.build(commands.py:1198).What gets mutated: the compiled SQL, with references restored
appended. The last
config()wins for scalar keys(
dbt_common/contracts/config/base.py:296-317), sois_incremental()is falseby construction.
--full-refreshwould not do this, because a model withfull_refresh: falsedefeats the flag.compileit and takecompiled_codefrom the manifest.mutation.prepare(compiled_code, dialect=..., parents=..., dbt_ctes=...)parsesthe code in the connector dialect (
adapters.get_dialect) and restoresreferences:
depends_onparent'srelation_nameis parsed withexp.to_table(name, dialect=...)and matched exactly on (catalog, db, name,quoting). That covers BigQuery backticks and Snowflake case with no folding.
The suffix-tolerant
match_identifieris not used, because an ambiguous matchwould cross-wire two refs.
{{ ref('m') }},{{ ref('pkg','m') }},{{ ref('m', v=N) }}or{{ source('s','t') }}.__dbt__cte__*CTE belonging to anephemeral node is stripped, including hoisted grandparents. Refused if a
stripped non-parent is still referenced.
{% raw %}...{% endraw %}, so a{{inside a string literal or comment is notre-rendered. Refused if the SQL contains
endraw.same generator. It is the harness's own self-check: a round trip that changes
what a test sees shows up at baseline, not in every mutant.
The mutation library (
transform/mutation.py, pure, no I/O)comparisonEQ/NEQ/GT/GTE/LT/LTE(null-safe pair flips to each other)predicatesql_shape.predicates)expression_is_trueoraccepted_valuesjoin_typesql_shape.joins)relationshipsor a row-count testcase_branchWHEN(exp.Case.ifs)defaultorexp.null()(emptyifsgenerates invalidCASE END)accepted_valuesornot_nullon the outputdivisionexp.Div(keepingtyped/safe),exp.SafeDivide, SnowflakeDIV0(round-trips toIFF(b = 0 ...), so the guard's denominator is swapped too)window_frameLiteralbounds ofexp.WindowSpec(intervalRANGEbounds skipped)aggregateSUM,MAXSUMtoMAX,MAXtoSUMexp.Selectin thetree, not only
sql_shape.scopes, so aUNIONroot is covered.final union".
_variantpattern(
row_attribution.py:687): copy the tree, find site i in a fixed traversalorder, apply the change. So mutants never compound.
re-parse, and, when
guards.approved_functionsis set, a mutant that failsguarded_statement_verdict.stable ids (
m01...), then cut to the cap. The elided count per class goes intodata.capand a warning, following the rule atrow_attribution.py:51that acap which binds says so.
sql_shape.row_attribution._set_predicatesbecomes the publicsql_shape.set_predicates, and row_attribution imports it from there.prepare,enumerate_mutants(model, cap),inline_into_test,classify,MAX_MUTANTS,EPHEMERAL_HEADER.Baseline and verdicts
each test's attached model.
relationshipstest pointing here, which isuseful. It is reported with
attached_model.reason. This covers tests that introspect the relation catalog, unit tests that
depend on
overrides(the Jinja they act on is already rendered), andstore_failurestests.fix:
transform build --select +<model>.classify):killed: a baseline-passing test now fails or warns.caught_bynames thetests.
warn_onlyis set when every catching test has severity warn.rejected: otherwise, a baseline-passing test now errors. The warehouse or dbtrefused the mutant, and
dbt buildwould fail too, so it is not a test gap. ABigQuery dry run that fails at pricing marks the mutant
rejectedbefore anyspend.
survived: otherwise.not_run: the run produced no results, a pinned test is missing from them, orthe budget stopped the batch.
Only test names and statuses appear.
score= killed / (killed + survived), or null.Free refusals, before any connection or spend
All of these are refused before dex opens a connection or spends anything:
and snapshots are all refused;
sql_header, which only materializations emit;Selector a set operation);--max-mutantsis above 20.Models that are already ephemeral work as they are. A versioned model accepts
nameor
name.vN; a bare name means the latest version.Cost
skipped_handshake_warning, asbuilddoes.compiled_codewithadapter.query_estimate:a real dry run on BigQuery, a local heuristic on Snowflake.
inline_into_testparses the test SQL, replaces the body ofthe CTE aliased
__dbt__cte__<model>with the mutant body, and prices theresult.
compiled_codeis post-injection (compilation.py:584-590).raise the bytes scanned, so a multiplied baseline would under-report.
estimate is noted as a partial floor (the
compile_estimateconvention).command_args.billed_handshake("transform test", adapter, total, per_table={"(baseline)": b, "m01 comparison": x, ...}).confirmation, as
commands.builddoes at:1279.confirmed budget, stop. The rest become
not_run, stated in a warning.rounds down.
_settle_dbt_spendextracted from
_shape_build_result's per-paradigm branches(
commands.py:1936-2006): bytes fromadapter_response, seconds fromexecution_time._record_build_spendgainscommand=, so rows saytransform test.gate.settle()runs in afinally.compute_spend_translationruns once onthe total.
Payload (
MutationCoverageResult,transform/results.py)model,target.baseline {tests: [{name, kind, attached_model, status}], excluded: [{name, status, reason}]}.mutants, survivors first:{id, operator, defect, scope, detail, before, after, suggested_test, status, caught_by, warn_only}.counts {generated, run, killed, rejected, survived, not_run}.score.cap {limit, generated, elided}.runs.spend, lifted todata.spend.Status is
okwhen mutants survive, the same as--verifyfindings. Warningscarry a pointer line with the survivor count, and a sentence saying that
"survived" means no test told the mutant apart on the dev data or the unit test
fixtures.
Changes by file
Engine (
packages/dex-core/src/exmergo_dex_core/)sql_shape.py:set_predicates.transform/mutation.py(new).transform/build.py:shadow_project,ShadowRun. The enginebuild()isunchanged.
transform/commands.py:_settle_dbt_spendextracted with no behavior change (test_buildguards it);_record_build_spend(command=);test_mutations(engine, model, *, max_mutants=None, target=None), whichmirrors
commands.buildin order: dev check, deps in the real project,ShadowRun, identity compile, refusals,
prepare/enumerate, price, handshake,baseline, loop, settle;
cmd_testdispatching--scaffoldor--mutate.transform/results.py:MutationCoverageResult.engine.py:DexEngine.test_mutations.cli.py: thetransform testblock (:575) and its comment.transform/row_attribution.py: importsset_predicates.Tests (
packages/dex-core/tests/, mirroring src)tests/test_sql_shape.py:set_predicates.tests/transform/test_mutation.py(pure):branch, interval bounds skipped, Div/SafeDivide/DIV0 guard, null-safe pair;
preparewith a hoisted grandparent CTE, BigQuery backticks, mixed-caseSnowflake, a versioned ref, a package ref, a source, an unmatched literal
table, a union root, and multi-statement SQL refused;
{% raw %}wrapping;inline_into_testwith a nested WITH;classifymatrix;tests/transform/test_build.py:shadow_projectkeepsshadow_parsegreen, andShadowRunsets argv, cwd and the scrubbed env.tests/transform/test_mutation_command.py:_fake_runner_factorypattern,test_build.py:168,writing artifacts under argv's
--target-path):parse,compileortest;--project-diris the copy;testinvocations;not_run;transform test;test_confirmed_dev_build_runs_dbt_for_real(
test_build.py:553):materialized='incremental';publicmodel parses;not_nullalone;after.
tests/test_safety_spine.py:needs_confirmationwith the batch total andruns no
dbt test;tests/test_cli_contract.py:_SUBCOMMAND_PARITY[("transform","test")]maps totest_mutations(mutatetomodel,max_mutants,target), with the scaffoldpath noted.
tests/test_engine.py:_NOT_ENGINE_METHODSand_METHOD_NAMES.tests/test_spend_parity.py: add the command tobilled_runs.tests/integration/test_{bigquery,snowflake}_transform.py:command="transform test", underintegration_budget().Docs
AGENTS.md: the command-table row (:96) and the "three things called a test"note (
:159).references/command-contract.md: the surface line (:258), the transform tablerow, and the spend-reporting command list (about
:1155).references/<connector>.md, all seven: a cost paragraph beside the--verifyone. DuckDB is free; BigQuery prices per test and per mutant with dry runs;
Snowflake uses seconds, with the resume-minimum floor.
skills/transform/SKILL.md: when to reach for--mutate(after writing tests,before trusting them), and how to read survivors and
suggested_test. Also oneeval case in
skills/transform/evals/evals.json.references/methodology.md: a short "Test strength" section.CHANGELOG.md[Unreleased]### Added, in house style