Skip to content

Commit ff50b02

Browse files
committed
fix: require a TEST() header in SQL test files again
A SQL test file must again start with a TEST() header; a file without one fails discovery with D003. Mode inference and the optional trailing SELECT 1 are unchanged. Docs, the skill and the example tests show the header again.
1 parent e1e7e62 commit ff50b02

21 files changed

Lines changed: 180 additions & 120 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ A test mocks the sources and asserts on the model, resolving every model in betw
175175
SQL:
176176

177177
```sql
178+
TEST();
179+
178180
WITH
179181
__source__raw__orders AS (
180182
SELECT

‎src/sqlbuild/.agents/skills/sqlbuild/SKILL.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,10 @@ do not hand-write that filter. Unlisted inputs are read in full. Use `__cursor_s
125125
window into batches, each with its own audits. Always `sqb plan` an incremental change before building. Read
126126
[references/incremental-models.md](references/incremental-models.md).
127127

128-
**Tests.** SQL unit tests live under `tests/unit/`. A test file mocks inputs with
129-
`__source__<name>`, `__ref__<name>`, `__seed__<name>` CTEs, states expected output with
130-
`__expected__<model>` (only the listed columns are compared, matched by name; unlisted columns are
131-
ignored), and adds zero-row checks with `__assert__<name>`. A single-test file needs no `TEST();`
132-
header and no closing `SELECT 1`.
128+
**Tests.** SQL unit tests live under `tests/unit/`. A test file starts with a `TEST();` header,
129+
mocks inputs with `__source__<name>`, `__ref__<name>`, `__seed__<name>` CTEs, states expected output
130+
with `__expected__<model>` (only the listed columns are compared, matched by name; unlisted columns
131+
are ignored), and adds zero-row checks with `__assert__<name>`. The closing `SELECT 1` is optional.
133132
One test can span many models: mock the sources, assert on the final model, and every intermediate
134133
model runs from its real SQL. Never write a test that only proves empty inputs produce no rows; it
135134
cannot fail, Rules reject it, and it does not count toward `min_tests_per_model`. Mock real rows

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/declaration-scopes/visibility.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ A test first sees declarations available from its own folder tree. It may also u
110110
macros, enums, and constants available to a model for which it defines expected output.
111111

112112
```sql
113+
TEST();
114+
113115
WITH
114116
__expected__orders AS (
115117
SELECT

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/macros.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ def mock_orders(count: int = 1) -> str:
177177
```
178178

