Database Testing for QA Engineers
Database testing checks that the data an application stores is correct, complete, consistent and protected — not just that the screen says “Saved”. A UI or API test tells you what the application reports; a database check tells you what actually happened: which rows were written, which constraints held, whether related records still agree, and whether anything was left behind.
This page is the entry point to the topic. It maps the areas a tester covers — CRUD verification, constraints and referential integrity, stored logic, consistency across systems, test data, performance and security — and leads to two deeper pages: writing SQL verification queries, and testing transactions, concurrency and migrations.
What database testing covers
Most defects that reach a database are invisible at the interface. An order confirmation page can render perfectly while the order row carries the wrong total, a payment is linked to the wrong order, or a cancelled order’s items stay reserved. Users meet these defects later — in a monthly statement, a refund that fails, a report that does not add up — when they are expensive to trace.
Database testing is not a separate test level. It is a set of checks applied at the integration, system and acceptance levels wherever data is created or changed. Its oracle is the data model itself: the schema, its constraints, the business rules that relate one table to another, and the numbers that must reconcile.
- Structural (schema) testing — tables, columns, types, lengths, defaults, constraints and indexes match the design and the migration scripts.
- Functional data testing — every create, read, update and delete the UI or API performs leaves exactly the rows it should.
- Integrity testing — keys, uniqueness, NOT NULL, CHECK and foreign-key rules reject bad data, and related records stay consistent.
- Logic testing — stored procedures, triggers, views and scheduled jobs give the right results for normal, boundary and error inputs.
- Transaction and concurrency testing — all-or-nothing behaviour, and correct results when two users change the same data at once (covered in depth on the transactions and migrations page).
- Migration testing — schema and data changes run correctly on realistic volumes and keep the running application working.
- Non-functional testing — query performance on large tables, access control and protection of personal data.
CRUD validation: act through the product, confirm in the database
The core technique is simple: perform the action the way a user or client would — through the UI or the API — then query the database to confirm the effect. Inserting rows directly and calling that a test of the feature skips the application code you are supposed to be testing. Direct inserts are for setting up preconditions, not for exercising the behaviour.
For each operation, check the target row and its neighbours. A create should write one row with every field mapped correctly, server-generated values (ids, timestamps, status) set, and child rows created. An update should change only the intended columns and move updated_at. A delete should remove or soft-delete exactly what the requirement says and treat child rows as designed. A read should return what is stored — Arabic text, emoji, maximum lengths and decimals included — without truncation or re-rounding.
For example, after creating an order with two units of a 75.00 SAR item and one 40.00 SAR item through the API, the query at the end of this section verifies the order row, its item rows and the server-set values. Typical CRUD defects this kind of check catches:
- Field mapping — the API accepts delivery_notes, but the column stays NULL.
- Precision — an amount of 19.995 is stored as 19.99 in one table and 20.00 in another.
- Encoding — Arabic names are saved as “????” because a column or the connection uses a non-Unicode character set.
- Time zones — created_at is stored in local time by one service and in UTC by another.
- Partial writes — the order row exists but its items do not after a validation error on the third item.
-- After POST /api/orders returned order_id 1042 for customer 317
SELECT order_id, customer_id, status, total_amount, created_at, updated_at
FROM orders
WHERE order_id = 1042;
SELECT product_sku, quantity, unit_price
FROM order_items
WHERE order_id = 1042
ORDER BY order_item_id;
-- Expected: customer_id = 317, status = 'PENDING',
-- total_amount = 2 x 75.00 + 1 x 40.00 = 190.00,
-- exactly two item rows, created_at set by the server (UTC)Tests the feature
- Test
- Product UI or API, application code
- Database
- 1 From Test To Database: Seed preconditions only A direct insert is fine here, e.g. customer 317 exists
- 2 From Test To Product: Create the order the way a client would Customer 317: 2 × 75.00 SAR item, 1 × 40.00 SAR item
POST /api/orders - 3 From Product To Database: Application code writes the rows The order row and its item rows; the server sets id, status and timestamps
- 4 From Product To Test: Returns order_id 1042 reply
- 5 From Test To Database: Query on the returned key
orders, thenorder_itemsWHERE order_id = 1042 - 6 From Database To Test: Rows to compare with the expected values reply Customer 317, PENDING, total 190.00, exactly two item rows,
created_atset by the server (UTC)
Outcome: The act goes through the product; only the precondition seed and the check go straight to the database.
Not a test of the feature
- Test
- Product UI or API, application code
- Database
- 1 From Test To Database: INSERT the order and item rows directly Instead of creating the order through the API
- 2 From Test To Database: SELECT them back
- 3 From Database To Test: The rows you wrote reply
Outcome: The product never runs: this skips the application code you are supposed to be testing. Caution
Constraints: the rules the database enforces
Constraints are the database’s last line of defence: even when application validation has a bug, a well-designed schema refuses data that breaks the model. Testing them has two sides. First, confirm the constraint exists — read the schema or the migration, not only the design document. Second, confirm the application handles the rejection gracefully: a clear validation message or a 4xx API response, not a 500 carrying a raw database error.
Engines differ, so check the one you ship on. MySQL enforces CHECK constraints only from version 8.0.16 (earlier versions parsed and ignored them), and SQLite enforces foreign keys only when the connection enables them with PRAGMA foreign_keys = ON. A constraint that exists in the migration file but is not enforced at runtime is a finding.
- Primary key — every row is uniquely identified. A retried request must not create a second row with a new id for the same business event; that is an idempotency problem the key alone does not solve.
- UNIQUE — register the same email twice, including with different letter case and leading or trailing spaces. Agree with the team whether Layla.H@example.com and layla.h@example.com are the same customer, then check that the database agrees.
- NOT NULL — send the field missing, empty and whitespace-only. An empty string is not NULL in PostgreSQL or MySQL, so NOT NULL alone does not stop blank names.
- CHECK — quantity above zero, amounts not negative, status within an allowed set. Test the boundaries (0, 1, the maximum) and one step outside them.
- FOREIGN KEY — create an order for a customer id that does not exist. The insert must fail and the API must return a meaningful error.
- Defaults, types and lengths — omitted fields get the documented default, and each column holds the widest valid input. A 50-character column behind a field the UI allows 100 characters for is a truncation bug waiting to happen.
Try it: Boundary Number Dataset Generator Try it: Special Character Dataset Generator Try it: Unicode Dataset Generator
Relationships, referential integrity and cascades
Referential integrity means every reference points to something that exists: every order item belongs to an existing order, every payment to an existing order, every order to an existing customer. When foreign keys are declared, the database enforces this. When they are not — common across microservices, in sharded or legacy schemas, and in tables loaded by batch jobs — the application must, and orphaned rows become one of the most frequent data defects a tester finds.
Cascade rules decide what happens to child rows when a parent is deleted or its key changes. ON DELETE CASCADE removes the children with the parent; RESTRICT or NO ACTION refuses the delete while children exist; SET NULL keeps the child and clears the reference. Each is right for some relationship and dangerous for another: cascading a customer delete into invoices may destroy records the business must keep. Test each relationship against the requirement, not against whatever the schema happens to do.
- Delete a parent through the product, then query every child table for rows that still reference it.
- Check soft deletes: a customer marked as deleted should disappear from lists, while their historical orders may need to stay readable.
- Run orphan-detection queries after batch imports, data migrations and failed jobs — the SQL page shows the LEFT JOIN … IS NULL pattern.
Stored procedures, triggers and back-end rules
When business logic lives in the database — a stored procedure that closes a month, a trigger that writes an audit row, a view that feeds a report — treat it like any other code: define inputs and expected outputs, and choose cases with equivalence partitioning and boundary values. Call procedures directly in a test database with prepared data, and also exercise them through the application path that uses them.
Triggers need special attention because they act silently. Check that they fire on every path that should fire them (insert, update and delete; single-row and bulk statements), that they do not fire twice, and that a failure inside a trigger rolls back the statement that caused it instead of leaving half the work done.
- Audit trigger — changing an order’s status writes exactly one audit row with the old and new value, the acting user and a server timestamp.
- Procedure boundaries — run a month-end procedure for February in a leap year, and for a month with no orders at all.
- Back-end validation — a rule enforced in the UI is also enforced by the API and, where designed, by the database. Bypass the UI with a direct API call to confirm.
Data consistency across services, caches and search indexes
Modern systems keep copies of data: a read replica, a cache, a search index, a reporting warehouse, another microservice’s own database. Each copy can drift from the source of truth. A price updated in the database but still cached, an order visible in the order service but missing from invoicing, a deleted customer still findable by search — these are consistency defects, and you find them by comparing the copies.
Reconciliation is the technique: count and sum the same population in two places and compare. Counts by status and by day, sums of amounts, and lists of ids present on one side but not the other catch most drift. Where replication is asynchronous, agree the acceptable delay with the team — eventual consistency is a design choice, not a defect — and test that the copies converge within it, including after failures, retries and replays.
- Orders created today: count and total in the order database, in the invoicing service and in the finance report.
- Search index: every active product id in the database is in the index, and no deleted id remains.
- Cache invalidation: update a record, then read it through the API immediately and after the cache lifetime; if the design says writes invalidate the cache, both reads return the new value.
- Event-driven flows: stop the consumer, create records, restart it, and confirm that nothing was lost or applied twice.
Try it: SQL IN Clause Generator Try it: SQL VALUES Generator
Test data: synthetic, seeded and resettable
Database tests need known data — known customers, known orders, known totals — so that the expected result can be written before the test runs. Build it deliberately: a seed script creates the baseline, each test adds what it needs and cleans up or runs inside a transaction that is rolled back, and the whole database can be reset to the baseline in minutes.
Do not copy production personal data into test environments. Saudi Arabia’s Personal Data Protection Law (PDPL), overseen by SDAIA, governs the processing of personal data, and a test environment holding real customers’ names, national ids, phone numbers and addresses is personal-data processing under weaker controls than production. Use synthetic data by default. If production-shaped data is genuinely needed, for example for a migration rehearsal, agree the approach with your data protection officer: masked data can still be personal data if it can be linked back to a person.
- Seed from generated SQL: CREATE TABLE and INSERT scripts for a realistic baseline, kept in version control beside the migrations.
- Include difficult values on purpose: maximum lengths, zero amounts, Arabic and Devanagari text, emoji, apostrophes in names, dates at month and year ends.
- Choose a reset strategy: roll back per test, truncate and re-seed per suite, restore a snapshot or template database, or start a fresh containerised database for each pipeline run.
- For performance and migration tests, generate data at production-like row counts, not ten rows.
Try it: SQL Dataset Generator Try it: SQL INSERT Generator Try it: CSV to SQL Try it: JSON to SQL
Performance at a tester’s level: indexes, query plans and N+1
You do not need to tune a database to find its performance defects. The usual cause is plain: a query that was fast on 200 test rows scans millions of rows in production because the column it filters on has no index. Test with realistic volume, watch response times as data grows, and ask for the query plan when something is slow.
EXPLAIN shows how the database intends to run a query — whether it uses an index or scans the whole table, and in what order it joins. PostgreSQL and MySQL provide EXPLAIN and EXPLAIN ANALYZE; SQLite provides EXPLAIN QUERY PLAN. EXPLAIN ANALYZE actually executes the statement, so never run it on an UPDATE or DELETE outside a transaction you roll back. You do not have to read every line of a plan: a full scan of a large table in a query that should return a handful of rows is worth a bug report.
- N+1 queries — a list page loads 50 orders with one query, then runs one more query per order to fetch its customer: 51 round trips instead of one or two. It shows up in the query log or an APM trace as a burst of near-identical statements.
- Deep pagination — OFFSET-based paging slows down on late pages of large tables; test page 1 and page 5,000.
- Unbounded queries — exports and reports without limits that load a whole table into memory.
- Missing indexes on foreign keys and on columns used in the WHERE, JOIN and ORDER BY of frequent queries.
Security: injection, privileges and personal data
SQL injection happens when user input is concatenated into SQL text. The OWASP defence is parameterised queries (prepared statements) everywhere, plus allow-list validation for parts that cannot be parameters, such as a sort column name. As a tester, confirm the expectation rather than writing exploits: put values containing quotes, semicolons, comment markers and other SQL metacharacters into every input and expect them to be stored and shown literally, with no database error, stack trace or change in behaviour. A database error message in any response is a finding in itself. Deeper injection testing belongs in an authorised security assessment.
Least privilege limits the damage when something does go wrong. The application account should have only what it needs — typically read and write on its own tables, no schema changes, no administrator rights — and migrations should run under a separate account. Testers and reporting tools should use read-only accounts.
- Find out which account the application connects with and what it may do; a production application running as the database owner is a finding.
- Look for personal data where it should not be: application logs, error messages, analytics events, CSV exports, backups on shared drives and test environments.
- Check that passwords are stored with a dedicated password-hashing algorithm (such as Argon2id, bcrypt, scrypt or PBKDF2, which salt each password) rather than a plain or fast general-purpose hash, and that tokens and other secrets are hashed or encrypted as designed, never in plain text.
- Audit columns — created_at, updated_at, created_by, updated_by — are set by the server, not accepted from the client; updated_at and updated_by change whenever the record changes, while created_at and created_by never change after the insert.
Typical defects, automation and CI
Database checks automate well because their expected results are exact. Put integrity and reconciliation queries in the same suite as the API tests: call the API, query the database, assert. Run the suite in the pipeline against a freshly migrated and freshly seeded database so every build proves that the migrations apply cleanly and the data rules still hold. Keep a small set of read-only integrity queries — orphans, duplicates, totals that do not match — to run on staging after each deployment and on a schedule.
When a check fails, the rows it returns are the evidence: attach the query and its output to the bug report. Defects found this way typically read like this:
- “Order total differs from the sum of item lines when a discount is applied to the last item”
- “Deleting a customer leaves orphaned payment rows (no foreign key on payments.order_id)”
- “Duplicate customer created when Register is double-clicked”
- “Arabic product names truncated in order_items.product_name”
- “Search still returns products deleted more than 24 hours ago”
- “Order history page runs one query per order (N+1): 4 s with 200 orders”
- “API returns 500 with the raw database error when the email already exists”
Frequently asked questions
Do testers need to know SQL for database testing?
Yes, at reading level: SELECT with WHERE, inner and left joins, GROUP BY with HAVING, aggregates such as COUNT and SUM, EXISTS, and basic transactions. That is enough to verify almost every change an application makes. Schema design and tuning remain developer and DBA work.
Should I test through the UI or directly in the database?
Both, for different purposes. Exercise the behaviour through the UI or API so the application code runs, then query the database to confirm the effect. Use direct SQL for preconditions, for verification and for integrity checks — not as a substitute for the feature.
Can we use a copy of production data for testing?
Not with personal data in it. Under Saudi Arabia’s PDPL, real customer data in a test environment is still personal data being processed. Use synthetic data by default, and involve your data protection officer if production-shaped data is truly necessary.
What is referential integrity?
The rule that every reference between tables points to a row that exists — no order items without an order, no payments without an order. Foreign keys enforce it in the database; without them, orphaned rows appear after deletes, imports and failed jobs.
Which tools does database testing need?
A SQL client connected with a read-only account, the product’s own UI and API, a way to generate and reset test data, and a test framework that can run SQL after API calls. Mutqan’s SQL and dataset generators help with seed scripts and edge-case values.
References
- Constraints — PostgreSQL DocumentationCheck, not-null, unique, primary-key, foreign-key and exclusion constraints — the rules a database enforces and a tester verifies.
- Transactions — PostgreSQL DocumentationWhat a transaction is: several steps applied all-or-nothing with BEGIN, COMMIT and ROLLBACK, invisible to others until committed.
- Transaction Isolation Levels — MySQL 8.4 Reference ManualThe four SQL isolation levels as InnoDB implements them, with REPEATABLE READ as its default.
- OWASP Cheat Sheet Series: SQL Injection PreventionParameterised queries, stored procedures used safely, allow-list validation and least privilege — the defences a database test should confirm.
- SQL InjectionHow SQL injection works and why parameterised queries, not string building, are the defence.
- Personal Data Protection Law (English translation)The Saudi law governing personal data — why test environments should use synthetic data rather than copies of real customer records.
- 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.
Practice exercise
Verify an order cancellation in the database
You test a fictional grocery delivery service on staging. Build 3.8.0 lets a customer cancel an order until it leaves the store. You cancelled a card-paid test order through the Android app, the way a customer would, and then checked the result in the database with a read-only account. The material below gives the six requirements, an excerpt of the schema, what happened in the app and on the server, and the output of your five queries.
Judge the result the way the guide teaches: write the expected state first, then compare it with the actual rows, and look beyond the target row — at its neighbours, at the copy of the data the app shows and at the schema itself. For each difference, name the area of database testing it belongs to. Pen and paper are enough; allow 40–50 minutes. The app log shows the Arabic reason as JSON \u escapes, which the Unicode Decoder turns back into text; if you write SQL in step 4, you can check its syntax with the SQL Query Validator from the guide’s tool list.
Fictional grocery delivery service: web shop, Android and iOS apps.
Staging, build 3.8.0, PostgreSQL database. Synthetic test accounts only.
Design rule: every timestamp is stored in UTC. Riyadh time is UTC+3.
New in 3.8: a customer can cancel an order until it leaves the store.
Requirements: cancel an order
CN-1 Only an order in PENDING or PACKING can be cancelled. For any other status
the API returns 409 ORDER_NOT_CANCELLABLE and nothing changes.
CN-2 The order row is kept: status CANCELLED, cancelled_at = the time of the cancel,
cancel_reason = the customer’s text as typed, and updated_at moves.
The order_items rows are kept, so that the order history still shows them.
CN-3 Every stock reservation of the order is deleted, so the stock can be sold again.
CN-4 If the order was paid by card, one refund row is created: amount = the order
total, status PENDING, linked to the card payment.
CN-5 Every status change writes exactly one row in order_status_audit.
CN-6 "My orders" in the apps reads through a cache whose entries live 5 minutes.
A status change removes the order’s entry, so the next read is current.
Schema excerpt (all tables are in the same database)
CREATE TABLE stock_reservations (
reservation_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_sku VARCHAR(40) NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0)
);
CREATE TABLE refunds (
refund_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (order_id),
payment_id BIGINT NOT NULL REFERENCES payments (payment_id),
amount NUMERIC(10,2) NOT NULL CHECK (amount > 0),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL
);
orders: order_id PRIMARY KEY, customer_id, status, total_amount, cancel_reason,
created_at, updated_at, cancelled_at
order_items.order_id REFERENCES orders (order_id) ON DELETE CASCADE
payments: payment_id PRIMARY KEY, order_id, amount, status
order_status_audit: audit_id, order_id, old_status, new_status, changed_by, changed_at
order_status_audit is filled by a trigger: AFTER UPDATE OF status ON orders.
stock_reservations has no index other than its primary key.
The test, through the Android app, as a customer would do it
Customer 5021 (test account). Order 88412, created 2026-10-05 07:12:40 UTC,
status PACKING, paid by card: payment 77031, 55.00 SAR.
Two stock reservations, one per item:
MLK-1L fresh milk 1 L: 2 x 6.50 SAR
RCE-5KG basmati rice 5 kg: 1 x 42.00 SAR
07:15:04 UTC: "Cancel order", with a reason typed in Arabic ("I ordered by mistake")
App network log (the app gives up on a request after 10 s and retries once)
07:15:04 POST /api/orders/88412/cancel
{"reason": "\u0637\u0644\u0628\u062a \u0628\u0627\u0644\u062e\u0637\u0623"}
no response within 10 s
07:15:15 POST /api/orders/88412/cancel
automatic retry with the same body
200 {"orderId": 88412, "status": "CANCELLED"}
Server log
07:15:04 POST /api/orders/88412/cancel 200 (10.6 s)
07:15:15 POST /api/orders/88412/cancel 200 (0.5 s)
App, "My orders": at 07:15:16 order 88412 shows "Packing";
at 07:20:30 it shows "Cancelled".
Database checks afterwards (read-only account)
Q1 SELECT status, cancel_reason, created_at, updated_at, cancelled_at
FROM orders WHERE order_id = 88412;
status: CANCELLED
cancel_reason: ???? ??????
created_at: 2026-10-05 07:12:40
updated_at: 2026-10-05 07:12:40
cancelled_at: 2026-10-05 10:15:15
Q2 SELECT product_sku, quantity, unit_price FROM order_items
WHERE order_id = 88412 ORDER BY order_item_id;
MLK-1L | 2 | 6.50
RCE-5KG | 1 | 42.00
Q3 SELECT reservation_id, product_sku, qty FROM stock_reservations
WHERE order_id = 88412;
5530182 | RCE-5KG | 1
Q4 SELECT refund_id, payment_id, amount, status, created_at FROM refunds
WHERE order_id = 88412 ORDER BY refund_id;
9101 | 77031 | 55.00 | PENDING | 2026-10-05 07:15:14
9102 | 77031 | 55.00 | PENDING | 2026-10-05 07:15:15
Q5 SELECT audit_id, old_status, new_status, changed_by, changed_at
FROM order_status_audit WHERE order_id = 88412 ORDER BY audit_id;
70411 | PENDING | PACKING | system | 2026-10-05 07:13:05
70415 | PACKING | CANCELLED | customer:5021 | 2026-10-05 07:15:14
70416 | CANCELLED | CANCELLED | customer:5021 | 2026-10-05 07:15:15- Before you read Q1 to Q5, write the expected state after one correct cancel of order 88412: the orders row, order_items, stock_reservations, refunds and order_status_audit, the response to the retried request, and what "My orders" should show.
- Compare the query output, the two logs and the app list with your expected state. For each difference, write the evidence (table, row, values), the requirement it breaks and the area of database testing it belongs to.
- List what is correct and must not be reported, with the reason.
- Review the schema excerpt: which rules does no constraint protect, what would you propose, and which read-only integrity queries would you add to the checks that run after every deployment?
- Rank your findings, and write the title, the expected result and the evidence of the first bug report.
Show the model answer
Step 1 — the expected state. orders: the row of 88412 still exists, with status CANCELLED, cancel_reason exactly طلبت بالخطأ, cancelled_at at about 07:15 UTC on 2026-10-05, updated_at moved to the same moment and created_at unchanged at 07:12:40. order_items: both rows unchanged (MLK-1L 2 × 6.50 and RCE-5KG 1 × 42.00). stock_reservations: no row for 88412 (CN-3). refunds: exactly one row, for payment 77031, with amount 55.00 (2 × 6.50 + 42.00) and status PENDING (CN-4). order_status_audit: the earlier row PENDING → PACKING plus exactly one new row PACKING → CANCELLED (CN-5). The retried request finds the order already CANCELLED, which is neither PENDING nor PACKING, so by CN-1 it receives 409 ORDER_NOT_CANCELLABLE and changes nothing. "My orders" shows Cancelled on the first read after the cancel (CN-6).
Step 2 — the differences. F1, the most serious: the cancel was carried out twice. The first request did succeed, but the server needed 10.6 s, so the app gave up after 10 s and retried; the retry found an order that was already CANCELLED and should have received 409. It received 200 and did the work again. Q4 shows two refunds, 9101 and 9102, of 55.00 each for the one payment 77031: 110.00 SAR is queued for refund on a 55.00 SAR order. Audit row 70416 (CANCELLED → CANCELLED) and a cancelled_at that matches the second request are traces of the same second run. It breaks CN-1 and CN-4. Area: functional data testing (a create must write exactly one row) and the idempotency problem the guide names under primary keys: a retried request must not create a second row for the same business event. How to fix it is the team’s decision: the status check that CN-1 already requires and, because the app retries by itself, preferably an idempotency key, so that a retry receives the first result instead of an error. Two identical requests at the same moment need a check that also holds under concurrency, which the transactions and migrations page covers. Either way, the database must end with one refund.
F2: a stock reservation was not released. Q3 still returns reservation 5530182 for RCE-5KG, while the reservation for MLK-1L is gone, so CN-3 was done only in part. Area: CRUD — a delete must remove exactly what the requirement says, and this is a partial write. The bag of rice stays reserved and cannot be sold, and other cancelled orders may block stock in the same way. Retest with orders of one, two and five items to show the developer the pattern.
F3: the Arabic cancel reason is lost. Q1 shows ???? ?????? where the customer typed طلبت بالخطأ: four and six question marks for words of four and six letters — the encoding defect from the guide’s CRUD section. It breaks CN-2. First make sure the question marks are really stored and are not just how your SQL client displays the text: SELECT octet_length(cancel_reason) FROM orders WHERE order_id = 88412 returns 11 if the value is ten question marks and a space, while the Arabic reason takes 21 bytes in UTF-8. Stored question marks cannot be turned back into the customer’s words. The request body decodes to exactly طلبت بالخطأ (\u0637 is the first letter, ط), so the text left the app intact and was damaged on the server side, on its way into the database. Ask how the service converts the text and which encoding the connection and the database use (in PostgreSQL the encoding is set for the whole database, not per column), save the same reason through the web shop and the iOS app to see whether one path or all of them are affected, and add Devanagari text and an emoji to the retest.
F4: cancelled_at is in Riyadh time. It reads 10:15:15, while the server log, audit row 70416 and refund 9102 put the same moment at 07:15:15 UTC. Exactly three hours ahead means the value was stored in local time, against the design rule. Area: CRUD (time zones). Everything computed from it is wrong: the order seems to have been cancelled 3 h 2 min 35 s after it was created instead of 2 min 35 s, and an hourly report of cancellations puts it in the wrong hour.
F5: updated_at does not move. It still equals created_at (07:12:40), although the order changed twice: to PACKING at 07:13:05 (audit row 70411) and then to CANCELLED. The guide’s rule is that every update moves updated_at and that the server sets it. Because the earlier change to PACKING missed it as well, the defect is probably in how updated_at is maintained in general, not only in the cancel code. Ask whether any job reads changes by updated_at, such as an incremental export to reporting: such a job would never pick up this cancel.
F6: "My orders" showed a stale status. At 07:15:16, after the server had answered CANCELLED, the app still listed 88412 as Packing, and it showed Cancelled only at 07:20:30, by which time the 5-minute cache entry had expired. CN-6 requires the change to remove the entry, so the first read after the cancel must already be current. Area: data consistency across copies (cache invalidation). A customer who sees Packing may try to cancel again or call support. To confirm, compare the API response with the app right after a cancel and read every 30 seconds until they agree; ask whether the list also reads from a read replica, which would be one more copy that can lag.
F7, smaller, in stored logic: audit row 70416 records CANCELLED → CANCELLED, which is not a status change. A trigger declared AFTER UPDATE OF status fires whenever an UPDATE sets that column, even to the value it already has; in PostgreSQL a condition such as WHEN (OLD.status IS DISTINCT FROM NEW.status) limits it to real changes. Fixing F1 hides this row but does not change the trigger, so report it separately. Record one more observation without calling it a defect yet: the first request took 10.6 s, longer than the app’s timeout, and that is what caused the retry. Ask for a trace of the request and the query plans of the cancel. The missing index on stock_reservations.order_id is one candidate, but the fast second request (0.5 s) suggests the time may have gone elsewhere, for example into a call to another service.
Step 3 — correct, not to be reported. The order row was kept, not deleted (CN-2). Both order_items rows are still there, which CN-2 requires for the order history; if you expected them to be gone, you tested against an assumption instead of the requirement, and the ON DELETE CASCADE on order_items does not matter here, because nothing was deleted. Each refund has the right amount, 55.00 SAR (2 × 6.50 + 42.00), and the right payment; the defect is that there are two. Audit row 70411 is the legitimate earlier change to PACKING. changed_by holds customer:5021, which the server set: the request body carried only the reason.
Step 4 — what the schema does not protect. (a) Nothing stops a second refund for the same payment. If the rule is one refund per payment, UNIQUE (payment_id) on refunds would have rejected refund 9102; if partial refunds are planned, the protection has to be the API’s idempotency plus a check that refunds never exceed the payment. Ask which rule applies, and test that a rejected insert reaches the app as 409, not as a 500 carrying the raw database error. (b) refunds.status accepts any text: propose a CHECK with the allowed values and ask for the list. (c) stock_reservations.order_id has no foreign key, although both tables are in the same database, so a reservation can point to an order that does not exist; propose the foreign key, and until it exists, check for orphans. (d) That column has no index either, so finding an order’s reservations reads the whole table; ask for the query plan on production-like volume. (e) Ask to read the trigger definition behind F7.
Read-only integrity queries for the checks after every deployment; each must return no rows. Duplicate refunds, once the team confirms one refund per payment: SELECT payment_id, COUNT(*) AS refund_count, SUM(amount) AS refunded FROM refunds GROUP BY payment_id HAVING COUNT(*) > 1. Refunds above the payment: SELECT p.payment_id, p.amount, SUM(r.amount) AS refunded FROM payments p JOIN refunds r ON r.payment_id = p.payment_id GROUP BY p.payment_id, p.amount HAVING SUM(r.amount) > p.amount. Reservations left on cancelled orders: SELECT r.reservation_id, r.order_id FROM stock_reservations r JOIN orders o ON o.order_id = r.order_id WHERE o.status = 'CANCELLED'. Orphan reservations: SELECT r.reservation_id, r.order_id FROM stock_reservations r LEFT JOIN orders o ON o.order_id = r.order_id WHERE o.order_id IS NULL. Audit rows that record no change: SELECT audit_id, order_id FROM order_status_audit WHERE old_status = new_status. Cancelled orders whose updated_at did not move: SELECT order_id FROM orders WHERE status = 'CANCELLED' AND (cancelled_at IS NULL OR updated_at < cancelled_at). Run the same queries in the pipeline after the API tests, on a freshly migrated and seeded database.
Step 5 — ranking and the first report. F1 comes first: it pays money out twice and happens whenever a slow response makes the app retry. Then F2 (stock blocked by cancelled orders), F3 (the customer’s words lost for good), F6 (the customer sees the wrong status for up to five minutes), F4 and F5 (wrong and stale timestamps that mislead reports and any sync job), and F7. Title of the first report: "Retried cancel creates a second refund: order 88412 has two 55.00 SAR refunds for one card payment". Steps: cancel a card-paid order in PACKING through the API, then send the same cancel again; or cancel in the app while the first response takes longer than 10 s. Expected: the second request receives 409 ORDER_NOT_CANCELLABLE (or the first result, if the team adds an idempotency key), and the database holds one refund and one new audit row. Evidence: build 3.8.0 on staging, test account 5021, order 88412, both log extracts, Q4 and Q5 with their output, and CN-1 and CN-4. Proposed severity: Critical.
Check your work. You should have six defects (F1 to F6), the smaller F7, the slow first request as an open question, and at least three correct results. If you found only F1 and F2, look again at the other columns of the target row (F3 to F5) and at the copy of the data in the app (F6): the response said CANCELLED and the main rows looked right, which is exactly why the guide asks you to confirm in the database and beyond the target row. Note how the test was run: the cancel went through the app, as a customer does it, and the database was only read, with a read-only account; setting status to CANCELLED with SQL would have skipped the application code in which most of these defects live. After the fixes, repeat the same test, retry included, as confirmation testing, and keep the queries above as regression checks.
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.
Part of the QA Roadmap
Before this topic
Next topics
Tools mentioned in this guide
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.
Convert a JSON array of objects into SQL INSERT statements (with optional CREATE TABLE).
Generate boundary values around limits: min-1, min, min+1, max-1, max, max+1 for integers of any width.
Generate inputs full of special characters: quotes, SQL/HTML/JSON metacharacters, control chars, emoji and path tricks.
Generate Unicode test strings across scripts (Arabic, Devanagari, CJK, Cyrillic), emoji, combining marks and normalisation forms.
Turn a list of values (one per line or comma separated) into a quoted SQL IN (...) clause.