From e947825d718b27928777644358d28efe3260ad0e Mon Sep 17 00:00:00 2001 From: Adegbite Ayoade Date: Sat, 12 Sep 2026 22:18:24 +0100 Subject: [PATCH 1/2] feat(test): add --local to run tests without loading state `sqlmesh lint --local` skips loading remote state, but `test` had no equivalent, so a commit hook or an offline unit-test run still opened a connection to the state backend. Handle it the same way lint does: the flag is declared on the command with expose_value=False and the gating happens in the group callback, which is where Context is constructed before the subcommand runs. The two commands now share a single OPTIONAL_LOCAL_COMMANDS tuple rather than each special-casing its own name. As with lint, multi-repository projects that depend on models which exist only in remote state may see missing-reference errors under --local; this is documented alongside the same caveat for lint. Signed-off-by: Adegbite Ayoade --- docs/concepts/tests.md | 8 +++++++ docs/reference/cli.md | 6 +++++ sqlmesh/cli/main.py | 16 +++++++++++-- tests/cli/test_cli.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/concepts/tests.md b/docs/concepts/tests.md index a293237687..b61161daba 100644 --- a/docs/concepts/tests.md +++ b/docs/concepts/tests.md @@ -463,6 +463,14 @@ You can also run tests that match a pattern or substring using a glob pathname e $ sqlmesh test tests/test_* ``` +You can pass `--local` to run tests without loading state from the configured state connection: + +``` bash +$ sqlmesh test --local +``` + +This keeps offline runs and commit hooks from opening a connection to the state backend. As with [`sqlmesh lint --local`](../guides/linter.md), in multi-repository setups, or when running tests for only a subset of projects, `--local` may cause errors because SQLMesh will not resolve references or schemas from models that exist only in remote state. + ### Testing using notebooks You can execute tests on demand using the `%run_test` notebook magic as follows: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1367f8551b..536e762aa0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -630,6 +630,12 @@ Options: useful for debugging. --select-model TEXT Select specific models to run unit tests for. Can be specified multiple times. + --local Run tests using only locally loaded project files + without loading state. In multi-repository setups, or + when running tests for only a subset of projects, this + may cause errors because SQLMesh will not resolve + references or schemas from models that exist only in + remote state. --help Show this message and exit. ``` diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index b6678136f0..f735646950 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -42,6 +42,8 @@ ) SKIP_CONTEXT_COMMANDS = ("init", "ui") LOCAL_ONLY_COMMANDS = ("format",) +# Commands that are local-only when they're passed --local. +OPTIONAL_LOCAL_COMMANDS = ("lint", "test") class _SQLMeshGroup(click.Group): @@ -129,8 +131,12 @@ def cli( load = True # Local-only gating must hold for any number of --paths, so it stays outside the block below. load_state = ctx.invoked_subcommand not in LOCAL_ONLY_COMMANDS - # The parent callback constructs Context before Click invokes `lint`, so inspect its parsed args here. - if ctx.invoked_subcommand == "lint" and "--local" in ctx.meta["subcommand_args"]: + # The parent callback constructs Context before Click invokes the subcommand, so inspect its + # parsed args here. + if ( + ctx.invoked_subcommand in OPTIONAL_LOCAL_COMMANDS + and "--local" in ctx.meta["subcommand_args"] + ): load_state = False if len(paths) == 1: @@ -811,6 +817,12 @@ def create_test( multiple=True, help="Select specific models to run unit tests for.", ) +@click.option( + "--local", + is_flag=True, + expose_value=False, + help="Run tests using only locally loaded project files without loading state.", +) @click.argument("tests", nargs=-1) @click.pass_obj @error_handler diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index da73952991..d529b1c4a0 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -2530,6 +2530,59 @@ def test_lint_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker mock.assert_not_called() +def test_test_still_loads_state(runner: CliRunner, tmp_path: Path, mocker): + """Guard that `test` explicitly passes `load_state=True` and still reaches state sync.""" + mock = _setup_local_only_project(tmp_path, mocker) + init_spy = mocker.spy(Context, "__init__") + + runner.invoke(cli, ["--paths", str(tmp_path), "test"]) + + assert init_spy.called, "Context was never constructed" + for call in init_spy.call_args_list: + assert "load_state" in call.kwargs, ( + "CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently" + ) + assert call.kwargs["load_state"] is True, ( + f"Context was constructed with load_state={call.kwargs['load_state']} for `test`" + ) + assert mock.called, "state-sync was never accessed during `test`" + + +def test_test_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker): + mock = _setup_local_only_project(tmp_path, mocker) + init_spy = mocker.spy(Context, "__init__") + + result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"]) + + assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}" + assert init_spy.called, "Context was never constructed" + for call in init_spy.call_args_list: + assert "load_state" in call.kwargs, ( + "CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently" + ) + assert call.kwargs["load_state"] is False, ( + f"Context was constructed with load_state={call.kwargs['load_state']} for `test --local`" + ) + mock.assert_not_called() + + +def test_test_local_runs_without_state_multiple_paths( + runner: CliRunner, tmp_path: Path, mocker +) -> None: + """`--local` gating must hold for any number of --paths, matching `lint --local`.""" + project_a = tmp_path / "a" + project_b = tmp_path / "b" + _create_local_only_project(project_a, "proj_a") + _create_local_only_project(project_b, "proj_b") + mock = _patch_state_access(mocker) + + result = runner.invoke( + cli, ["--paths", str(project_a), "--paths", str(project_b), "test", "--local"] + ) + assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}" + mock.assert_not_called() + + @pytest.mark.parametrize("command", ["format"]) def test_local_only_commands_skip_state_multiple_paths( runner: CliRunner, tmp_path: Path, mocker, command: str From 3b090c52429a898767d0ea6b3e64cf140abbaecc Mon Sep 17 00:00:00 2001 From: Adegbite Ayoade Date: Tue, 15 Sep 2026 13:29:45 +0100 Subject: [PATCH 2/2] test(cli): cover --local against real unit tests and a multi-repo project Adds the three cases asked for in review: - a real unit test from the example project's YAML runs under --local, exits 0 and never reaches state sync - a twin of test_format_does_not_open_state_connection, so a configured remote Postgres state connection is not opened when its env vars are unset - examples/multi with only repo_2 given, which pins the behavioural difference against lint: the same run reaches state without --local, and with --local the test for bronze.a (defined in repo_1, so not loaded) warns and is skipped while repo_2's own test still runs Also corrects the documented behaviour. The description said --local "may cause errors"; in fact create_test logs a warning and returns None when a model is missing, so the suite passes. The docs now say the test is skipped with a warning, show the output, and point out that a green exit code does not mean every expected test ran. The --local help text carries the same correction, and the entry on the CLI reference page is trimmed back to mirror the real --help output rather than adding prose the command never prints. Signed-off-by: Adegbite Ayoade --- docs/concepts/tests.md | 11 +++- docs/reference/cli.md | 7 +-- sqlmesh/cli/main.py | 2 +- tests/cli/test_cli.py | 112 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/docs/concepts/tests.md b/docs/concepts/tests.md index b61161daba..a9bd45a25a 100644 --- a/docs/concepts/tests.md +++ b/docs/concepts/tests.md @@ -469,7 +469,16 @@ You can pass `--local` to run tests without loading state from the configured st $ sqlmesh test --local ``` -This keeps offline runs and commit hooks from opening a connection to the state backend. As with [`sqlmesh lint --local`](../guides/linter.md), in multi-repository setups, or when running tests for only a subset of projects, `--local` may cause errors because SQLMesh will not resolve references or schemas from models that exist only in remote state. +This keeps offline runs and commit hooks from opening a connection to the state backend. + +In multi-repository setups, or when running tests for only a subset of projects, models that exist only in remote state are not loaded under `--local`. Unlike [`sqlmesh lint --local`](../guides/linter.md), which reports additional errors in that situation, a test whose model is missing is **skipped with a warning and the run still succeeds**: + +``` +[WARNING] Model '"memory"."bronze"."a"' was not found at tests/test_a.yaml +.**Successfully Ran `1` Tests Against `duckdb`** +``` + +So a passing exit code alone does not mean every test you expected actually ran. Watch the output for these warnings, and keep in mind that a hook using `--local` will not fail on them. ### Testing using notebooks diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 536e762aa0..1ed03535b9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -631,11 +631,8 @@ Options: --select-model TEXT Select specific models to run unit tests for. Can be specified multiple times. --local Run tests using only locally loaded project files - without loading state. In multi-repository setups, or - when running tests for only a subset of projects, this - may cause errors because SQLMesh will not resolve - references or schemas from models that exist only in - remote state. + without loading state. Tests whose model is not loaded + are skipped with a warning rather than failing. --help Show this message and exit. ``` diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index f735646950..c2c81ada9c 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -821,7 +821,7 @@ def create_test( "--local", is_flag=True, expose_value=False, - help="Run tests using only locally loaded project files without loading state.", + help="Run tests using only locally loaded project files without loading state. Tests whose model is not loaded are skipped with a warning rather than failing.", ) @click.argument("tests", nargs=-1) @click.pass_obj diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index d529b1c4a0..b52df5394a 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -2664,3 +2664,115 @@ def test_format_does_not_open_state_connection( result = runner.invoke(cli, ["--paths", str(tmp_path), "format"]) assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}" mock.assert_not_called() + + +def test_test_local_runs_project_unit_tests(runner: CliRunner, tmp_path: Path, mocker) -> None: + """A real unit test from the project's YAML runs under `--local` without touching state.""" + create_example_project(tmp_path) + mock = _patch_state_access(mocker) + + result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"]) + + assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}" + assert "Successfully Ran 1 tests" in " ".join(result.output.split()) + mock.assert_not_called() + + +def test_test_local_does_not_open_state_connection( + runner: CliRunner, tmp_path: Path, mocker, monkeypatch +) -> None: + """`test --local` must not open a configured remote Postgres state connection.""" + pytest.importorskip("psycopg2") + + for var in ("PG_HOST", "PG_USER", "PG_PASSWORD", "PG_DATABASE"): + monkeypatch.delenv(var, raising=False) + + create_example_project(tmp_path) + (tmp_path / "config.yaml").write_text( + """project: cli_test + +gateways: + prod: + state_connection: + type: postgres + host: "{{ env_var('PG_HOST', 'postgres.internal.example.com') }}" + port: 5432 + user: "{{ env_var('PG_USER') }}" + password: "{{ env_var('PG_PASSWORD') }}" + database: "{{ env_var('PG_DATABASE', 'sqlmesh_state') }}" + connection: + type: duckdb + database: "warehouse.db" + +default_gateway: prod + +model_defaults: + dialect: duckdb +""", + encoding="utf-8", + ) + + mock = _patch_state_access(mocker) + + result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"]) + assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}" + mock.assert_not_called() + + +def test_test_local_multi_repo_partial(runner: CliRunner, copy_to_temp_path, mocker) -> None: + """Run tests for one repo of a multi-repo project whose upstream models live only in state. + + Pins the behavioral difference against `lint --local`: a model that isn't loaded produces a + warning and its test is skipped, rather than turning into an error. + """ + repo_2 = copy_to_temp_path("examples/multi")[0] / "repo_2" + + # silver.c lives in repo_2 and its upstream bronze.a is supplied as a test input. + (repo_2 / "tests" / "test_c.yaml").write_text( + """test_silver_c: + model: silver.c + inputs: + bronze.a: + rows: + - col_a: 1 + - col_a: 1 + - col_a: 2 + outputs: + query: + rows: + - col_a: 1 + - col_a: 2 +""", + encoding="utf-8", + ) + # bronze.a itself is defined in repo_1, so it is not loaded when only repo_2 is given. + (repo_2 / "tests" / "test_a.yaml").write_text( + """test_bronze_a: + model: bronze.a + outputs: + query: + rows: + - col_a: 1 +""", + encoding="utf-8", + ) + + mock = _patch_state_access(mocker) + args = ["--gateway", "memory", "--paths", str(repo_2), "test"] + + # Without --local the same run reaches the state backend. + runner.invoke(cli, args) + assert mock.called, "state-sync was never accessed during `test`" + + mock.reset_mock() + + result = runner.invoke(cli, [*args, "--local"]) + + assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}" + # Console output wraps, so compare against whitespace-normalized text. + output = " ".join(result.output.split()) + assert 'Model \'"memory"."bronze"."a"\' was not found' in output, ( + "the unloaded model should warn rather than fail" + ) + assert "Successfully Ran 1 tests" in output, "the repo_2 test should still run" + mock.assert_not_called()