PL/SQL & T-SQL (Oracle and SQL Server) Module 8 – Production T-SQL: concurrency, security, new features and tests
Testing T-SQL: tSQLt, CI and safe deployments
Write tSQLt tests that fake tables and expect errors, run them on a throwaway SQL Server in CI, and write release scripts that can run twice.
What you will learn
- Write tSQLt tests with FakeTable, AssertEquals, AssertEqualsTable and ExpectException
- Isolate a test from constraints and called procedures, and read the failure it reports
- Run database tests in CI against a disposable SQL Server container that fails the build on any failure
- Write release scripts that run twice safely, and compare schema changes with SQL database projects
- Schedule database jobs with SQL Server Agent where the edition includes it
Before you start
What you’ll learn · Before you start
What you will learn
- Write tSQLt tests with FakeTable, AssertEquals, AssertEqualsTable and ExpectException
- Isolate a test from constraints and called procedures, and read the failure it reports
- Run database tests in CI against a disposable SQL Server container that fails the build on any failure
- Write release scripts that run twice safely, and compare schema changes with SQL database projects
- Schedule database jobs with SQL Server Agent where the edition includes it
Before you start
On this page
A withdrawal procedure that accepts an amount of 0 writes a ledger row that says nothing happened, and no single call with a sensible amount will ever show it. Three short tests can: one for the normal case, one for a balance that is too small, one for the amount nobody thought of.
Those tests are written with tSQLt, a test framework that lives inside the database. You will write and read a test class, see why each test leaves the database exactly as it found it, run the same tests in CI on a server that exists for one job, and write release scripts that are safe to run twice.
A tSQLt test is a procedure, and its changes are rolled back
tSQLt: An open-source unit test framework for SQL Server, licensed under Apache 2.0. Its tests are T-SQL stored procedures, and each one runs inside a transaction that tSQLt rolls back when the test ends. tests are T-SQL stored procedure: A named unit of procedural code kept in the database and run with one call, with parameters and the permissions of the database, instead of being sent as text each time. More in “PL/SQL and T-SQL: what they are and where they run” whose names start with
test, grouped in a schema that tSQLt calls a test class: In tSQLt, a schema that groups related tests, created with tSQLt.NewTestClass. Running tSQLt.Run with the class name runs every test in it..
EXEC tSQLt.Run 'withdraw_tests' runs one class and EXEC tSQLt.RunAll runs every class in the database. The
project’s README says that tests run within transaction: A group of changes that the database keeps or undoes as one unit: a COMMIT makes all of them permanent, and a ROLLBACK removes all of them. More in “T-SQL transactions, @@TRANCOUNT and savepoints”, and its FakeTable page adds that
tSQLt rolls each one back at the end of the test, so a test needs no cleanup. tSQLt is licensed under Apache 2.0, and the newest download on tsqlt.org is version 1.0.8083.3529.
Step through one test of the class:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A transaction for each test. tSQLt opens a transaction before the test procedure starts. Everything the test changes from here on belongs to it, so nothing needs cleaning up by hand.
- Tables swapped for fakes. tSQLt.FakeTable replaces dbo.accounts and dbo.ledger with empty copies that have no constraints, so the test can insert account 7 with only the columns it cares about.
- The test's own rows. The test inserts one account with a balance of 1000.00. No foreign key asks for a customer row, because the fake has no constraints.
- The code under test runs. The test calls dbo.withdraw for 250.00. The procedure reads and writes the fakes as if they were the real tables.
- The checks. AssertEqualsTable compares the expected rows with the actual ones, and AssertEquals compares one value. A test that calls ExpectException instead succeeds only if the expected error is raised after it.
- A failed check also rolls back. When a check fails, the test stops there and goes straight to the rollback, so a failing test leaves the database exactly as a passing one does.
- Everything is rolled back. The rollback removes the test's rows and puts the real tables back in place of the fakes. The next test starts from the same database.
- One line in the summary. tSQLt records Success, Failure or Error for the test. After the last test of the class it prints the summary table and the count of test cases.
The procedures this lesson uses each do one job:
| Procedure | What it does |
|---|---|
tSQLt.FakeTable |
swaps a table for an empty copy without its constraints |
tSQLt.ApplyConstraint |
puts one constraint of the real table back on its fake |
tSQLt.AssertEquals |
fails the test unless two values are equal |
tSQLt.AssertEqualsTable |
compares two tables row by row |
tSQLt.ExpectException |
fails the test unless an error follows it |
tSQLt.SpyProcedure |
replaces a called procedure with one that logs its calls |
SetUp, in the class |
runs before each test of its class |
SQL Server cannot run on this page, so the model below runs the lesson’s three tests the way tSQLt does, beside the
real test class in withdraw_tests.sql. Press Edit, set CHECK_AMOUNT = True and run it again:
"""A model of what tSQLt.Run 'withdraw_tests' does with the three tests of withdraw_tests.sql.
tSQLt runs each test inside a transaction and rolls it back when the test ends, so every change a test makes, the
fake tables included, is undone. tSQLt.FakeTable swaps a table for an empty copy without its constraints. This model
keeps the database as Python dictionaries, copies it before each test (the transaction) and puts the copy back after
it (the rollback). The version of dbo.withdraw under test does not refuse an amount of 0, so the third test fails;
the real table's CHECK constraint would have hidden that, but a fake table has none. Press Edit and set
CHECK_AMOUNT = True, the fix, to see all three tests succeed.
"""
import copy
from decimal import Decimal
CHECK_AMOUNT = False # the planted bug: dbo.withdraw does not refuse an amount of 0 or less
class SqlError(Exception):
def __init__(self, number, message):
super().__init__(message)
self.number = number
DATABASE = { # account 1 of the practice schema and its opening deposit, with the two CHECK constraints
"dbo.accounts": {"rows": [{"account_id": 1, "balance": Decimal("2500.00")}], "checks": ["balance >= 0"]},
"dbo.ledger": {"rows": [{"account_id": 1, "amount": Decimal("2500.00")}], "checks": ["amount <> 0"]},
}
def check(table, row):
if "balance >= 0" in table["checks"] and row["balance"] < 0:
raise SqlError(547, "The UPDATE statement conflicted with the CHECK constraint accounts_balance_ck.")
if "amount <> 0" in table["checks"] and row["amount"] == 0:
raise SqlError(547, "The INSERT statement conflicted with the CHECK constraint ledger_amt_nonzero.")
def withdraw(db, account_id, amount):
"""dbo.withdraw: refuse a bad amount (only with the fix), refuse a short balance, then update and log."""
if CHECK_AMOUNT and amount <= 0:
raise SqlError(50020, "The amount must be greater than 0.")
accounts = db["dbo.accounts"]
account = next((r for r in accounts["rows"] if r["account_id"] == account_id), None)
if account is None or account["balance"] < amount:
raise SqlError(50021, "Insufficient funds or no such account.")
changed = dict(account, balance=account["balance"] - amount)
check(accounts, changed)
account.update(changed)
row = {"account_id": account_id, "amount": -amount}
check(db["dbo.ledger"], row)
db["dbo.ledger"]["rows"].append(row)
def fake_table(db, name):
db[name] = {"rows": [], "checks": []} # empty, and without its constraints
def test_lowers_balance(db):
fake_table(db, "dbo.accounts")
fake_table(db, "dbo.ledger")
db["dbo.accounts"]["rows"].append({"account_id": 7, "balance": Decimal("1000.00")})
withdraw(db, 7, Decimal("250.00"))
actual = [(r["account_id"], r["balance"]) for r in db["dbo.accounts"]["rows"]]
assert actual == [(7, Decimal("750.00"))], f"Expected: <[(7, 750.00)]> but was: <{actual}>"
rows = len([r for r in db["dbo.ledger"]["rows"] if r["amount"] == Decimal("-250.00")])
assert rows == 1, f"Expected: <1> but was: <{rows}>"
def expect_exception(number, act):
try:
act()
except SqlError as e:
if e.number != number:
raise AssertionError(
f"Exception did not match expectation! Expected Error Number: {number}, Actual: {e.number}"
) from None
return
raise AssertionError("Expected an error to be raised.")
def test_refuses_more_than_balance(db):
fake_table(db, "dbo.accounts")
fake_table(db, "dbo.ledger")
db["dbo.accounts"]["rows"].append({"account_id": 7, "balance": Decimal("100.00")})
expect_exception(50021, lambda: withdraw(db, 7, Decimal("250.00")))
def test_refuses_zero(db):
fake_table(db, "dbo.accounts")
fake_table(db, "dbo.ledger")
db["dbo.accounts"]["rows"].append({"account_id": 7, "balance": Decimal("100.00")})
expect_exception(50020, lambda: withdraw(db, 7, Decimal("0")))
TESTS = [
("test withdraw lowers the balance and writes one ledger row", test_lowers_balance),
("test withdraw refuses more than the balance", test_refuses_more_than_balance),
("test withdraw refuses an amount of zero", test_refuses_zero),
]
results = []
for name, test in TESTS:
work = copy.deepcopy(DATABASE) # BEGIN TRANSACTION
try:
test(work)
results.append((name, "Success"))
except AssertionError as failure:
results.append((name, "Failure"))
print(f"[withdraw_tests].[{name}] failed: (Failure) {failure}")
except SqlError as error:
results.append((name, "Error"))
print(f"[withdraw_tests].[{name}] failed: (Error) {error} [Error {error.number}]")
# ROLLBACK TRANSACTION: work is thrown away, DATABASE is untouched
width = max(len(n) for n, _ in results) + 18
print("+" + "-" * 22 + "+")
print("|Test Execution Summary|")
print("+" + "-" * 22 + "+")
for i, (name, result) in enumerate(results, 1):
print(f"|{i}|{('[withdraw_tests].[' + name + ']').ljust(width)}|{result}")
ok = sum(r == "Success" for _, r in results)
failed = sum(r == "Failure" for _, r in results)
errored = sum(r == "Error" for _, r in results)
print(f"Test Case Summary: {len(results)} test case(s) executed, {ok} succeeded, {failed} failed, {errored} errored.")
real = DATABASE["dbo.accounts"]["rows"]
print(f"After the run the real tables are unchanged: account 1 at {real[0]['balance']}, "
f"{len(DATABASE['dbo.ledger']['rows'])} ledger row for it, no account 7") Output
[withdraw_tests].[test withdraw refuses an amount of zero] failed: (Failure) Expected an error to be raised. +----------------------+ |Test Execution Summary| +----------------------+ |1|[withdraw_tests].[test withdraw lowers the balance and writes one ledger row]|Success |2|[withdraw_tests].[test withdraw refuses more than the balance] |Success |3|[withdraw_tests].[test withdraw refuses an amount of zero] |Failure Test Case Summary: 3 test case(s) executed, 2 succeeded, 1 failed, 0 errored. After the run the real tables are unchanged: account 1 at 2500.00, 1 ledger row for it, no account 7
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tsqlt_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab_tsqlt: the practice schema and tSQLt, in a database of their
-- own. Client: sqlcmd. Needs tSQLt 1.0.8083.3529 in that database: PrepareServer.sql once per server (it enables CLR
-- and installs the certificate for tSQLt's assembly), then tSQLt.class.sql in the database. Never install tSQLt on a
-- production server.
-- dbo.withdraw takes money from an account and writes a ledger row. The test class withdraw_tests has three tests: one
-- that succeeds, one that expects error 50021, and one that fails because this first version of dbo.withdraw does
-- not refuse an amount of 0. The script then adds the check and runs the class again. tsqlt_model.py models both runs.
SET NOCOUNT ON;
GO
CREATE OR ALTER PROCEDURE dbo.withdraw
@account_id int,
@amount decimal(14,2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.accounts WHERE account_id = @account_id AND balance >= @amount)
THROW 50021, N'Insufficient funds or no such account.', 1;
BEGIN TRANSACTION;
UPDATE dbo.accounts SET balance = balance - @amount
WHERE account_id = @account_id AND balance >= @amount;
IF @@ROWCOUNT = 0
THROW 50021, N'Insufficient funds or no such account.', 1; -- another session got there first
INSERT INTO dbo.ledger (account_id, amount, posted_at, memo)
VALUES (@account_id, -@amount, SYSDATETIME(), N'withdrawal');
COMMIT TRANSACTION;
END;
GO
EXEC tSQLt.NewTestClass 'withdraw_tests';
GO
CREATE PROCEDURE withdraw_tests.[test withdraw lowers the balance and writes one ledger row]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo.accounts'; -- empty, no constraints, no identity: account_id can be given
EXEC tSQLt.FakeTable 'dbo.ledger';
INSERT INTO dbo.accounts (account_id, customer_id, kind, balance) VALUES (7, 1, N'savings', 1000.00);
EXEC dbo.withdraw @account_id = 7, @amount = 250.00;
SELECT account_id, balance INTO #actual FROM dbo.accounts;
SELECT TOP (0) account_id, balance INTO #expected FROM dbo.accounts;
INSERT INTO #expected (account_id, balance) VALUES (7, 750.00);
EXEC tSQLt.AssertEqualsTable '#expected', '#actual';
DECLARE @rows int = (SELECT COUNT(*) FROM dbo.ledger WHERE account_id = 7 AND amount = -250.00);
EXEC tSQLt.AssertEquals 1, @rows;
END;
GO
CREATE PROCEDURE withdraw_tests.[test withdraw refuses more than the balance]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo.accounts';
EXEC tSQLt.FakeTable 'dbo.ledger';
INSERT INTO dbo.accounts (account_id, customer_id, kind, balance) VALUES (7, 1, N'savings', 100.00);
EXEC tSQLt.ExpectException @ExpectedErrorNumber = 50021;
EXEC dbo.withdraw @account_id = 7, @amount = 250.00;
END;
GO
CREATE PROCEDURE withdraw_tests.[test withdraw refuses an amount of zero]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo.accounts'; -- the fake ledger has no CHECK (amount <> 0) to hide the bug
EXEC tSQLt.FakeTable 'dbo.ledger';
INSERT INTO dbo.accounts (account_id, customer_id, kind, balance) VALUES (7, 1, N'savings', 100.00);
EXEC tSQLt.ExpectException @ExpectedErrorNumber = 50020;
EXEC dbo.withdraw @account_id = 7, @amount = 0;
END;
GO
-- Run 1: 3 test cases, 2 succeeded, 1 failed. tSQLt.Run then raises error 50000 with its summary line; sqlcmd
-- without -b goes on to the next batch, as a CI job with -b would not.
EXEC tSQLt.Run 'withdraw_tests';
GO
-- The fix: refuse an amount of 0 or less before anything else.
CREATE OR ALTER PROCEDURE dbo.withdraw
@account_id int,
@amount decimal(14,2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @amount IS NULL OR @amount <= 0
THROW 50020, N'The amount must be greater than 0.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.accounts WHERE account_id = @account_id AND balance >= @amount)
THROW 50021, N'Insufficient funds or no such account.', 1;
BEGIN TRANSACTION;
UPDATE dbo.accounts SET balance = balance - @amount
WHERE account_id = @account_id AND balance >= @amount;
IF @@ROWCOUNT = 0
THROW 50021, N'Insufficient funds or no such account.', 1;
INSERT INTO dbo.ledger (account_id, amount, posted_at, memo)
VALUES (@account_id, -@amount, SYSDATETIME(), N'withdrawal');
COMMIT TRANSACTION;
END;
GO
-- Run 2: 3 test cases, 3 succeeded. Then the class and the procedure are removed.
EXEC tSQLt.Run 'withdraw_tests';
GO
EXEC tSQLt.DropClass 'withdraw_tests';
DROP PROCEDURE IF EXISTS dbo.withdraw;
-- The real tables end as they began: each test ran in a transaction that tSQLt rolled back, fake tables included.
SELECT account_id, balance FROM dbo.accounts WHERE account_id = 1; -- 1, 2500.00
GO """withdraw_tests.sql in the lab: sqlcmd runs it as sa in the database lab_tsqlt, which holds the practice schema and
tSQLt 1.0.8083.3529. Each test's duration changes from run to run and shows as <ms>."""
from mssql_session import Lab
with Lab(tsqlt=True) as lab:
lab.vary(r"\|( *\d+)\|(?:Success|Failure|Error|Skipped)\|", "<ms>") # each test's duration
lab.sqlcmd("tests/withdraw_tests.sql", errors=1) Output
$ sqlcmd -S localhost -U sa -C -d lab_tsqlt -i tests/withdraw_tests.sql
.[test withdraw refuses an amount of zero] failed: (Failure) Expected an error to be raised.
+----------------------+
|Test Execution Summary|
+----------------------+
|No|Test Case Name |Dur(ms)|Result |
+--+-----------------------------------------------------------------------------+-------+-------+
|1 |[withdraw_tests].[test withdraw lowers the balance and writes one ledger row]| <ms>|Success|
|2 |[withdraw_tests].[test withdraw refuses more than the balance] | <ms>|Success|
|3 |[withdraw_tests].[test withdraw refuses an amount of zero] | <ms>|Failure|
----------------------------------------------------------------------------------------
Msg 50000, Level 16, State 10, Server lab-mssql, Line 1
Test Case Summary: 3 test case(s) executed, 2 succeeded, 0 skipped, 1 failed, 0 errored.
----------------------------------------------------------------------------------------
+----------------------+
|Test Execution Summary|
+----------------------+
|No|Test Case Name |Dur(ms)|Result |
+--+-----------------------------------------------------------------------------+-------+-------+
|1 |[withdraw_tests].[test withdraw lowers the balance and writes one ledger row]| <ms>|Success|
|2 |[withdraw_tests].[test withdraw refuses an amount of zero] | <ms>|Success|
|3 |[withdraw_tests].[test withdraw refuses more than the balance] | <ms>|Success|
----------------------------------------------------------------------------------------
Test Case Summary: 3 test case(s) executed, 3 succeeded, 0 skipped, 0 failed, 0 errored.
----------------------------------------------------------------------------------------
account_id balance
---------- -------
1 2500.00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 withdraw_tests_session.py
Two tests succeed and the zero test fails, because this version of dbo.withdraw never checks the amount, and the
fake table: In tSQLt, an empty copy of a table without its constraints, which tSQLt.FakeTable puts in place of the real table for one test. The rollback at the end of the test brings the real table back. for the ledger has no CHECK constraint that would have refused the row. With the fix, all three succeed. Either way the real
tables end as they began: account 1 still holds 2500.00.
Checkpoint
Check: tSQLt tests and their rollback
CI runs the same tests on a server that exists for one job
Tests that run only on a developer’s laptop protect nobody else’s change. In CI, short for continuous integration,
every pull request starts a job on a hosted runner. A disposable SQL Server container starts as a service, the job
deploys the schema and procedures, installs tSQLt, runs tSQLt.RunAll and fails when any test fails. The CI job
takes the server’s password from the repository’s secret store, and the server is gone when the job ends, so no test
ever reaches a shared or production server. tSQLt’s own quick start says it should never be installed in production.
Safe deployments are the other half of the same discipline. A release script that stops halfway must be safe to run
again, an idempotent script: A script that can run again without failing and without making any change twice, because each change first checks whether it has already been made., so each change checks first: CREATE OR ALTER for modules, DROP … IF EXISTS, and guarded ALTER TABLE
statements. SQL database projects keep the schema as code and build it into a dacpac: The file a SQL database project builds: a model of a database's schema that SqlPackage compares with a real database before it publishes the differences. file, which
SqlPackage compares with a database before it changes anything. Scheduled work runs as
SQL Server Agent: The SQL Server service that runs scheduled jobs, such as a nightly call of a procedure. Some editions include it and others do not. jobs where the edition includes the Agent.
Checkpoint
Check: Tests in CI and safe deployments
Tests that isolate the procedure, worked through and read when they fail Pro: opens with a Pro, Premium or Ultimate pass
The three tests of withdraw_tests.sql are short because each one fakes what it does not own and checks one thing. Below, each tool is taken apart on that class, with the failure each one reports.
In this part:
- What FakeTable removes, and how to put a rule back
- Expecting the right error, not just any error
- Calls the test does not own: SpyProcedure and SetUp
about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A CI job that fails on one failed test, and releases that can run twice Pro: opens with a Pro, Premium or Ultimate pass
The workflow below is checked with actionlint on this page, and the release script reproduces the error a plain script gives on its second run. Together they are the release half of testing.
In this part:
- A workflow that fails the build on one failed test
- A release script that can run twice
- SQL database projects: compare before you publish
- SQL Server Agent where it exists, with the logic in procedures
4 runnable examples (Python, SQL, YAML) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: predict what each test reports
Each question shows a test, a fake or a release step. Work out what tSQLt or SQL Server does with it before you check:
Practice that adapts to you
Practise this lesson
8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what happens to the rows a tSQLt test inserts?
They are rolled back. tSQLt runs each test inside a transaction and rolls it back when the test ends, whether it succeeded or failed, so the test’s rows and its fake tables disappear and the next test starts from the same database. A test therefore needs no cleanup code, and one test cannot leave rows behind for the next.
Three longer questions follow, about a test that only fails in the full suite, a CI job for database tests and a release that failed halfway; write your answer before you open each first step:
-
Mid to senior
A tSQLt test of dbo.post_fee succeeds when you run its test class alone, and fails when the nightly job runs tSQLt.RunAll: it expected the new ledger row to have entry_id 6 and found 9. Why can one test depend on the others when each one is rolled back, and how do you fix this test?
-
Mid to senior
Your team wants every pull request to run the database's tSQLt tests before it can merge, but the tests have only ever run on a shared development server. Design the CI job: where its server comes from, what runs in which order, how one failed test fails the build, and what must never be in the repository.
-
Mid to senior
A release script adds a column, creates an index and alters two procedures. It stopped halfway on the staging server when the connection dropped, and running it again fails at once on the ALTER TABLE. How do you make the script safe to run again, and how would a SQL database project change the way you release?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a tSQLt test, and how is it run?
A stored procedure whose name starts with test, grouped in a schema that tSQLt calls a test class. EXEC tSQLt.Run 'withdraw_tests' runs one class and EXEC tSQLt.RunAll every class in the database.
-
Why does a tSQLt test need no cleanup?
Tests run within transactions and tSQLt rolls each one back at the end of the test, fakes included, so the real tables end exactly as they began.
-
What does tSQLt.FakeTable do, and what follows from it?
It swaps a table for an empty copy without its constraints, so the test sees what the procedure itself checks rather than what a CHECK constraint refuses. tSQLt.ApplyConstraint puts one constraint of the real table back when the test needs it.
-
Which tSQLt procedures assert a value, a table and an error?
tSQLt.AssertEquals fails unless two values are equal, tSQLt.AssertEqualsTable compares two tables row by row, and tSQLt.ExpectException fails unless an error follows it. tSQLt.SpyProcedure replaces a called procedure with one that logs its calls.
-
What runs before each test of a class?
A procedure called SetUp in that class, if there is one.
-
How do database tests run in CI?
Every pull request starts a job on a hosted runner with a disposable SQL Server container as a service: the job deploys the schema and procedures, installs tSQLt, runs tSQLt.RunAll and fails when any test fails. The job takes the server's password from the repository's secret store, and the server is gone when the job ends.
-
Where should tSQLt never be installed?
In production: tSQLt's own quick start says so, which is also why CI uses a server that exists for one job and no test reaches a shared server.
-
What makes a release script safe to run again after it stopped halfway?
Each change checks first: CREATE OR ALTER for modules, DROP ... IF EXISTS, and guarded ALTER TABLE statements. SQL database projects keep the schema as code and build a dacpac, which SqlPackage compares with a database before changing anything.
Key takeaways
- A tSQLt test is a procedure in a test class, and tSQLt rolls back everything it changes, fakes included.
- A fake table drops the table’s constraints, so a test sees what the procedure itself checks.
- In CI, a disposable SQL Server runs every test class, and one failure fails the job.
- Safe deployments check before each change, so a release that stopped halfway can simply run again.
Terms in this lesson
- dacpac
- The file a SQL database project builds: a model of a database's schema that SqlPackage compares with a real database before it publishes the differences.
- fake table
- In tSQLt, an empty copy of a table without its constraints, which tSQLt.FakeTable puts in place of the real table for one test. The rollback at the end of the test brings the real table back.
- idempotent script
- A script that can run again without failing and without making any change twice, because each change first checks whether it has already been made.
- SQL Server Agent
- The SQL Server service that runs scheduled jobs, such as a nightly call of a procedure. Some editions include it and others do not.
- test class
- In tSQLt, a schema that groups related tests, created with tSQLt.NewTestClass. Running tSQLt.Run with the class name runs every test in it.
- tSQLt
- An open-source unit test framework for SQL Server, licensed under Apache 2.0. Its tests are T-SQL stored procedures, and each one runs inside a transaction that tSQLt rolls back when the test ends.
References
- tSQLt quick start (tSQLt)
- tSQLt full user guide (tSQLt)
- tSQLt downloads (tSQLt)
- FakeTable (tSQLt)
- ApplyConstraint (tSQLt)
- SpyProcedure (tSQLt)
- AssertEquals (tSQLt)
- AssertEqualsTable (tSQLt)
- ExpectException (tSQLt)
- Run (tSQLt)
- RunAll (tSQLt)
- tSQLt repository (README, licence and Source/Run_Methods.sql) (tSQLt)
- Common language runtime (CLR) integration, enabling (Microsoft)
- Quickstart, run SQL Server Linux container images with Docker (Microsoft)
- Configure SQL Server settings with environment variables on Linux (Microsoft)
- sqlcmd utility (Microsoft)
- About service containers (GitHub)
- Using secrets in GitHub Actions (GitHub)
- Workflow syntax for GitHub Actions (GitHub)
- actionlint (actionlint project)
- CREATE PROCEDURE (Transact-SQL) (Microsoft)
- DROP TABLE (Transact-SQL) (Microsoft)
- ALTER TABLE (Transact-SQL) (Microsoft)
- COL_LENGTH (Transact-SQL) (Microsoft)
- Database engine events and errors (2000 to 2999) (Microsoft)
- Database engine events and errors (1000 to 1999) (Microsoft)
- What are SQL database projects? (Microsoft)
- SqlPackage (Microsoft)
- SqlPackage Publish parameters and properties (Microsoft)
- SQL Server Agent (Microsoft)
- Editions and supported features of SQL Server 2025 (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress