SQL Tutorial › SQL Projects › Project: QA Test Results Tracker

SQL Project: QA Test Results Tracker

ProjectProject 5 of 530 min read13 queries run on MySQL 8.4

Test automation produces thousands of results, and SQL turns them into answers: did this build get better or worse, which tests are flaky, what broke between two builds, how much is automated, and how fast do we fix defects? In this project you are the QA lead of a web, API and mobile product with six nightly runs stored in MySQL. Every query is one a real test team runs.

Advertisement
In this lesson
  • Calculate pass rates per run and per suite
  • Find the tests that fail now, flip between results, or newly regressed
  • Measure execution time and automation coverage
  • Report defect counts and time to fix

The database

TableOne row perNotes
test_suitessuiteplatform: Web, API, Mobile
test_casestest casepriority P1-P3, automated 1 or 0
test_runsnightly runthe build it tested, environment
test_resultscase in a runstatus: PASSED, FAILED, SKIPPED; duration_ms NULL when skipped
defectsbug linked to a caseseverity, status, opened_on, closed_on
test_suites · 5 rows
suite_idsuite_nameplatform
1Login & AuthWeb
2CheckoutWeb
3SearchWeb
4Payments APIAPI
5Mobile SmokeMobile
test_runs · 6 rows
run_idbuildstarted_atenvironment
12.4.02025-09-01 02:00:00staging
22.4.12025-09-08 02:00:00staging
32.5.02025-09-15 02:00:00uat
42.5.12025-09-22 02:00:00staging
52.6.02025-09-29 02:00:00staging
62.6.12025-10-06 02:00:00uat
test_cases · 22 rows
case_idsuite_idtitlepriorityautomated
11Valid loginP11
21Invalid passwordP21
31Locked accountP20
41Password reset emailP21
51Logout clears sessionP10
62Add to cartP30
72Apply couponP31
82Remove itemP20
92Guest checkoutP11
102Order confirmation emailP11
113Search by keywordP21
123Filter by priceP11
133Sort by ratingP21
143No results messageP10
154Create payment 201P21
164Refund paymentP21
174Invalid card 402P21
184Idempotency keyP31
195App launchP20
205Biometric loginP21
215Push notificationP21
223Voice searchP30
test_results · first 10 of 126 rows
run_idcase_idstatusduration_ms
11PASSED5969
12PASSED968
13PASSED6333
14PASSED1927
15PASSED2640
16PASSED2146
17FAILED6356
18PASSED8730
19PASSED3101
110PASSED2746
defects · 6 rows
defect_idcase_idseveritystatusopened_onclosed_on
118CriticalOpen2025-09-29NULL
27MajorOpen2025-09-10NULL
313MinorClosed2025-09-092025-09-15
42MajorClosed2025-09-022025-09-04
510CriticalClosed2025-09-162025-09-17
621MinorOpen2025-10-06NULL
qa.sql
-- sqatools.in SQL project 5: QA test results tracker (generated by gen_projects.py)CREATE TABLE test_suites (  suite_id   INT PRIMARY KEY,  suite_name VARCHAR(30) NOT NULL,  platform   VARCHAR(10) NOT NULL);CREATE TABLE test_cases (  case_id   INT PRIMARY KEY,  suite_id  INT NOT NULL,  title     VARCHAR(40) NOT NULL,  priority  VARCHAR(2) NOT NULL,  automated INT NOT NULL,  FOREIGN KEY (suite_id) REFERENCES test_suites (suite_id));CREATE TABLE test_runs (  run_id      INT PRIMARY KEY,  build       VARCHAR(10) NOT NULL,  started_at  DATETIME NOT NULL,  environment VARCHAR(10) NOT NULL);CREATE TABLE test_results (  run_id      INT,  case_id     INT,  status      VARCHAR(8) NOT NULL,  duration_ms INT,  PRIMARY KEY (run_id, case_id),  FOREIGN KEY (run_id) REFERENCES test_runs (run_id),  FOREIGN KEY (case_id) REFERENCES test_cases (case_id));CREATE TABLE defects (  defect_id INT PRIMARY KEY,  case_id   INT NOT NULL,  severity  VARCHAR(10) NOT NULL,  status    VARCHAR(8) NOT NULL,  opened_on DATE NOT NULL,  closed_on DATE,  FOREIGN KEY (case_id) REFERENCES test_cases (case_id));INSERT INTO test_suites VALUES(1, 'Login & Auth', 'Web'),(2, 'Checkout', 'Web'),(3, 'Search', 'Web'),(4, 'Payments API', 'API'),(5, 'Mobile Smoke', 'Mobile');INSERT INTO test_cases VALUES(1, 1, 'Valid login', 'P1', 1),(2, 1, 'Invalid password', 'P2', 1),(3, 1, 'Locked account', 'P2', 0),(4, 1, 'Password reset email', 'P2', 1),(5, 1, 'Logout clears session', 'P1', 0),(6, 2, 'Add to cart', 'P3', 0),(7, 2, 'Apply coupon', 'P3', 1),(8, 2, 'Remove item', 'P2', 0),(9, 2, 'Guest checkout', 'P1', 1),(10, 2, 'Order confirmation email', 'P1', 1),(11, 3, 'Search by keyword', 'P2', 1),(12, 3, 'Filter by price', 'P1', 1),(13, 3, 'Sort by rating', 'P2', 1),(14, 3, 'No results message', 'P1', 0),(15, 4, 'Create payment 201', 'P2', 1),(16, 4, 'Refund payment', 'P2', 1),(17, 4, 'Invalid card 402', 'P2', 1),(18, 4, 'Idempotency key', 'P3', 1),(19, 5, 'App launch', 'P2', 0),(20, 5, 'Biometric login', 'P2', 1),(21, 5, 'Push notification', 'P2', 1),(22, 3, 'Voice search', 'P3', 0);INSERT INTO test_runs VALUES(1, '2.4.0', '2025-09-01 02:00:00', 'staging'),(2, '2.4.1', '2025-09-08 02:00:00', 'staging'),(3, '2.5.0', '2025-09-15 02:00:00', 'uat'),(4, '2.5.1', '2025-09-22 02:00:00', 'staging'),(5, '2.6.0', '2025-09-29 02:00:00', 'staging'),(6, '2.6.1', '2025-10-06 02:00:00', 'uat');INSERT INTO test_results VALUES(1, 1, 'PASSED', 5969),(1, 2, 'PASSED', 968),(1, 3, 'PASSED', 6333),(1, 4, 'PASSED', 1927),(1, 5, 'PASSED', 2640),(1, 6, 'PASSED', 2146),(1, 7, 'FAILED', 6356),(1, 8, 'PASSED', 8730),(1, 9, 'PASSED', 3101),(1, 10, 'PASSED', 2746),(1, 11, 'PASSED', 7067),(1, 12, 'FAILED', 3760),(1, 13, 'FAILED', 5395),(1, 14, 'PASSED', 1147),(1, 15, 'PASSED', 8947),(1, 16, 'PASSED', 8762),(1, 17, 'PASSED', 7227),(1, 18, 'PASSED', 2634),(1, 19, 'PASSED', 30092),(1, 20, 'PASSED', 33695),(1, 21, 'PASSED', 33975),(2, 1, 'PASSED', 2131),(2, 2, 'PASSED', 978),(2, 3, 'SKIPPED', NULL),(2, 4, 'PASSED', 3910),(2, 5, 'SKIPPED', NULL),(2, 6, 'SKIPPED', NULL),(2, 7, 'FAILED', 1852),(2, 8, 'SKIPPED', NULL),(2, 9, 'PASSED', 8013),(2, 10, 'PASSED', 2729),(2, 11, 'PASSED', 2948),(2, 12, 'PASSED', 6213),(2, 13, 'PASSED', 4826),(2, 14, 'SKIPPED', NULL),(2, 15, 'PASSED', 3753),(2, 16, 'PASSED', 8448),(2, 17, 'PASSED', 1503),(2, 18, 'PASSED', 975),(2, 19, 'SKIPPED', NULL),(2, 20, 'PASSED', 32236),(2, 21, 'PASSED', 30448),(3, 1, 'PASSED', 1993),(3, 2, 'PASSED', 2459),(3, 3, 'PASSED', 6402),(3, 4, 'PASSED', 4759),(3, 5, 'PASSED', 6222),(3, 6, 'PASSED', 7457),(3, 7, 'PASSED', 5799),(3, 8, 'PASSED', 2869),(3, 9, 'FAILED', 4755),(3, 10, 'PASSED', 3893),(3, 11, 'PASSED', 8181),(3, 12, 'PASSED', 4826),(3, 13, 'PASSED', 6064),(3, 14, 'PASSED', 5385),(3, 15, 'PASSED', 2439),(3, 16, 'PASSED', 5068),(3, 17, 'PASSED', 1965),(3, 18, 'PASSED', 1934),(3, 19, 'PASSED', 28607),(3, 20, 'PASSED', 32897),(3, 21, 'PASSED', 28175),(4, 1, 'PASSED', 8998),(4, 2, 'PASSED', 6061),(4, 3, 'SKIPPED', NULL),(4, 4, 'PASSED', 6423),(4, 5, 'SKIPPED', NULL),(4, 6, 'SKIPPED', NULL),(4, 7, 'PASSED', 1813),(4, 8, 'SKIPPED', NULL),(4, 9, 'PASSED', 8461),(4, 10, 'PASSED', 6125),(4, 11, 'PASSED', 2249),(4, 12, 'PASSED', 8424),(4, 13, 'FAILED', 7935),(4, 14, 'SKIPPED', NULL),(4, 15, 'PASSED', 5023),(4, 16, 'PASSED', 5049),(4, 17, 'PASSED', 2330),(4, 18, 'PASSED', 6865),(4, 19, 'SKIPPED', NULL),(4, 20, 'PASSED', 29325),(4, 21, 'PASSED', 33969),(5, 1, 'PASSED', 3634),(5, 2, 'PASSED', 6497),(5, 3, 'PASSED', 8728),(5, 4, 'PASSED', 3981),(5, 5, 'PASSED', 8592),(5, 6, 'PASSED', 4813),(5, 7, 'FAILED', 7004),(5, 8, 'PASSED', 7863),(5, 9, 'PASSED', 2223),(5, 10, 'PASSED', 8987),(5, 11, 'PASSED', 4101),(5, 12, 'PASSED', 7382),(5, 13, 'PASSED', 5341),(5, 14, 'PASSED', 8458),(5, 15, 'PASSED', 2612),(5, 16, 'FAILED', 8287),(5, 17, 'PASSED', 6899),(5, 18, 'FAILED', 5129),(5, 19, 'PASSED', 29258),(5, 20, 'PASSED', 30288),(5, 21, 'PASSED', 27586),(6, 1, 'PASSED', 8385),(6, 2, 'PASSED', 8038),(6, 3, 'PASSED', 5577),(6, 4, 'PASSED', 3822),(6, 5, 'PASSED', 6710),(6, 6, 'PASSED', 2270),(6, 7, 'FAILED', 7986),(6, 8, 'PASSED', 8345),(6, 9, 'PASSED', 2902),(6, 10, 'PASSED', 2769),(6, 11, 'PASSED', 8079),(6, 12, 'PASSED', 8427),(6, 13, 'FAILED', 2514),(6, 14, 'PASSED', 5783),(6, 15, 'PASSED', 7571),(6, 16, 'PASSED', 8342),(6, 17, 'PASSED', 6743),(6, 18, 'FAILED', 3983),(6, 19, 'PASSED', 26334),(6, 20, 'PASSED', 29237),(6, 21, 'PASSED', 33799);INSERT INTO defects VALUES(1, 18, 'Critical', 'Open', '2025-09-29', NULL),(2, 7, 'Major', 'Open', '2025-09-10', NULL),(3, 13, 'Minor', 'Closed', '2025-09-09', '2025-09-15'),(4, 2, 'Major', 'Closed', '2025-09-02', '2025-09-04'),(5, 10, 'Critical', 'Closed', '2025-09-16', '2025-09-17'),(6, 21, 'Minor', 'Open', '2025-10-06', NULL);

