Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with oracle-free 23.26.3.0.0, mssql 17.0.5005.3, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A tSQLt test is a procedure, and its changes are rolled back · CI runs the same tests on a server that exists for one job. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

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:

Interactive · stage by stage One tSQLt test, from its transaction to the summary Step down the stages of one test. The branch on the right is the path of a test whose check fails.
One tSQLt test, from its transaction to the summary, step 8 of 8. Showing Open a transaction, Fake the tables, Insert the test rows, Call dbo.withdraw, Compare or expect, Roll back, Record the result. In focus: Record the result.a check failsOpen a transactionone for each testFake the tablesempty, no constraintsInsert the test rowsonly what this test needsCall dbo.withdrawthe code under testCompare or expectAssertEquals or an errorRoll backfakes and rows are goneRecord the resultSuccess, Failure, Error

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

Three tSQLt tests of dbo.withdraw, modelled in Python Python · model/tsqlt_model.py
"""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

withdraw_tests.sql: the procedure, the test class and two runs SQL · tests/withdraw_tests.sql
-- 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, run in the lab with tSQLt Python · withdraw_tests_session.py
"""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

  1. Question 1 of 2 A tSQLt test inserts account 7 into dbo.accounts and then fails. What does dbo.accounts hold after the run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Exactly what it held before the test, because tSQLt rolls the test's transaction back

    The rollback is why a test needs no cleanup code, and why the next test starts from the same database.

  2. Question 2 of 2 In the first run, the test of an amount of 0 fails, although the real dbo.ledger refuses an amount of 0. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The fake ledger has no CHECK constraint, and dbo.withdraw itself never checks the amount, so no error comes

    With the fix, dbo.withdraw raises 50020 for an amount of 0 or less, and all three tests succeed.

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

  1. Question 1 of 2 Where does the SQL Server that the CI job tests against come from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A container started for that job alone, which is gone when the job ends

    The job starts the server as a service, deploys, installs tSQLt, runs every class and throws the server away.

  2. Question 2 of 2 A release script stopped halfway when the connection dropped. What must be true of it before you run it again from the top?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each change checks first, so a second run makes only the changes that are still missing

    CREATE OR ALTER, DROP … IF EXISTS and guarded ALTER TABLE statements are the usual checks.

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.

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.

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.

  1. Question 1 of 8 Which procedure does EXEC tSQLt.Run 'withdraw_tests' run as a test?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: withdraw_tests.[test withdraw refuses more than the balance]

    A test class is a schema created with tSQLt.NewTestClass, and its tests are the procedures whose names start with test.

  2. Question 2 of 8 A test needs to check every row and column of dbo.accounts after dbo.withdraw has run. Which procedure does that?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: tSQLt.AssertEqualsTable, comparing an expected table with the actual one

    Copy the actual rows into a table, build the expected rows beside them, and compare the two.

  3. Question 3 of 8 After EXEC tSQLt.ExpectException @ExpectedErrorNumber = 50021, the procedure under test returns normally. What does tSQLt record?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Failure, because no error followed the expectation

    That is exactly how the zero test failed in the first run.

  4. Question 4 of 8 In the model, CHECK_AMOUNT = True makes dbo.withdraw refuse an amount of 0 with error 50020. What does the summary line say after that change?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: 3 test case(s) executed, 3 succeeded, 0 failed, 0 errored

    Press Edit in the model and run it with the fix to see the same line.

  5. Question 5 of 8 Where does the CI job's SQL Server password belong?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: In the repository's secret store, read by the workflow at run time

    The job reads the password from the secret store each time it runs, so no file in the repository ever holds it.

  6. Question 6 of 8 Why does the CI job install tSQLt on its own throwaway server rather than on a server people use?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: tSQLt's quick start says it should never be installed in production, and tests belong where nothing real is at stake

    The same reason keeps tests off shared servers: they change data, even if only inside rolled-back transactions.

  7. Question 7 of 8 A pull request changes dbo.withdraw. In which order does the CI job do its work?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Start the server, deploy the schema and procedures, install tSQLt, run every test class, keep the results

    The same order works on a laptop: it is what the workflow automates.

  8. Question 8 of 8 Which statement can run twice in a release script without a guard?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: CREATE OR ALTER PROCEDURE dbo.freeze_account …

    Guard the others with a check, or use the IF EXISTS forms where the statement has one.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. What runs before each test of a class?

    A procedure called SetUp in that class, if there is one.

  6. 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.

  7. 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.

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.