179179
```sql
180+
TEST();
181+
180182
WITH
181183
__source__raw__orders AS (
182184
@mock_orders(3)

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ A test file defines mock inputs and expected outputs using CTEs. SQLBuild substi
3737

3838
```sql
3939
-- tests/unit/test_stg_orders.sql
40+
TEST();
41+
4042
WITH
4143
__source__raw__orders AS (
4244
SELECT
@@ -81,11 +83,12 @@ that read them. Statically provable collection-versus-scalar type conflicts iden
8183
column and suggest explicit `CAST`, `ARRAY_CONSTRUCT`, or `PARSE_JSON` expressions. SQLBuild never
8284
invents missing values.
8385

84-
A test file needs no header and no closing statement: a file that holds one test can be just its
85-
CTEs. Add a `TEST (...)` header to name the test, set a cursor window, declare parameters, or turn
86-
off SQL analysis, and on every block of a file that holds several tests. The `TEST();` header and
87-
the trailing `SELECT 1` that earlier releases required are still accepted, so this file is
88-
equivalent to the first example:
86+
Every test file starts with a `TEST()` header. All of its fields are optional: `TEST();` is a
87+
complete header, and a test without a `name` is named after its file. Add fields to name the test,
88+
set a cursor window, declare parameters, or turn off SQL analysis.
89+
90+
A test ends after its last CTE. The trailing `SELECT 1` that earlier releases required is still
91+
accepted but no longer needed, so this file is equivalent to the first example:
8992

9093
```sql
9194
TEST();
@@ -140,6 +143,8 @@ expected rows can therefore live in one helper that both an `__expected__` CTE a
140143
read:
141144

142145
```sql
146+
TEST();
147+
143148
WITH
144149
__source__raw__orders AS (
145150
SELECT 1 AS id, 3 AS quantity UNION ALL SELECT 2 AS id, 5 AS quantity
@@ -167,6 +172,8 @@ Tests can span multiple models in a single file. Mock your sources, define an ex
167172
```sql
168173
-- Mock two sources, assert on the final mart.
169174
-- stg_orders and stg_payments resolve automatically from their real SQL.
175+
TEST();
176+
170177
WITH
171178
__source__raw__orders AS (
172179
SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity,
@@ -200,6 +207,8 @@ dependency errors.
200207
You can mock models directly with `__ref__<name>` and seeds with `__seed__<name>`, not just sources. This skips the model's real SQL (or the seed's real CSV data) and provides controlled data instead:
201208

202209
```sql
210+
TEST();
211+
203212
WITH
204213
__ref__stg_orders AS (
205214
SELECT
@@ -270,6 +279,8 @@ expressions, with the fixture relation. The mocked function is not deployed as a
270279
```sql
271280
-- models/customer_order_totals.sql reads:
272281
-- __table_fn("customer_orders")(42)
282+
TEST();
283+
273284
WITH
274285
fixture_orders AS (
275286
SELECT 101 AS order_id, 42 AS customer_id, 2500 AS amount_cents
@@ -295,6 +306,8 @@ compares the real function.
295306
A single test can assert on multiple models. SQLBuild resolves and compares each one independently:
296307

297308
```sql
309+
TEST();
310+
298311
WITH
299312
__source__raw__orders AS (
300313
SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity,
@@ -330,6 +343,8 @@ Only explicit expected CTEs create grants. A matching test filename, `__ref__` m
330343
Because unit tests are written in SQL, they support macro calls. This lets you write reusable mock generators instead of copy-pasting mock data across test files:
331344

332345
```sql
346+
TEST();
347+
333348
WITH
334349
__source__raw__orders AS (
335350
@mock_orders()
@@ -361,6 +376,8 @@ with `D013`.
361376
When a model uses macros that you want to control in tests (e.g. target-specific logic, dynamic SQL generation), you can override their output with `__macro__<name>` CTEs:
362377

363378
```sql
379+
TEST();
380+
364381
WITH
365382
__macro__country_filter AS (
366383
SELECT 'country_code = ''US'''
@@ -387,6 +404,8 @@ This is useful for:
387404
Unit tests can include `__assert__<name>` CTEs for property-based checks. An assertion passes if the query returns zero rows - any returned rows are failing examples.
388405

389406
```sql
407+
TEST();
408+
390409
WITH
391410
__ref__stg_orders AS (
392411
SELECT 1 AS order_id, 100 AS customer_id, 3 AS quantity,
@@ -454,8 +473,9 @@ project file; see [Rule options](rules/configuration-and-selection.md#rule-optio
454473
Model tests mock sources and refs and compare model outputs. Three more modes test reusable logic
455474
directly, without a model chain. SQLBuild infers the mode from the test's CTEs: a test that defines
456475
`__macro_actual__` is a macro test, `__udf_actual__` a UDF test, `__table_fn_actual__` a table
457-
function test, and any other test a model test. `TEST (mode ...)` states the mode explicitly; an
458-
explicit mode that contradicts the CTEs is a compile error.
476+
function test, and any other test a model test, so the header's `mode` field is optional.
477+
`TEST (mode ...)` states the mode explicitly; an explicit mode that contradicts the CTEs is a
478+
compile error.
459479

460480
### Macro tests
461481

@@ -544,11 +564,10 @@ CTE prefixes from other modes are not allowed. For example, `__source__` in a ma
544564
## Multiple tests per file
545565

546566
A single test file can contain multiple `TEST()` blocks. A test's name is its `name`, or the file
547-
stem when `name` or the whole header is omitted. Test names are globally unique: a test cannot share
548-
a name with another test, a scenario, a model, a source, a seed, a function, or a Python node. Name
549-
the file or the block after the behavior it checks, such as `orders_status_rules`, rather than after
550-
the model. The cases of one parameterized test share its name. Every block in a multi-test file needs
551-
its own `TEST (...)` header with a unique `name`:
567+
stem when `name` is omitted. Test names are globally unique: a test cannot share a name with another
568+
test, a scenario, a model, a source, a seed, a function, or a Python node. Name the file or the block
569+
after the behavior it checks, such as `orders_status_rules`, rather than after the model. The cases
570+
of one parameterized test share its name. Each block in a file must have its own `name`:
552571

553572
```sql
554573
TEST (name "completed_orders_only");
@@ -572,8 +591,7 @@ __expected__stg_orders AS (
572591
)
573592
```
574593

575-
A file with a single test can omit the `name` field, or the whole header. Files with multiple tests
576-
require a header with a name on every block.
594+
A file with a single test can omit the `name` field. Files with multiple tests require names on every block.
577595

578596
## Repeating test logic
579597

@@ -669,6 +687,8 @@ capture and replay identity remains explicit.
669687
When independent status is unnecessary, a normal SQL case table remains concise:
670688

671689
```sql
690+
TEST();
691+
672692
WITH cases(case_name, source_status, expected_status) AS (
673693
VALUES
674694
('completed', 'completed', 'completed'),

‎src/sqlbuild/.agents/skills/sqlbuild/references/testing.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,19 @@
2121

2222
## Writing a unit test
2323

24-
Files live under `tests/unit/` (any subfolder). A file with one test is just its CTEs; add a
25-
`TEST (...)` header only to set a name, cursor window, parameters or `sql_analysis false`, and on
26-
every block of a file with several tests. The older `TEST();` header and trailing `SELECT 1` are
27-
still accepted but not needed.
24+
Files live under `tests/unit/` (any subfolder). Every file starts with a `TEST();` header followed
25+
by CTEs. Header fields are optional; add them to set a name, cursor window, parameters or
26+
`sql_analysis false`, and give every block of a file with several tests its own `name`. The
27+
trailing `SELECT 1` is optional.
2828
A test's name is its `name` or the file stem. Test names are globally unique across tests,
2929
scenarios, models, sources, seeds, functions and Python nodes, so never name a test file after its
3030
model (`test_stg_orders.sql`, not `stg_orders.sql`). Run one test with `sqb test <test name>` or
3131
`sqb test test:<name>`; only `sqb test` and `sqb build` accept test selectors.
3232

3333
```sql
3434
-- tests/unit/test_stg_orders.sql
35+
TEST();
36+
3537
WITH
3638
__source__raw__orders AS (
3739
SELECT 1 AS id, 100 AS customer_id, 'completed' AS status,
@@ -67,7 +69,7 @@ shared expected rows can live in one helper that both an `__expected__` CTE and
6769

6870
Macros work inside tests, so reusable mock generators such as `@mock_orders(count=5)` are normal.
6971
Tests can also target a macro, UDF or table function directly: defining `__macro_actual__`,
70-
`__udf_actual__` or `__table_fn_actual__` selects that mode without a `TEST (mode ...)` header; see
72+
`__udf_actual__` or `__table_fn_actual__` selects that mode without a `mode` header field; see
7173
[docs/concepts/testing.md](docs/concepts/testing.md) for those modes.
7274

7375
## Empty-input tests

‎src/sqlbuild/compiler/discovery/_helpers/sql/tests.py‎

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
r"^\s*TEST\s*\(" + STATEMENT_HEADER_BODY_PATTERN + r"\)\s*;\s*",
5353
re.DOTALL | re.MULTILINE,
5454
)
55-
_TEST_HEADER_START_PATTERN: re.Pattern[str] = re.compile(r"\s*TEST\s*\(")
5655
_MODE_BY_ACTUAL_CTE_NAME: dict[str, SqlTestMode] = {
5756
MACRO_ACTUAL_TEST_CTE_NAME: SqlTestMode.MACRO,
5857
UDF_ACTUAL_TEST_CTE_NAME: SqlTestMode.UDF,
@@ -123,7 +122,10 @@ def parse_sql_test_file(*, contents: str, file_path: Path) -> tuple[DiscoveredSq
123122
file_path=file_path, contents=contents
124123
)
125124
if not raw_test_blocks:
126-
return (_parse_headerless_sql_test_file(file_path=file_path, contents=contents),)
125+
raise SqlTestParseError(
126+
f"SQL test '{file_path}' must start with a TEST() header as the first "
127+
"non-whitespace content"
128+
)
127129

128130
discovered_blocks: list[DiscoveredSqlTestBlock] = []
129131
test_index: int
@@ -220,23 +222,6 @@ def _code_segment_bounds(
220222
return (start + leading if first is None else first), start + len(stripped) - 1
221223

222224

223-
def _parse_headerless_sql_test_file(*, file_path: Path, contents: str) -> DiscoveredSqlTestBlock:
224-
if _TEST_HEADER_START_PATTERN.match(contents) is not None:
225-
raise SqlTestParseError(
226-
f"SQL test '{file_path}' starts with a TEST header that is not a complete "
227-
"TEST(...); statement; close the header with `);` or omit it"
228-
)
229-
sql_body: str = cleandoc(contents)
230-
if not sql_body.strip():
231-
raise SqlTestParseError(f"SQL test '{file_path}' must define SQL")
232-
return DiscoveredSqlTestBlock(
233-
test_index=1,
234-
header_values={},
235-
sql_body=sql_body,
236-
mode=_infer_sql_test_mode(sql_body=sql_body, file_path=file_path),
237-
)
238-
239-
240225
def _infer_sql_test_mode(*, sql_body: str, file_path: Path) -> SqlTestMode:
241226
"""Infer a TEST block's mode from the direct-logic actual CTE it defines, if any."""
242227

@@ -268,7 +253,7 @@ def _split_sql_test_blocks(*, file_path: Path, contents: str) -> tuple[tuple[str
268253
if contents[: matches[0].start()].strip():
269254
raise SqlTestParseError(
270255
f"SQL test '{file_path}' must start with a TEST() header as the first "
271-
"non-whitespace content; only a file with a single test may omit its header"
256+
"non-whitespace content"
272257
)
273258

274259
raw_blocks: list[tuple[str, int]] = []

‎src/sqlbuild/lint/_helpers/expansion.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def expand_file_bodies(
361361
external_identifiers: tuple[str, ...] = external_identifiers_for_headers(
362362
contents=contents, headers=headers
363363
)
364-
allows_ceremonial_select: bool = role.in_sql_test_directory or any(
364+
allows_ceremonial_select: bool = any(
365365
header.kind in {HEADER_KIND_TEST, HEADER_KIND_SCENARIO} for header in headers
366366
)
367367
allows_empty_fixture_star: bool = allows_ceremonial_select

‎src/sqlbuild/lint/_helpers/headers.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from pathlib import Path, PurePath
88

99
from sqlbuild.compiler.compile.constants import HOOK_DIRECTORY_NAME
10-
from sqlbuild.compiler.discovery.constants import SQL_TESTS_OWNERSHIP_ROOT
1110
from sqlbuild.compiler.discovery.main.named_declaration_role_kind import (
1211
named_declaration_role_kind,
1312
)
@@ -45,7 +44,6 @@ def lint_file_role(
4544
declaration_kind=named_declaration_role_kind(relative_path=relative_path),
4645
in_project=True,
4746
in_hook_directory=relative_path.is_relative_to(HOOK_DIRECTORY_NAME),
48-
in_sql_test_directory=relative_path.is_relative_to(SQL_TESTS_OWNERSHIP_ROOT),
4947
)
5048

5149

‎src/sqlbuild/lint/_helpers/native_format.py‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
)
1616
from sqlbuild.compiler.sql_analysis.constants import POLYGLOT_MAX_FUNCTION_CALL_DEPTH
1717
from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax
18-
from sqlbuild.lint._helpers.headers import lint_body_ranges, lint_file_role, scan_headers
18+
from sqlbuild.lint._helpers.headers import lint_body_ranges, scan_headers
1919
from sqlbuild.lint._helpers.sqlbuild_tokens import neutralize_interpolation, restore_interpolation
2020
from sqlbuild.lint.constants import (
2121
CARRIAGE_RETURN_LINE_FEED,
@@ -27,13 +27,7 @@
2727
VIOLATION_SEVERITY_FAULT,
2828
)
2929
from sqlbuild.lint.exceptions import InterpolationRestorationError, NativeLintError
30-
from sqlbuild.lint.models import (
31-
HeaderSpan,
32-
InterpolationSite,
33-
LintConfig,
34-
LintFileRole,
35-
LintViolation,
36-
)
30+
from sqlbuild.lint.models import HeaderSpan, InterpolationSite, LintConfig, LintViolation
3731

3832
_NATIVE_FORMAT_API_VERSION: int = 1
3933
_FIXTURE_HEADER_KINDS: frozenset[str] = frozenset({HEADER_KIND_TEST, HEADER_KIND_SCENARIO})
@@ -75,15 +69,13 @@ def format_native_sql_bodies(
7569
requests_by_key: dict[tuple[str, str, str], dict[str, object]] = {}
7670
for file_path, contents in sorted(files.items()):
7771
headers: tuple[HeaderSpan, ...] = scan_headers(contents=contents)
78-
role: LintFileRole = lint_file_role(file_path=file_path, project_dir=project_dir)
7972
body_ranges: tuple[tuple[int, int], ...] = lint_body_ranges(
8073
contents=contents,
8174
headers=headers,
8275
file_path=file_path,
8376
project_dir=project_dir,
84-
role=role,
8577
)
86-
allows_omitted_select: bool = role.in_sql_test_directory or any(
78+
allows_omitted_select: bool = any(
8779
header.kind in _FIXTURE_HEADER_KINDS for header in headers
8880
)
8981
prepared_bodies: list[_PreparedBody] = []

0 commit comments

Comments
 (0)