Task 1: Pass rate per run

MySQL
SELECT r.run_id, r.build, r.environment,       COUNT(*)                     AS executed,       SUM(tr.status = 'PASSED')    AS passed,       SUM(tr.status = 'FAILED')    AS failed,       ROUND(100 * SUM(tr.status = 'PASSED') / COUNT(*), 1) AS pass_rateFROM test_runs rJOIN test_results tr ON tr.run_id = r.run_idWHERE tr.status <> 'SKIPPED'GROUP BY r.run_id, r.build, r.environmentORDER BY r.run_id;
ResultMySQL 8.4
run_idbuildenvironmentexecutedpassedfailedpass_rate
12.4.0staging2118385.7
22.4.1staging1514193.3
32.5.0uat2120195.2
42.5.1staging1514193.3
52.6.0staging2118385.7
62.6.1uat2118385.7
6 rows in set
How this query works, step by step
  1. Skipped tests are excluded: pass rate is usually measured over tests that actually ran.
  2. SUM(status = 'PASSED') counts passes because a true comparison is 1.
  3. The trend down the column shows whether quality is improving build by build.

Task 2: Pass rate per suite in the latest run

MySQL
SELECT s.suite_name, s.platform,       COUNT(*) AS executed,       ROUND(100 * SUM(tr.status = 'PASSED') / COUNT(*), 1) AS pass_rateFROM test_results trJOIN test_cases c  ON c.case_id = tr.case_idJOIN test_suites s ON s.suite_id = c.suite_idWHERE tr.run_id = (SELECT MAX(run_id) FROM test_runs)  AND tr.status <> 'SKIPPED'GROUP BY s.suite_name, s.platformORDER BY pass_rate, s.suite_name;
ResultMySQL 8.4
suite_nameplatformexecutedpass_rate
Payments APIAPI475.0
SearchWeb475.0
CheckoutWeb580.0
Login & AuthWeb5100.0
Mobile SmokeMobile3100.0
5 rows in set

