Skip to content

Commit e7e4841

Browse files
izeigermanCopilot
andauthored
Chore: Break up the core integration tests (#5432)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 97c6a12 commit e7e4841

22 files changed

+11437
-10891
lines changed

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,13 @@ engine-up: engine-clickhouse-up engine-mssql-up engine-mysql-up engine-postgres-
117117
engine-down: engine-clickhouse-down engine-mssql-down engine-mysql-down engine-postgres-down engine-spark-down engine-trino-down
118118

119119
fast-test:
120-
pytest -n auto -m "fast and not cicdonly" --junitxml=test-results/junit-fast-test.xml && pytest -m "isolated" && pytest -m "registry_isolation"
120+
pytest -n auto -m "fast and not cicdonly" --junitxml=test-results/junit-fast-test.xml && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated"
121121

122122
slow-test:
123-
pytest -n auto -m "(fast or slow) and not cicdonly" && pytest -m "isolated" && pytest -m "registry_isolation"
123+
pytest -n auto -m "(fast or slow) and not cicdonly" && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated"
124124

125125
cicd-test:
126-
pytest -n auto -m "fast or slow" --junitxml=test-results/junit-cicd.xml && pytest -m "isolated" && pytest -m "registry_isolation"
126+
pytest -n auto -m "fast or slow" --junitxml=test-results/junit-cicd.xml && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated"
127127

128128
core-fast-test:
129129
pytest -n auto -m "fast and not web and not github and not dbt and not jupyter"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ markers = [
241241
"remote: test that involves interacting with a remote DB",
242242
"cicdonly: test that only runs on CI/CD",
243243
"isolated: tests that need to run sequentially usually because they use fork",
244+
"dialect_isolated: tests that need to run separately due to global dialect overrides",
244245

245246
# Test Domain Markers
246247
# default: core functionality

tests/conftest.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,15 @@ def validate(
202202

203203

204204
def pytest_collection_modifyitems(items, *args, **kwargs):
205-
test_type_markers = {"fast", "slow", "docker", "remote", "isolated", "registry_isolation"}
205+
test_type_markers = {
206+
"fast",
207+
"slow",
208+
"docker",
209+
"remote",
210+
"isolated",
211+
"registry_isolation",
212+
"dialect_isolated",
213+
}
206214
for item in items:
207215
for marker in item.iter_markers():
208216
if marker.name in test_type_markers:

tests/core/integration/__init__.py

Whitespace-only changes.

tests/core/integration/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
from pytest_mock.plugin import MockerFixture
3+
4+
5+
@pytest.fixture(autouse=True)
6+
def mock_choices(mocker: MockerFixture):
7+
mocker.patch("sqlmesh.core.console.TerminalConsole._get_snapshot_change_category")
8+
mocker.patch("sqlmesh.core.console.TerminalConsole._prompt_backfill")
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
from __future__ import annotations
2+
3+
import typing as t
4+
from textwrap import dedent
5+
import pytest
6+
from pathlib import Path
7+
import time_machine
8+
from sqlglot import exp
9+
from IPython.utils.capture import capture_output
10+
11+
from sqlmesh.core.config import (
12+
Config,
13+
ModelDefaultsConfig,
14+
)
15+
from sqlmesh.core.context import Context
16+
from sqlmesh.utils.errors import (
17+
PlanError,
18+
)
19+
from tests.utils.test_helpers import use_terminal_console
20+
from tests.utils.test_filesystem import create_temp_file
21+
22+
pytestmark = pytest.mark.slow
23+
24+
25+
@time_machine.travel("2023-01-08 15:00:00 UTC")
26+
@use_terminal_console
27+
def test_audit_only_metadata_change(init_and_plan_context: t.Callable):
28+
context, plan = init_and_plan_context("examples/sushi")
29+
context.apply(plan)
30+
31+
# Add a new audit
32+
model = context.get_model("sushi.waiter_revenue_by_day")
33+
audits = model.audits.copy()
34+
audits.append(("number_of_rows", {"threshold": exp.Literal.number(1)}))
35+
model = model.copy(update={"audits": audits})
36+
context.upsert_model(model)
37+
38+
plan = context.plan_builder("prod", skip_tests=True).build()
39+
assert len(plan.new_snapshots) == 2
40+
assert all(s.change_category.is_metadata for s in plan.new_snapshots)
41+
assert not plan.missing_intervals
42+
43+
with capture_output() as output:
44+
context.apply(plan)
45+
46+
assert "Auditing models" in output.stdout
47+
assert model.name in output.stdout
48+
49+
50+
@use_terminal_console
51+
def test_audits_running_on_metadata_changes(tmp_path: Path):
52+
def setup_senario(model_before: str, model_after: str):
53+
models_dir = Path("models")
54+
create_temp_file(tmp_path, models_dir / "test.sql", model_before)
55+
56+
# Create first snapshot
57+
context = Context(paths=tmp_path, config=Config())
58+
context.plan("prod", no_prompts=True, auto_apply=True)
59+
60+
# Create second (metadata) snapshot
61+
create_temp_file(tmp_path, models_dir / "test.sql", model_after)
62+
context.load()
63+
64+
with capture_output() as output:
65+
with pytest.raises(PlanError):
66+
context.plan("prod", no_prompts=True, auto_apply=True)
67+
68+
assert 'Failed models\n\n "model"' in output.stdout
69+
70+
return output
71+
72+
# Ensure incorrect audits (bad data, incorrect definition etc) are evaluated immediately
73+
output = setup_senario(
74+
"MODEL (name model); SELECT NULL AS col",
75+
"MODEL (name model, audits (not_null(columns=[col]))); SELECT NULL AS col",
76+
)
77+
assert "'not_null' audit error: 1 row failed" in output.stdout
78+
79+
output = setup_senario(
80+
"MODEL (name model); SELECT NULL AS col",
81+
"MODEL (name model, audits (not_null(columns=[this_col_does_not_exist]))); SELECT NULL AS col",
82+
)
83+
assert (
84+
'Binder Error: Referenced column "this_col_does_not_exist" not found in \nFROM clause!'
85+
in output.stdout
86+
)
87+
88+
89+
@pytest.mark.slow
90+
def test_default_audits_applied_in_plan(tmp_path: Path):
91+
models_dir = tmp_path / "models"
92+
models_dir.mkdir(exist_ok=True)
93+
94+
# Create a model with data that will pass the audits
95+
create_temp_file(
96+
tmp_path,
97+
models_dir / "orders.sql",
98+
dedent("""
99+
MODEL (
100+
name test.orders,
101+
kind FULL
102+
);
103+
104+
SELECT
105+
1 AS order_id,
106+
'customer_1' AS customer_id,
107+
100.50 AS amount,
108+
'2024-01-01'::DATE AS order_date
109+
UNION ALL
110+
SELECT
111+
2 AS order_id,
112+
'customer_2' AS customer_id,
113+
200.75 AS amount,
114+
'2024-01-02'::DATE AS order_date
115+
"""),
116+
)
117+
118+
config = Config(
119+
model_defaults=ModelDefaultsConfig(
120+
dialect="duckdb",
121+
audits=[
122+
"not_null(columns := [order_id, customer_id])",
123+
"unique_values(columns := [order_id])",
124+
],
125+
)
126+
)
127+
128+
context = Context(paths=tmp_path, config=config)
129+
130+
# Create and apply plan, here audits should pass
131+
plan = context.plan("prod", no_prompts=True)
132+
context.apply(plan)
133+
134+
# Verify model has the default audits
135+
model = context.get_model("test.orders")
136+
assert len(model.audits) == 2
137+
138+
audit_names = [audit[0] for audit in model.audits]
139+
assert "not_null" in audit_names
140+
assert "unique_values" in audit_names
141+
142+
# Verify audit arguments are preserved
143+
for audit_name, audit_args in model.audits:
144+
if audit_name == "not_null":
145+
assert "columns" in audit_args
146+
columns = [col.name for col in audit_args["columns"].expressions]
147+
assert "order_id" in columns
148+
assert "customer_id" in columns
149+
elif audit_name == "unique_values":
150+
assert "columns" in audit_args
151+
columns = [col.name for col in audit_args["columns"].expressions]
152+
assert "order_id" in columns
153+
154+
155+
@pytest.mark.slow
156+
def test_default_audits_fail_on_bad_data(tmp_path: Path):
157+
models_dir = tmp_path / "models"
158+
models_dir.mkdir(exist_ok=True)
159+
160+
# Create a model with data that violates NOT NULL constraint
161+
create_temp_file(
162+
tmp_path,
163+
models_dir / "bad_orders.sql",
164+
dedent("""
165+
MODEL (
166+
name test.bad_orders,
167+
kind FULL
168+
);
169+
170+
SELECT
171+
1 AS order_id,
172+
NULL AS customer_id, -- This violates NOT NULL
173+
100.50 AS amount,
174+
'2024-01-01'::DATE AS order_date
175+
UNION ALL
176+
SELECT
177+
2 AS order_id,
178+
'customer_2' AS customer_id,
179+
200.75 AS amount,
180+
'2024-01-02'::DATE AS order_date
181+
"""),
182+
)
183+
184+
config = Config(
185+
model_defaults=ModelDefaultsConfig(
186+
dialect="duckdb", audits=["not_null(columns := [customer_id])"]
187+
)
188+
)
189+
190+
context = Context(paths=tmp_path, config=config)
191+
192+
# Plan should fail due to audit failure
193+
with pytest.raises(PlanError):
194+
context.plan("prod", no_prompts=True, auto_apply=True)
195+
196+
197+
@pytest.mark.slow
198+
def test_default_audits_with_model_specific_audits(tmp_path: Path):
199+
models_dir = tmp_path / "models"
200+
models_dir.mkdir(exist_ok=True)
201+
audits_dir = tmp_path / "audits"
202+
audits_dir.mkdir(exist_ok=True)
203+
204+
create_temp_file(
205+
tmp_path,
206+
audits_dir / "range_check.sql",
207+
dedent("""
208+
AUDIT (
209+
name range_check
210+
);
211+
212+
SELECT * FROM @this_model
213+
WHERE @column < @min_value OR @column > @max_value
214+
"""),
215+
)
216+
217+
# Create a model with its own audits in addition to defaults
218+
create_temp_file(
219+
tmp_path,
220+
models_dir / "products.sql",
221+
dedent("""
222+
MODEL (
223+
name test.products,
224+
kind FULL,
225+
audits (
226+
range_check(column := price, min_value := 0, max_value := 10000)
227+
)
228+
);
229+
230+
SELECT
231+
1 AS product_id,
232+
'Widget' AS product_name,
233+
99.99 AS price
234+
UNION ALL
235+
SELECT
236+
2 AS product_id,
237+
'Gadget' AS product_name,
238+
149.99 AS price
239+
"""),
240+
)
241+
242+
config = Config(
243+
model_defaults=ModelDefaultsConfig(
244+
dialect="duckdb",
245+
audits=[
246+
"not_null(columns := [product_id, product_name])",
247+
"unique_values(columns := [product_id])",
248+
],
249+
)
250+
)
251+
252+
context = Context(paths=tmp_path, config=config)
253+
254+
# Create and apply plan
255+
plan = context.plan("prod", no_prompts=True)
256+
context.apply(plan)
257+
258+
# Verify model has both default and model-specific audits
259+
model = context.get_model("test.products")
260+
assert len(model.audits) == 3
261+
262+
audit_names = [audit[0] for audit in model.audits]
263+
assert "not_null" in audit_names
264+
assert "unique_values" in audit_names
265+
assert "range_check" in audit_names
266+
267+
# Verify audit execution order, default audits first then model-specific
268+
assert model.audits[0][0] == "not_null"
269+
assert model.audits[1][0] == "unique_values"
270+
assert model.audits[2][0] == "range_check"
271+
272+
273+
@pytest.mark.slow
274+
def test_default_audits_with_custom_audit_definitions(tmp_path: Path):
275+
models_dir = tmp_path / "models"
276+
models_dir.mkdir(exist_ok=True)
277+
audits_dir = tmp_path / "audits"
278+
audits_dir.mkdir(exist_ok=True)
279+
280+
# Create custom audit definition
281+
create_temp_file(
282+
tmp_path,
283+
audits_dir / "positive_amount.sql",
284+
dedent("""
285+
AUDIT (
286+
name positive_amount
287+
);
288+
289+
SELECT * FROM @this_model
290+
WHERE @column <= 0
291+
"""),
292+
)
293+
294+
# Create a model
295+
create_temp_file(
296+
tmp_path,
297+
models_dir / "transactions.sql",
298+
dedent("""
299+
MODEL (
300+
name test.transactions,
301+
kind FULL
302+
);
303+
304+
SELECT
305+
1 AS transaction_id,
306+
'TXN001' AS transaction_code,
307+
250.00 AS amount,
308+
'2024-01-01'::DATE AS transaction_date
309+
UNION ALL
310+
SELECT
311+
2 AS transaction_id,
312+
'TXN002' AS transaction_code,
313+
150.00 AS amount,
314+
'2024-01-02'::DATE AS transaction_date
315+
"""),
316+
)
317+
318+
config = Config(
319+
model_defaults=ModelDefaultsConfig(
320+
dialect="duckdb",
321+
audits=[
322+
"not_null(columns := [transaction_id, transaction_code])",
323+
"unique_values(columns := [transaction_id])",
324+
"positive_amount(column := amount)",
325+
],
326+
)
327+
)
328+
329+
context = Context(paths=tmp_path, config=config)
330+
331+
# Create and apply plan
332+
plan = context.plan("prod", no_prompts=True)
333+
context.apply(plan)
334+
335+
# Verify model has all default audits including custom
336+
model = context.get_model("test.transactions")
337+
assert len(model.audits) == 3
338+
339+
audit_names = [audit[0] for audit in model.audits]
340+
assert "not_null" in audit_names
341+
assert "unique_values" in audit_names
342+
assert "positive_amount" in audit_names
343+
344+
# Verify custom audit arguments
345+
for audit_name, audit_args in model.audits:
346+
if audit_name == "positive_amount":
347+
assert "column" in audit_args
348+
assert audit_args["column"].name == "amount"

0 commit comments

Comments
 (0)