Stored Procedures, Triggers and Views
Stored procedures, triggers and views are logic that lives inside the database instead of the application. A stored procedure is a named routine the application calls with parameters: CALL cancel_order(1042, 7) (order 1042, cancelled by user 7) checks the order’s status, cancels it, returns the items to stock and creates a refund. A trigger is code attached to a table that runs by itself when rows change, for example to write an audit row. A view is a saved query that reports read like a table.
In the lesson on constraints and data integrity you tested declarative rules such as keys and CHECK. Stored logic is procedural code with branches, side effects and errors, so you test it like any code: against a contract, with three test design techniques, each taught in its own lesson in the test design techniques module. Equivalence partitioning splits each input into partitions, groups of values the routine should treat the same way, and tests one value from each. Boundary value analysis tests the edges of ordered partitions: the minimum and maximum of each partition and the closest values just outside them. A decision table lists every combination of conditions with its expected outcome, and each feasible combination becomes a test.
Why it matters
Database logic is easy to overlook. It sits outside the application code most reviews look at, changes through migration scripts, and a trigger acts silently on every path that touches its table: the API, an admin fix, a nightly import. A defect there damages data directly — a refund created twice, stock returned for a shipped order, a report that counts orders twice — and often surfaces weeks later, far from its cause.
These routines also hold critical rules: money, stock, status changes, audit records. Calling them directly and checking every table they touch lets you find such defects before release.
Stored procedures: operations inside the database
A stored procedure is created in the database, usually by a migration script, and called by name. It takes input (IN), output (OUT) or INOUT parameters and can contain IF branches, loops, several INSERT, UPDATE and DELETE statements, and explicit errors such as ORDER_NOT_CANCELLABLE. Teams use procedures to keep a multi-step operation next to the data, or to let the application run it without the right to change the tables directly.
A function is a close relative: it returns a value and is used inside a query, as in SELECT order_total(1042). A procedure is invoked on its own with CALL (PostgreSQL, MySQL) or EXEC (SQL Server); in PostgreSQL only a procedure may commit or roll back inside its body. When you read either, list:
- The parameters and their allowed values: inputs for equivalence partitioning and boundary values.
- Every branch (
IF,CASE, early exits): each one needs a test. - Every table read and written, including through triggers.
- Every error raised, and whether a failure halfway undoes what already ran.
-- PostgreSQL and MySQL: call a procedure
CALL cancel_order(1042, 7);
-- SQL Server (T-SQL)
EXEC dbo.cancel_order @order_id = 1042, @user_id = 7;
-- PostgreSQL and MySQL: a function returns a value and is used inside a query
SELECT order_total(1042);Triggers: code that runs by itself
A trigger belongs to a table and fires on INSERT, UPDATE or DELETE. A BEFORE trigger runs before the row is written and can change or reject it; an AFTER trigger runs afterwards and suits side effects such as audit rows. A row-level trigger (FOR EACH ROW) runs once per changed row and can read the OLD and NEW values; a statement-level trigger runs once per statement. SQL Server has no BEFORE triggers, only AFTER and INSTEAD OF; its triggers run once per statement and see all changed rows in the inserted and deleted tables, so code that assumes one row breaks on a bulk update.
In a transactional engine, an error inside a trigger fails the statement that fired it and rolls back its changes. Triggers also skip some paths: TRUNCATE fires no DELETE triggers in PostgreSQL, MySQL or SQL Server, and in MySQL foreign key cascades fire no triggers at all. In PostgreSQL a trigger has two parts, as below: a function that RETURNS trigger and a CREATE TRIGGER statement that attaches it to the table; its WHEN condition uses IS DISTINCT FROM, a not-equal comparison that also handles NULL. For this audit trigger, test that it:
- Fires on every path that changes the table: API, admin screen, bulk script, import.
- Writes no row when a status is set to the value it already has.
- Fires once per changed row, also when one statement changes many rows.
- Records the person who made the change: on a shared application connection the database user is the application’s account, so the user must be passed in, as
updated_byis here.
-- PostgreSQL: one audit row whenever an order's status really changes
CREATE FUNCTION log_status_change() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO order_audit (order_id, old_status, new_status, changed_by, changed_at)
VALUES (NEW.order_id, OLD.status, NEW.status, NEW.updated_by, now());
RETURN NEW;
END $$;
CREATE TRIGGER orders_status_audit
AFTER UPDATE OF status ON orders
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION log_status_change();Views: saved queries that reports trust
A view stores a query, not data: each time it is read, the database runs the query against the current base tables. Views give reports and APIs a stable shape and can expose only some columns, such as customer data without phone numbers. A materialized view stores its result and shows the data as of its last refresh, so its tests also cover the refresh.
A view’s defects are query defects: a wrong join type drops rows, a join to a child table multiplies them, a filter is missing, NULLs vanish from counts. The view below joins order_items, so an order with two items is counted twice.
- Compare the view with an independent query on the base tables: your test oracle.
- Seed edge rows: an order with several items, a cancelled order, a day without orders, a NULL amount.
- Check the columns too: hidden personal data must not reappear under another name.
- For a materialized view, compare the result before and after the refresh.
CREATE VIEW daily_sales AS
SELECT o.order_date, COUNT(*) AS order_count, SUM(o.total) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status <> 'CANCELLED'
GROUP BY o.order_date;
-- Seed: one order on 2026-09-14, total 190.00, with two item rows
-- Expected: order_count = 1, revenue = 190.00
-- Actual: order_count = 2, revenue = 380.00Testing stored logic: a worked example
Derive tests from each routine’s contract: partitions and boundary values for parameters, a decision table where conditions combine. Calling a procedure directly is component testing in ISTQB terms; calling it through the API that uses it also tests the integration. Do both: the application may pass other values or hide a clear database error behind a confusing one.
Each test is arrange, act, assert in a test database: seed known rows, make the call, then query every table that should change and every table that must not. If the procedure does not commit by itself, ending with ROLLBACK restores the starting state; otherwise use a database you can reset. Frameworks such as pgTAP (PostgreSQL) and tSQLt (SQL Server) automate this pattern.
Requirement: "An order can be cancelled only while PENDING or PAID. Cancelling sets the status to CANCELLED and returns every item to stock; a PAID order also gets one refund of its total. Otherwise the call raises ORDER_NOT_CANCELLABLE (or ORDER_NOT_FOUND) and nothing changes."
Paid order 1042: cancelled
- Application Order API
- Stored procedure
cancel_order - Tables Orders, stock, refunds, audit
- Audit trigger
orders_status_audit
- 1 From Application To Stored procedure: Call the procedure
CALL cancel_order(1042, 7) - 2 From Stored procedure To Tables: Read the order Status PAID, total 190.00
- 3 From Stored procedure To Tables: Set the status to CANCELLED
UPDATE orders - 4 From Tables To Audit trigger: The status change fires the trigger AFTER UPDATE, once for the row
- 5 From Audit trigger To Tables: Write one audit row PAID → CANCELLED, user 7
INSERT INTO order_audit - 6 From Stored procedure To Tables: Return the items to stock Product 11: 50 → 52; product 12: 8 → 9
UPDATE products - 7 From Stored procedure To Tables: Create one refund of 190.00
INSERT INTO refunds - 8 From Stored procedure To Application: Success reply
Outcome: Four tables change: the test queries every one of them, not only the reply.
Shipped order 1043: refused
- Application Order API
- Stored procedure
cancel_order - Tables Orders, stock, refunds, audit
- Audit trigger
orders_status_audit
- 1 From Application To Stored procedure: Call the procedure
CALL cancel_order(1043, 7) - 2 From Stored procedure To Tables: Read the order Status SHIPPED
- 3 Stored procedure internally: Only PENDING or PAID can be cancelled
- 4 From Stored procedure To Application: Error, no update reply
ORDER_NOT_CANCELLABLE
Outcome: Expected: all four tables exactly as before; with no status change, the trigger never fires.
- PAID order 1042 (2 × product 11, 1 × product 12, total 190.00): CANCELLED, stock 50 → 52 and 8 → 9, one refund of 190.00, one audit row PAID → CANCELLED by user 7.
- PENDING order: cancelled, stock returned, one audit row PENDING → CANCELLED, no refund row.
- SHIPPED order 1043, a second cancel of the same order, unknown order 9999: an error, all four tables unchanged, stock never returned twice.
- Two lines for the same product, if the schema allows it: stock rises by both quantities. A procedure that returns stock with one PostgreSQL
UPDATE … FROM order_items, without first summing the quantities per product, would apply only one of the two lines, because a target row that matches several joined rows is updated once. - Failure halfway: if
refunds.order_idhas a unique key, seed a refund for order 1042 so that the last step, the refund insert, is rejected, and call without a wrapping transaction, as the application does. Status, stock and audit must stay unchanged. - Through the API: the same rows, and a clear 4xx for the shipped order, not a 500 with a raw database error.
BEGIN;
-- Arrange: known rows (customer 317 exists; other columns use defaults)
INSERT INTO products (product_id, stock) VALUES (11, 50), (12, 8);
INSERT INTO orders (order_id, customer_id, status, total)
VALUES (1042, 317, 'PAID', 190.00);
INSERT INTO order_items (order_id, product_id, quantity)
VALUES (1042, 11, 2), (1042, 12, 1);
-- Act
CALL cancel_order(1042, 7);
-- Assert: every table the procedure and its trigger touch
SELECT status FROM orders WHERE order_id = 1042; -- CANCELLED
SELECT product_id, stock FROM products ORDER BY product_id; -- 11: 52, 12: 9
SELECT amount FROM refunds WHERE order_id = 1042; -- 1 row: 190.00
SELECT old_status, new_status, changed_by
FROM order_audit WHERE order_id = 1042; -- 1 row: PAID, CANCELLED, 7
ROLLBACK;Try it: SQL INSERT Generator Try it: Decision Table Generator
Common mistakes
- Checking only what the procedure returns, not every table it and its triggers change, nor the tables that must not change.
- Assuming a procedure is all-or-nothing. That depends on the engine and on the procedure’s own transaction handling: force a late failure and look.
- Testing a trigger only through the screen that usually fires it, never with a bulk statement, an import or a cascade.
- Accepting a view because its output looks plausible instead of comparing it with an independent query.
- Skipping regression testing when only a migration changed a procedure, trigger or view: it is still a code change.
The QA perspective
In refinement, ask where a rule is implemented: in the application, the database or both. If the database is involved, get read access to the migration scripts and review the stored code in the pull request; its branches and errors become your test conditions. Agree with the developer or DBA on a test database you can reset and seed scripts you can rerun.
Record each test with its seeded rows, the exact call, the expected rows per table and the verification queries, so it can be repeated and automated. A bug report gives the call, the rows before and after, the expected rows, and the database engine and version, because stored code and its transaction behaviour differ between engines and versions.
Practice exercise
Design tests for a loyalty points trigger
Requirement: "When an order becomes DELIVERED, the customer earns one loyalty point per full 10.00 SAR of the order total. If a delivered order is later RETURNED, those points are removed." A developer wrote the trigger below.
Design test cases with the expected change in loyalty_points, and find the defects in the code.
CREATE FUNCTION award_points() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.status = 'DELIVERED' THEN
UPDATE customers
SET loyalty_points = loyalty_points + floor(NEW.total / 10)
WHERE customer_id = NEW.customer_id;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER orders_award_points
AFTER UPDATE OF status ON orders
FOR EACH ROW
EXECUTE FUNCTION award_points();- List the status changes that must add points, remove them or change nothing.
- Add boundary values for the order total.
- Add the statement shapes and paths that could fire the trigger differently.
- Compare each case with the code.
Show the model answer
Status changes: SHIPPED → DELIVERED adds points; DELIVERED → RETURNED removes them; DELIVERED → DELIVERED (a retried update) and changes involving neither status, such as PAID → SHIPPED, change nothing.
Boundaries: total 9.99 → 0 points, 10.00 → 1, 19.99 → 1, 20.00 → 2. Check customers.loyalty_points before and after, not only that no error appears.
Shapes and paths: one UPDATE that delivers three orders, two for the same customer, adds points for all three; the API, an admin screen and a bulk script give the same result.
Defects: the old status is never checked, so DELIVERED → DELIVERED awards the points again (WHEN (OLD.status IS DISTINCT FROM NEW.status) would prevent it); RETURNED is not handled, so points are never removed; and an order inserted as DELIVERED, for example by a data migration, earns nothing because the trigger fires only on UPDATE — ask whether that is intended.
Frequently asked questions
Is testing stored procedures the developer’s job or the tester’s?
Both. Developers should unit-test their database code, for example with pgTAP or tSQLt. The tester checks the business rule against the requirement, through the application and directly when the rule is complex, and keeps the checks in regression. Database logic that nobody tests is a common gap, so ask who covers it.
What is the difference between a stored procedure and a function?
A function returns a value and is used inside a query; a procedure is called on its own with CALL or EXEC and can return values through output parameters. In PostgreSQL only a procedure can commit or roll back inside its body. Test both the same way: inputs, outputs, changed rows and errors.
Does a view store a copy of the data?
An ordinary view does not: it runs its query against the base tables each time it is read, so it is always current. A materialized view stores its result and shows the data as of its last refresh, so you test the refresh as well.
How do I test database logic if I cannot see its source?
Stored code is part of the schema, so it lives in the migration scripts in the repository and in the database itself; ask for read access. Until you have it, test it as a black box: vary the inputs and compare the rows before and after each call.
References
- PostgreSQL Documentation: SQL SyntaxLexical structure of SQL in PostgreSQL: identifiers, quoting, string constants and escaping.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- ISTQB Certified Tester Foundation Level (CTFL) v4.0The foundation syllabus of software testing: test levels and types, test design techniques, test management and tools.
- Transactions — PostgreSQL DocumentationWhat a transaction is: several steps applied all-or-nothing with BEGIN, COMMIT and ROLLBACK, invisible to others until committed.
Practise with Mutqan tools
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Build decision tables from conditions and actions, generate all rule combinations and collapse impossible ones.
Perform full boundary value analysis (2-value and 3-value BVA) with equivalence classes and expected results.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.