The subquery (SELECT MAX(run_id) FROM test_runs) always picks the newest run, so the report needs no editing after each night.

Task 3: What is failing right now?

SQL
SELECT c.case_id, s.suite_name, c.title, c.priority, tr.duration_msFROM test_results trJOIN test_cases c  ON c.case_id = tr.case_idJOIN test_suites s ON s.suite_id = c.suite_idWHERE tr.run_id = (SELECT MAX(run_id) FROM test_runs)  AND tr.status = 'FAILED'ORDER BY c.priority, s.suite_name;
ResultMySQL 8.4
case_idsuite_nametitlepriorityduration_ms
13SearchSort by ratingP22514
7CheckoutApply couponP37986
18Payments APIIdempotency keyP33983
3 rows in set

Task 4: Newly broken tests (regressions)

A regression is a test that passed in the previous run and fails in this one. LAG puts each result next to the same test’s previous result:

SQL
WITH history AS (  SELECT tr.case_id, tr.run_id, tr.status,         LAG(tr.status) OVER (PARTITION BY tr.case_id ORDER BY tr.run_id) AS previous_status  FROM test_results tr)SELECT h.run_id, r.build, c.title, h.previous_status, h.statusFROM history hJOIN test_cases c ON c.case_id = h.case_idJOIN test_runs r  ON r.run_id = h.run_idWHERE h.previous_status = 'PASSED' AND h.status = 'FAILED'ORDER BY h.run_id, c.title;
ResultMySQL 8.4
run_idbuildtitleprevious_statusstatus
32.5.0Guest checkoutPASSEDFAILED
42.5.1Sort by ratingPASSEDFAILED
52.6.0Apply couponPASSEDFAILED
52.6.0Idempotency keyPASSEDFAILED
52.6.0Refund paymentPASSEDFAILED
62.6.1Sort by ratingPASSEDFAILED
6 rows in set
How this query works, step by step
  1. PARTITION BY case_id ORDER BY run_id makes a timeline per test.
  2. LAG(status) reads the test’s status in the run before.
  3. Keeping PASSED → FAILED rows lists every regression and the build that introduced it (LAG & LEAD).

