Skip to content

Commit 869248c

Browse files
feature: adding smarter unit test execution (#5966)
Signed-off-by: Cortland Goffena <30168413+cmgoffena13@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cc30650 commit 869248c

18 files changed

Lines changed: 353 additions & 28 deletions

File tree

‎docs/concepts/overview.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ Bad data is worse than no data. The best way to keep bad data out of your system
5252
### [Tests](./tests.md)
5353
SQLMesh "tests" are similar to unit tests in software development, where the unit is a single model. SQLMesh tests validate model *code* &mdash; you specify the input data and expected output, then SQLMesh runs the test and compares the expected and actual output.
5454

55-
SQLMesh automatically runs tests when you apply a `plan`, or you can run them on demand with the [`test` command](../reference/cli.md#test).
55+
SQLMesh automatically runs all unit tests when a `plan` is created. Use `--test-changed-only` to run tests only for models included in the plan (added, modified, or restated), `--skip-tests` to skip, run tests for specific models with [`sqlmesh test --select-model`](../reference/cli.md#test), or run the full suite on demand with the [`test` command](../reference/cli.md#test).
56+
57+
Learn more in the [testing guide](../guides/testing.md).
5658

5759
### [Audits](./audits.md)
5860
In contrast to tests, SQLMesh "audits" validate the results of model code applied to your actual data.

‎docs/concepts/tests.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Testing
22

3-
Testing allows you to protect your project from regression by continuously verifying that the output of each model matches your expectations. Unlike [audits](audits.md), tests are executed either on demand (for example, as part of a CI/CD job) or every time a new [plan](plans.md) is created.
3+
Testing allows you to protect your project from regression by continuously verifying that the output of each model matches your expectations. Unlike [audits](audits.md), tests are executed either on demand (for example, as part of a CI/CD job or via [`sqlmesh test`](../reference/cli.md#test)) or when a new [plan](plans.md) is created.
4+
5+
By default, `sqlmesh plan` runs all unit tests. Use `--test-changed-only` to run tests only for models included in the plan (added, modified, or restated), or `--skip-tests` to run none. With both `--select-model` and `--test-changed-only`, tests run only for selected models that changed.
46

57
Similar to unit testing in software development, SQLMesh evaluates the model's logic against predefined inputs and then compares the output to expected outcomes provided as part of each test.
68

‎docs/faq/faq.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
SQLMesh's default behavior is appropriate for most deployments, but you can override where SQLMesh creates physical tables and views with [schema configuration options](../guides/configuration.md#environment-schemas).
103103

104104
??? question "What's the difference between a `test` and an `audit`?"
105-
A SQLMesh [`test`](../concepts/tests.md) is analogous to a "unit test" in software engineering. It tests *code* based on known inputs and outputs. In SQLMesh, the inputs and outputs are specified in a YAML file, and SQLMesh automatically runs them when `sqlmesh plan` is executed.
105+
A SQLMesh [`test`](../concepts/tests.md) is analogous to a "unit test" in software engineering. It tests *code* based on known inputs and outputs. In SQLMesh, the inputs and outputs are specified in a YAML file, and SQLMesh runs all unit tests when `sqlmesh plan` is executed (use `--test-changed-only` to run only tests for models in the plan).
106106

107107
Writing YAML is annoying and error-prone, so SQLMesh's [`create_test` command](../concepts/tests.md#automatic-test-generation) allows you to automatically generate YAML test files based on queries of existing data tables.
108108

‎docs/guides/models.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Reverting to a previous model version is a quick operation since no additional w
161161
162162
SQLMesh automatically validates your models in order to ensure the quality and accuracy of your data. This is done via the following:
163163
164-
* Running unit tests by default when you execute the `plan` command. This ensures all changes to applied to any environment are logically validated. Refer to [testing](../concepts/tests.md) for more information.
164+
* Running all unit tests when you execute the `plan` command (use `--test-changed-only` to run only tests for models in the plan). This ensures changes applied to any environment are logically validated. Refer to [testing](../concepts/tests.md) for more information.
165165
* Running audits whenever data is loaded to a table (either for backfill or loading on a cadence). This way you know all data present in any table has passed all defined audits. Refer to [auditing](../concepts/audits.md) for more information.
166166
167167
SQLMesh also provides automatic validation via CI/CD by automatically creating a preview environment.

‎docs/guides/testing.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,19 @@ OK
1212
```
1313
As the unit tests run, SQLMesh will identify any that fail.
1414

15+
By default, `sqlmesh plan` runs all unit tests. Use `--test-changed-only` to run tests only for models included in the plan (added, modified, or restated), or `--skip-tests` to run none. With both `--select-model` and `--test-changed-only`, tests run only for selected models that changed.
16+
1517
For more information about tests, refer to [testing](../concepts/tests.md).
1618

1719
### Test changes to a specific model
1820

19-
To run a specific model test, pass in the suite file name followed by `::` and the name of the test; for example: `sqlmesh test tests/test_suite.yaml::test_example_full_model`.
21+
To run unit tests for a specific model, use `--select-model`:
22+
23+
```bash
24+
$ sqlmesh test --select-model sqlmesh_example.full_model
25+
```
26+
27+
Alternatively, pass in the suite file name followed by `::` and the name of the test; for example: `sqlmesh test tests/test_suite.yaml::test_example_full_model`.
2028

2129
### Run a subset of tests
2230

‎docs/reference/cli.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ Options:
357357
Default: prod.
358358
--skip-tests Skip tests prior to generating the plan if
359359
they are defined.
360+
--test-changed-only Run unit tests only for models included in
361+
the plan instead of all tests.
360362
--skip-linter Skip linting prior to generating the plan if
361363
the linter is enabled.
362364
-r, --restate-model TEXT Restate data for specified models and models
@@ -626,6 +628,8 @@ Options:
626628
-v, --verbose Verbose output.
627629
--preserve-fixtures Preserve the fixture tables in the testing database,
628630
useful for debugging.
631+
--select-model TEXT Select specific models to run unit tests for. Can be
632+
specified multiple times.
629633
--help Show this message and exit.
630634
```
631635

‎docs/reference/notebook.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ options:
9696
#### plan
9797
```
9898
%plan [--start START] [--end END] [--execution-time EXECUTION_TIME]
99-
[--create-from CREATE_FROM] [--skip-tests]
99+
[--create-from CREATE_FROM] [--skip-tests] [--test-changed-only]
100100
[--restate-model [RESTATE_MODEL ...]] [--no-gaps]
101101
[--skip-backfill, --dry-run] [--forward-only]
102102
[--effective-from EFFECTIVE_FROM] [--no-prompts] [--auto-apply]
@@ -120,6 +120,8 @@ options:
120120
The environment to create the target environment from
121121
if it doesn't exist. Default: prod.
122122
--skip-tests, -t Skip the unit tests defined for the model.
123+
--test-changed-only Run unit tests only for models included in the plan
124+
instead of all tests.
123125
--restate-model <[RESTATE_MODEL ...]>, -r <[RESTATE_MODEL ...]>
124126
Restate data for specified models (and models
125127
downstream from the one specified). For production
@@ -131,7 +133,8 @@ options:
131133
comparing to existing snapshots for matching models in
132134
the target environment.
133135
--skip-backfill, --dry-run
134-
Skip the backfill step and only create a virtual update for the plan.
136+
Skip the backfill step and only create a virtual
137+
update for the plan.
135138
--forward-only Create a plan for forward-only changes.
136139
--effective-from EFFECTIVE_FROM
137140
The effective date from which to apply forward-only
@@ -430,7 +433,8 @@ options:
430433

431434
#### run_test
432435
```
433-
%run_test [--pattern [PATTERN ...]] [--verbose] [--preserve-fixtures] [tests ...]
436+
%run_test [--pattern [PATTERN ...]] [--verbose] [--preserve-fixtures]
437+
[--select-model [SELECT_MODEL ...]] [tests ...]
434438
435439
Run unit test(s).
436440
@@ -443,6 +447,8 @@ options:
443447
--verbose, -v Verbose output.
444448
--preserve-fixtures Preserve the fixture tables in the testing database,
445449
useful for debugging.
450+
--select-model <[SELECT_MODEL ...]>
451+
Select specific models to run unit tests for.
446452
```
447453

448454
#### audit

‎sqlmesh/cli/main.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,12 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None:
427427
help="Skip tests prior to generating the plan if they are defined.",
428428
default=None,
429429
)
430+
@click.option(
431+
"--test-changed-only",
432+
is_flag=True,
433+
help="Run unit tests only for models included in the plan instead of all tests.",
434+
default=None,
435+
)
430436
@click.option(
431437
"--skip-linter",
432438
is_flag=True,
@@ -799,6 +805,12 @@ def create_test(
799805
default=False,
800806
help="Preserve the fixture tables in the testing database, useful for debugging.",
801807
)
808+
@click.option(
809+
"--select-model",
810+
type=str,
811+
multiple=True,
812+
help="Select specific models to run unit tests for.",
813+
)
802814
@click.argument("tests", nargs=-1)
803815
@click.pass_obj
804816
@error_handler
@@ -808,14 +820,19 @@ def test(
808820
k: t.List[str],
809821
verbose: int,
810822
preserve_fixtures: bool,
823+
select_model: t.List[str],
811824
tests: t.List[str],
812825
) -> None:
813826
"""Run model unit tests."""
827+
model_names = (
828+
obj._new_selector().expand_model_selections(select_model) if select_model else None
829+
)
814830
result = obj.test(
815831
match_patterns=k,
816832
tests=tests,
817833
verbosity=Verbosity(verbose),
818834
preserve_fixtures=preserve_fixtures,
835+
model_names=model_names,
819836
)
820837
if not result.wasSuccessful():
821838
exit(1)

‎sqlmesh/core/console.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2232,6 +2232,9 @@ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) ->
22322232
message = (
22332233
f"Ran {result.testsRun} tests against {target_dialect} in {result.duration} seconds."
22342234
)
2235+
if result.tests_skipped:
2236+
message = f"{message}\nSkipped {result.tests_skipped} tests"
2237+
22352238
if result.wasSuccessful():
22362239
self._print("=" * divider_length)
22372240
self._print(
@@ -3160,6 +3163,8 @@ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) ->
31603163
message = (
31613164
f"Ran {result.testsRun} tests against {target_dialect} in {result.duration} seconds."
31623165
)
3166+
if result.tests_skipped:
3167+
message = f"{message}\nSkipped {result.tests_skipped} tests"
31633168

31643169
if result.wasSuccessful():
31653170
success_color = {"color": "#008000"}
@@ -3600,6 +3605,8 @@ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) ->
36003605
return
36013606

36023607
message = f"Ran `{result.testsRun}` Tests Against `{target_dialect}`"
3608+
if result.tests_skipped:
3609+
message = f"{message}\n**Skipped `{result.tests_skipped}` Tests**"
36033610

36043611
if result.wasSuccessful():
36053612
self._print(f"**Successfully {message}**\n\n")

‎sqlmesh/core/context.py‎

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
ModelTestMetadata,
116116
generate_test,
117117
run_tests,
118+
filter_tests_by_model_names,
118119
filter_tests_by_patterns,
119120
)
120121
from sqlmesh.core.user import User
@@ -1416,6 +1417,7 @@ def plan(
14161417
execution_time: t.Optional[TimeLike] = None,
14171418
create_from: t.Optional[str] = None,
14181419
skip_tests: t.Optional[bool] = None,
1420+
test_changed_only: t.Optional[bool] = None,
14191421
restate_models: t.Optional[t.Iterable[str]] = None,
14201422
no_gaps: t.Optional[bool] = None,
14211423
skip_backfill: t.Optional[bool] = None,
@@ -1453,6 +1455,7 @@ def plan(
14531455
create_from: The environment to create the target environment from if it
14541456
doesn't exist. If not specified, the "prod" environment will be used.
14551457
skip_tests: Unit tests are run by default so this will skip them if enabled
1458+
test_changed_only: Run unit tests only for models included in the plan instead of all tests
14561459
restate_models: A list of either internal or external models, or tags, that need to be restated
14571460
for the given plan interval. If the target environment is a production environment,
14581461
ALL snapshots that depended on these upstream tables will have their intervals deleted
@@ -1499,6 +1502,7 @@ def plan(
14991502
execution_time=execution_time,
15001503
create_from=create_from,
15011504
skip_tests=skip_tests,
1505+
test_changed_only=test_changed_only,
15021506
restate_models=restate_models,
15031507
no_gaps=no_gaps,
15041508
skip_backfill=skip_backfill,
@@ -1553,6 +1557,7 @@ def plan_builder(
15531557
execution_time: t.Optional[TimeLike] = None,
15541558
create_from: t.Optional[str] = None,
15551559
skip_tests: t.Optional[bool] = None,
1560+
test_changed_only: t.Optional[bool] = None,
15561561
restate_models: t.Optional[t.Iterable[str]] = None,
15571562
no_gaps: t.Optional[bool] = None,
15581563
skip_backfill: t.Optional[bool] = None,
@@ -1587,6 +1592,7 @@ def plan_builder(
15871592
create_from: The environment to create the target environment from if it
15881593
doesn't exist. If not specified, the "prod" environment will be used.
15891594
skip_tests: Unit tests are run by default so this will skip them if enabled
1595+
test_changed_only: Run unit tests only for models included in the plan instead of all tests
15901596
restate_models: A list of either internal or external models, or tags, that need to be restated
15911597
for the given plan interval. If the target environment is a production environment,
15921598
ALL snapshots that depended on these upstream tables will have their intervals deleted
@@ -1628,6 +1634,7 @@ def plan_builder(
16281634
"execution_time": execution_time,
16291635
"create_from": create_from,
16301636
"skip_tests": skip_tests,
1637+
"test_changed_only": test_changed_only,
16311638
"restate_models": list(restate_models) if restate_models is not None else None,
16321639
"no_gaps": no_gaps,
16331640
"skip_backfill": skip_backfill,
@@ -1657,6 +1664,11 @@ def plan_builder(
16571664
}
16581665

16591666
skip_tests = explain or skip_tests or False
1667+
test_changed_only = test_changed_only or False
1668+
1669+
if skip_tests and test_changed_only:
1670+
raise PlanError("Cannot combine --skip-tests with --test-changed-only.")
1671+
16601672
no_gaps = no_gaps or False
16611673
skip_backfill = skip_backfill or False
16621674
empty_backfill = empty_backfill or False
@@ -1683,8 +1695,6 @@ def plan_builder(
16831695
if not skip_linter:
16841696
self.lint_models()
16851697

1686-
self._run_plan_tests(skip_tests=skip_tests)
1687-
16881698
environment_ttl = (
16891699
self.environment_ttl if environment not in self.pinned_environments else None
16901700
)
@@ -1767,6 +1777,19 @@ def plan_builder(
17671777
*[s.name for s in context_diff.added],
17681778
}
17691779

1780+
plan_test_model_names: t.Set[str] = {
1781+
*modified_model_names,
1782+
*(expanded_restate_models or set()),
1783+
}
1784+
if select_models and test_changed_only:
1785+
plan_test_model_names &= selected_fqns
1786+
1787+
self._run_plan_tests(
1788+
skip_tests=skip_tests,
1789+
test_changed_only=test_changed_only,
1790+
model_names=plan_test_model_names,
1791+
)
1792+
17701793
if (
17711794
is_dev
17721795
and not include_unmodified
@@ -2383,14 +2406,23 @@ def test(
23832406
verbosity: Verbosity = Verbosity.DEFAULT,
23842407
preserve_fixtures: bool = False,
23852408
stream: t.Optional[t.TextIO] = None,
2409+
model_names: t.Optional[t.Collection[str]] = None,
23862410
) -> ModelTextTestResult:
23872411
"""Discover and run model tests"""
23882412
if verbosity >= Verbosity.VERBOSE:
23892413
import pandas as pd
23902414

23912415
pd.set_option("display.max_columns", None)
23922416

2393-
test_meta = self.select_tests(tests=tests, patterns=match_patterns)
2417+
baseline_meta = self.select_tests(tests=tests, patterns=match_patterns, model_names=None)
2418+
if model_names is not None:
2419+
test_meta = self.select_tests(
2420+
tests=tests, patterns=match_patterns, model_names=model_names
2421+
)
2422+
tests_skipped = len(baseline_meta) - len(test_meta)
2423+
else:
2424+
test_meta = baseline_meta
2425+
tests_skipped = 0
23942426

23952427
result = run_tests(
23962428
model_test_metadata=test_meta,
@@ -2404,6 +2436,7 @@ def test(
24042436
default_catalog=self.default_catalog,
24052437
default_catalog_dialect=self.config.dialect or "",
24062438
)
2439+
result.tests_skipped = tests_skipped
24072440

24082441
self.console.log_test_results(
24092442
result,
@@ -2850,15 +2883,28 @@ def _run_tests(
28502883
result = self.test(stream=test_output_io, verbosity=verbosity)
28512884
return result, test_output_io.getvalue()
28522885

2853-
def _run_plan_tests(self, skip_tests: bool = False) -> t.Optional[ModelTextTestResult]:
2854-
if not skip_tests:
2855-
result = self.test()
2856-
if not result.wasSuccessful():
2857-
raise PlanError(
2858-
"Cannot generate plan due to failing test(s). Fix test(s) and run again."
2859-
)
2860-
return result
2861-
return None
2886+
def _run_plan_tests(
2887+
self,
2888+
skip_tests: bool = False,
2889+
test_changed_only: bool = False,
2890+
model_names: t.Optional[t.Collection[str]] = None,
2891+
) -> t.Optional[ModelTextTestResult]:
2892+
if skip_tests:
2893+
return None
2894+
2895+
effective_names: t.Optional[t.Set[str]] = None
2896+
2897+
if test_changed_only:
2898+
effective_names = set(model_names or [])
2899+
if not effective_names:
2900+
return None
2901+
2902+
result = self.test(model_names=effective_names)
2903+
if not result.wasSuccessful():
2904+
raise PlanError(
2905+
"Cannot generate plan due to failing test(s). Fix test(s) and run again."
2906+
)
2907+
return result
28622908

28632909
def _warn_if_virtual_catalog_rematerialization(self, plan: "Plan") -> None:
28642910
"""Warn when ClickHouse models appear as new snapshots solely because a virtual catalog
@@ -3569,6 +3615,7 @@ def select_tests(
35693615
self,
35703616
tests: t.Optional[t.List[str]] = None,
35713617
patterns: t.Optional[t.List[str]] = None,
3618+
model_names: t.Optional[t.Collection[str]] = None,
35723619
) -> t.List[ModelTestMetadata]:
35733620
"""Filter pre-loaded test metadata based on tests and patterns."""
35743621

@@ -3592,6 +3639,14 @@ def select_tests(
35923639
if patterns:
35933640
test_meta = filter_tests_by_patterns(test_meta, patterns)
35943641

3642+
if model_names is not None:
3643+
test_meta = filter_tests_by_model_names(
3644+
test_meta,
3645+
set(model_names),
3646+
default_catalog=self.default_catalog,
3647+
dialect=self.default_dialect,
3648+
)
3649+
35953650
return test_meta
35963651

35973652

0 commit comments

Comments
 (0)