Snowflake for Test-Result Warehousing
Most teams treat test results like a checkbox: green is good, red is bad, ship or block. The interesting signal lives between those two states — runtime variance across branches, retry inflation hiding real instability, the same five tests surfacing in every postmortem. That signal only becomes actionable when it's stored somewhere you can actually query it, join it against deployment events, and slice it by team, environment, or sprint boundary.
Snowflake has become a serious option for test-result warehousing because the workload fits its strengths: infrequent large writes (CI runs), infrequent but complex reads (trend queries, P95 runtimes, flake rates), and zero desire to babysit a database cluster at 2 AM. The trade-off versus PostgreSQL, ClickHouse, or BigQuery is real — Snowflake's per-second compute cost bites if you run dashboards that poll every 30 seconds, but for async analytics it's hard to beat.
By the end of this article you'll have a working schema, a Python ingest pipeline from JUnit XML, the SQL patterns that answer the questions engineering leaders actually ask, and a clear picture of where Snowflake earns its place versus where it doesn't.
Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.
What Test Results Actually Contain (and Why Schema Design Matters)
A test result in QE terms is more than pass/fail. A JUnit XML <testcase> element carries: test name, class, duration, status (passed/failed/skipped/errored), failure message, stack trace, and the suite-level metadata wrapping it — timestamp, hostname, and property bags your CI tool injects. GitHub Actions, Jenkins, and CircleCI all emit JUnit XML by default from Pytest (--junitxml), Playwright (reporter: 'junit'), and most JVM test runners. That raw XML is where the signal starts, but it's useless until it's normalized into rows.
In a modern test architecture, Snowflake sits one layer above your CI system. The pipeline emits JUnit XML → a lightweight ingest job parses and loads it → Snowflake holds the canonical history → Grafana or your BI tool queries it for the quality KPIs engineering leaders care about. The schema design at this layer is where most teams make expensive mistakes: storing raw XML as a VARIANT column feels clever until you're paying for full-table scans to answer "what's the P95 runtime for test_checkout_flow over the last 90 days?"
Building the Ingest Pipeline: Schema, Loader, and Core Queries
Start with a flat, query-friendly schema. Resist the urge to normalize into a separate test_suites table early — denormalization wins here because test-result queries almost always filter and aggregate at the case level.
CREATE TABLE test_results (
run_id STRING NOT NULL, -- CI build/run identifier
pipeline STRING, -- e.g. "github-actions", "jenkins"
branch STRING,
commit_sha STRING,
suite_name STRING,
test_name STRING NOT NULL,
test_class STRING,
status STRING NOT NULL, -- passed | failed | skipped | error
duration_ms NUMBER(12,2),
failure_message STRING,
stack_trace STRING,
retry_count NUMBER DEFAULT 0,
environment STRING,
recorded_at TIMESTAMP_NTZ NOT NULL
)
CLUSTER BY (recorded_at, branch, status);
The CLUSTER BY on recorded_at and status cuts micro-partition pruning costs dramatically for the most common query shape: "failures in the last 30 days on main." Without it, Snowflake scans every partition on a 6-month table.
The Python loader reads JUnit XML from a GitHub Actions artifact or an S3 drop zone and streams rows via the Snowflake Connector:
import xml.etree.ElementTree as ET
import snowflake.connector, os, datetime
def load_junit(xml_path: str, run_meta: dict):
tree = ET.parse(xml_path)
rows = []
for suite in tree.iter("testsuite"):
for case in suite.iter("testcase"):
failure = case.find("failure")
error = case.find("error")
rows.append((
run_meta["run_id"],
run_meta["pipeline"],
run_meta["branch"],
run_meta["commit_sha"],
suite.get("name"),
case.get("name"),
case.get("classname"),
"failed" if failure is not None else
"error" if error is not None else
"skipped" if case.find("skipped") is not None else "passed",
round(float(case.get("time", 0)) * 1000, 2),
(failure or error).text if (failure or error) is not None else None,
None, # stack_trace — parse from CDATA if needed
int(run_meta.get("retry_count", 0)),
run_meta.get("environment", "ci"),
datetime.datetime.utcnow()
))
conn = snowflake.connector.connect(
user=os.environ["SF_USER"], password=os.environ["SF_PASSWORD"],
account=os.environ["SF_ACCOUNT"], database="TEST_OBS", schema="PUBLIC"
)
conn.cursor().executemany(
"INSERT INTO test_results VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
rows
)
conn.close()
Wire this into GitHub Actions with a post-test step — it runs regardless of test outcome so failures are captured:
- name: Upload results to Snowflake
if: always()
run: python scripts/load_junit.py
env:
JUNIT_PATH: test-results/results.xml
SF_USER: ${{ secrets.SF_USER }}
SF_PASSWORD: ${{ secrets.SF_PASSWORD }}
SF_ACCOUNT: ${{ secrets.SF_ACCOUNT }}
RUN_ID: ${{ github.run_id }}
BRANCH: ${{ github.ref_name }}
COMMIT_SHA: ${{ github.sha }}
Once data is flowing, the queries that matter are straightforward. Flake rate over 14 days — tests that both pass and fail in the same period:
SELECT test_name,
COUNT_IF(status = 'failed') AS failures,
COUNT(*) AS total_runs,
ROUND(COUNT_IF(status = 'failed') / COUNT(*) * 100, 1) AS flake_pct
FROM test_results
WHERE recorded_at >= DATEADD(day, -14, CURRENT_TIMESTAMP())
AND branch = 'main'
GROUP BY test_name
HAVING COUNT_IF(status = 'passed') > 0
AND COUNT_IF(status = 'failed') > 0
ORDER BY flake_pct DESC
LIMIT 20;
One team running ~4,000 tests per day on three branches reported that triage time dropped from 22 minutes per failure to under 4 once this query backed a Grafana table panel — engineers could see the flake rate, click through to the pipeline heat map, and isolate the offending commit without digging through raw logs. For deeper trend analysis, the BigQuery vs Snowflake comparison covers where each engine's cost model diverges at scale.
Where Snowflake Ingest Pipelines Break Down in Practice
Retry inflation is the most common silent killer. If your CI runner retries a failed test automatically (Pytest's pytest-rerunfailures, Playwright's retries option), each attempt may produce its own <testcase> entry in the XML. Without a retry_count column and deduplication logic, your flake-rate query double- or triple-counts failures. The fix is normalizing retry attempts at ingest time — group by (run_id, test_name) and take the terminal status, not every intermediate one. Most teams skip this because the XML format doesn't make retries obvious; they look like independent test cases.
Warehouse suspension latency surprises teams running near-real-time dashboards. Snowflake's auto-suspend (default 10 minutes) means a cold query on a suspended XS warehouse takes 5–8 seconds just to resume. If Grafana is set to refresh every 60 seconds, you're paying resume costs on every panel load during off-hours. Set a dedicated reporting warehouse to auto-suspend at 2 minutes and pre-warm it with a lightweight heartbeat query during business hours, or accept the latency and set Grafana's minimum refresh to 5 minutes for cost sanity.
Myths That Stall Snowflake Test-Analytics Adoption
"Pass/fail rate is the signal." It isn't. A suite that passes 98% of the time while hiding a 400ms runtime regression on a critical path is lying to you. Duration percentiles — P50, P95, P99 per test over rolling windows — catch performance degradation before users do. Similarly, a test that always passes in isolation but fails 30% of the time in parallel runs is not a "passing test"; it's an infrastructure problem disguised as reliability. "Snowflake is overkill for test results" is the other common objection. It's valid at low volume (under ~50K test cases/day), but once you're joining test results against deployment events, feature flags, and on-call rotations to answer "did this deploy cause the spike?" — a warehouse with a SQL interface beats every alternative.
"A dashboard solves the problem." Dashboards surface patterns; they don't fix them. The gap between visibility and action is organizational, not technical. Wiring Snowflake to Grafana or Mode gives you the view, but without a defined owner for flaky-test remediation and a quarterly reliability scorecard to track progress, the dashboard becomes wallpaper within a sprint. Treat the warehouse as the source of truth; treat the dashboard as a forcing function for a conversation that has to happen in a retro or planning meeting.
Snowflake earns its place in test observability when your data volume outgrows what a transactional database handles cleanly and your queries need to join test history against other engineering signals. Start with the flat schema above, get the GitHub Actions loader running on if: always(), and validate the flake-rate query against two weeks of real data before building dashboards. Once the data is clean, the questions engineering leaders ask — "which tests are burning CI budget?" and "did reliability improve this quarter?" — answer themselves.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.