Task 5: Flaky tests

A flaky test changes result between runs without a code reason. Count how many times each test flipped status:

SQL
WITH history AS (  SELECT case_id, run_id, status,         LAG(status) OVER (PARTITION BY case_id ORDER BY run_id) AS prev  FROM test_results  WHERE status <> 'SKIPPED')SELECT c.case_id, c.title,       SUM(h.status <> h.prev)    AS flips,       SUM(h.status = 'FAILED')   AS failures,       COUNT(*)                   AS runsFROM history hJOIN test_cases c ON c.case_id = h.case_idGROUP BY c.case_id, c.titleHAVING flips >= 2ORDER BY flips DESC, c.case_id;
ResultMySQL 8.4
case_idtitleflipsfailuresruns
13Sort by rating436
7Apply coupon246
9Guest checkout216
16Refund payment216
4 rows in set
How this query works, step by step
  1. LAG compares each result with the previous executed result of the same test.
  2. SUM(status <> prev) counts changes. The first row’s prev is NULL, so the comparison is NULL and adds nothing.
  3. Two or more flips over six runs is a strong flakiness signal; a test that broke once and stayed broken flips only once, so it is not reported here.

Task 6: Slowest tests

MySQL
SELECT c.title, s.platform,       COUNT(tr.duration_ms)                AS timed_runs,       ROUND(AVG(tr.duration_ms) / 1000, 1) AS avg_seconds,       ROUND(MAX(tr.duration_ms) / 1000, 1) AS max_secondsFROM test_results trJOIN test_cases c  ON c.case_id = tr.case_idJOIN test_suites s ON s.suite_id = c.suite_idGROUP BY c.case_id, c.title, s.platformORDER BY avg_seconds DESCLIMIT 5;
ResultMySQL 8.4
titleplatformtimed_runsavg_secondsmax_seconds
Push notificationMobile631.334.0
Biometric loginMobile631.333.7
App launchMobile428.630.1
Refund paymentAPI67.38.8
Remove itemWeb47.08.7
5 rows in set

AVG and COUNT(duration_ms) skip the NULL durations of skipped tests.

Task 7: Run duration trend

MySQL
SELECT r.run_id, r.build,       ROUND(SUM(tr.duration_ms) / 60000, 1) AS total_minutes,       ROUND(SUM(tr.duration_ms) / 60000             - LAG(SUM(tr.duration_ms) / 60000) OVER (ORDER BY r.run_id), 1) AS change_minutesFROM test_runs rJOIN test_results tr ON tr.run_id = r.run_idGROUP BY r.run_id, r.buildORDER BY r.run_id;
ResultMySQL 8.4
run_idbuildtotal_minuteschange_minutes
12.4.03.1NULL
22.4.11.8-1.2
32.5.02.91.0
42.5.12.3-0.6
52.6.03.31.0
62.6.13.30.0
6 rows in set

Task 8: Automation coverage

MySQL
SELECT s.suite_name,       COUNT(*)                  AS cases,       SUM(c.automated)          AS automated,       ROUND(100 * SUM(c.automated) / COUNT(*)) AS automation_pctFROM test_suites sJOIN test_cases c ON c.suite_id = s.suite_idGROUP BY s.suite_id, s.suite_nameORDER BY automation_pct, s.suite_name;
ResultMySQL 8.4
suite_namecasesautomatedautomation_pct
Checkout5360
Login & Auth5360
Search5360
Mobile Smoke3267
Payments API44100
5 rows in set

Test cases that have never been executed at all:

SQL
SELECT c.case_id, c.title, c.automatedFROM test_cases cLEFT JOIN test_results tr ON tr.case_id = c.case_idWHERE tr.case_id IS NULL;
ResultMySQL 8.4
case_idtitleautomated
22Voice search0
1 row in set

Task 9: High-priority health

P1 and P2 tests that failed at least once in the last three runs:

MySQL
SELECT c.title, c.priority,       GROUP_CONCAT(CONCAT('run ', tr.run_id, ': ', tr.status) ORDER BY tr.run_id SEPARATOR ', ') AS last_threeFROM test_results trJOIN test_cases c ON c.case_id = tr.case_idWHERE c.priority IN ('P1', 'P2')  AND tr.run_id > (SELECT MAX(run_id) - 3 FROM test_runs)GROUP BY c.case_id, c.title, c.priorityHAVING SUM(tr.status = 'FAILED') > 0;
ResultMySQL 8.4
titleprioritylast_three
Sort by ratingP2run 4: FAILED, run 5: PASSED, run 6: FAILED
Refund paymentP2run 4: PASSED, run 5: FAILED, run 6: PASSED
2 rows in set
How this query works, step by step
  1. run_id > MAX(run_id) - 3 keeps the three most recent runs.
  2. GROUP_CONCAT lists each test’s recent history in one cell.
  3. HAVING keeps only tests with at least one failure among them.

Task 10: Defect metrics

MySQL
SELECT severity,       COUNT(*)                      AS total,       SUM(status = 'Open')          AS still_open,       ROUND(AVG(DATEDIFF(closed_on, opened_on)), 1) AS avg_days_to_fixFROM defectsGROUP BY severityORDER BY FIELD(severity, 'Critical', 'Major', 'Minor');
ResultMySQL 8.4
severitytotalstill_openavg_days_to_fix
Critical211.0
Major212.0
Minor216.0
3 rows in set
How this query works, step by step
  1. AVG(DATEDIFF(closed_on, opened_on)) uses only closed defects, because DATEDIFF with a NULL closed_on is NULL and AVG skips it.
  2. ORDER BY FIELD(...) sorts by business severity, not alphabetically.

Open defects whose test still fails in the latest run, which are the ones to discuss in stand-up:

MySQL
SELECT d.defect_id, d.severity, c.title, d.opened_on,       DATEDIFF('2025-10-06', d.opened_on) AS days_openFROM defects dJOIN test_cases c    ON c.case_id = d.case_idJOIN test_results tr ON tr.case_id = d.case_id AND tr.run_id = (SELECT MAX(run_id) FROM test_runs)WHERE d.status = 'Open' AND tr.status = 'FAILED'ORDER BY FIELD(d.severity, 'Critical', 'Major', 'Minor'), days_open DESC;
ResultMySQL 8.4
defect_idseveritytitleopened_ondays_open
1CriticalIdempotency key2025-09-297
2MajorApply coupon2025-09-1026
2 rows in set

Task 11: Results matrix (pivot)

One row per test, one column per run: the classic test-results grid:

MySQL
SELECT c.case_id, c.title,       MAX(CASE WHEN tr.run_id = 4 THEN LEFT(tr.status, 1) END) AS run_4,       MAX(CASE WHEN tr.run_id = 5 THEN LEFT(tr.status, 1) END) AS run_5,       MAX(CASE WHEN tr.run_id = 6 THEN LEFT(tr.status, 1) END) AS run_6FROM test_cases cJOIN test_results tr ON tr.case_id = c.case_idWHERE c.suite_id IN (2, 4)GROUP BY c.case_id, c.titleORDER BY c.case_id;
ResultMySQL 8.4
case_idtitlerun_4run_5run_6
6Add to cartSPP
7Apply couponPFF
8Remove itemSPP
9Guest checkoutPPP
10Order confirmation emailPPP
15Create payment 201PPP
16Refund paymentPFP
17Invalid card 402PPP
18Idempotency keyPFF
9 rows in set
How this query works, step by step
  1. Each MAX(CASE WHEN run_id = n THEN ... END) picks that run’s status for the test (there is only one, so MAX simply returns it).
  2. LEFT(status, 1) shortens PASSED / FAILED / SKIPPED to P / F / S.
  3. This is the conditional-aggregation pivot from CASE.

Your turn

Exercise: Failures per platform

Count failed results per platform across all runs.

Expected resultMySQL 8.4
platformfailures
Web9
API3
2 rows in set
Hint 1

Join results → cases → suites, filter status = 'FAILED', group by platform.

Exercise: Tests that never failed

List automated tests that passed every time they ran.

Expected resultMySQL 8.4
case_idtitle
1Valid login
2Invalid password
4Password reset email
10Order confirmation email
11Search by keyword
15Create payment 201
17Invalid card 402
20Biometric login
21Push notification
9 rows in set
Hint 1

Group by test and use HAVING SUM(status = 'FAILED') = 0.

What you practised

TaskSQL skills
Pass rates, coverage, defectsconditional SUM, ROUND, AVG(DATEDIFF), FIELD
Latest run reportsscalar subquery (SELECT MAX(run_id) ...)
Regressions, flaky tests, run trendLAG() OVER (PARTITION BY ... ORDER BY ...)
Never executedLEFT JOIN ... IS NULL
Results matrixpivot with MAX(CASE ...)
Tip

Testers use SQL beyond reports: to prepare test data, to verify what an application really stored after a UI action, and to check database constraints. See the SQL tutorial for every topic and the 150+ SQL interview questions.

Project FAQ

Why should testers learn SQL?

Testers use SQL to set up and clean test data, verify backend results after UI or API actions, check data integrity rules, and analyse automation results like the queries in this project.

How do you find flaky tests with SQL?

Compare each result with the previous result of the same test using LAG(status) OVER (PARTITION BY case_id ORDER BY run_id), count the status changes, and flag tests that change repeatedly.

How do you calculate test pass rate in SQL?

100 * SUM(status = 'PASSED') / COUNT(*) over executed (non-skipped) results, grouped by run, suite or build.