Every few releases, something slips through. A button label that wraps on iPhone SE. A white text on white background in dark mode that nobody caught before shipping.
I've been building and maintaining wallpaper and wellness apps since 2014 — they've reached over 50 million downloads combined — and release QA has always been the part that doesn't scale well when you're working alone. The number of device-size and dark/light mode combinations grows, but the hours in the day don't.
What finally helped me plug most of those gaps was Gemini 3.2 Flash's vision capability. Pass it a screenshot, ask if anything looks wrong, and it gives you a surprisingly specific answer.
What I Wanted to Catch
Before explaining the code, here's what I was actually looking for:
- Text clipping and overflow: Labels that escape their containers when the user's font size is set to large
- Dark mode contrast failures: The white-text-on-white-background scenario I embarrassingly shipped once
- Safe area violations: Elements overlapping the status bar or home indicator
Checking these manually across a matrix of device × dark/light × locale means reviewing dozens of screenshots. Handing them to Gemini is faster, and it doesn't get tired.
The Basic Implementation
This is the core of what I built — one screenshot in, a structured review out.
import google.generativeai as genai
from pathlib import Path
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-3.2-flash")
def review_screenshot(image_path: str, context: str = "") -> dict:
"""
Review a single app screenshot for UI issues.
Pass 'context' to describe the screen (e.g. "Settings screen, dark mode").
Better context = better accuracy.
"""
image_data = Path(image_path).read_bytes()
prompt = f"""
This is a mobile app screenshot.
{f"Screen description: {context}" if context else ""}
Check the UI quality for the following:
1. Text clipping or overflow outside its container
2. Element overlap or layout breaks
3. Insufficient contrast between text and background (especially in dark mode)
4. Buttons or tap targets that appear too small
If you find issues, list them. If everything looks fine, say so.
Respond in JSON: {{"status": "ok" or "issues_found", "issues": ["..."]}}
"""
response = model.generate_content([
prompt,
{"mime_type": "image/png", "data": image_data}
])
import json, re
json_match = re.search(r'\{.*\}', response.text, re.DOTALL)
if json_match:
return json.loads(json_match.group())
return {"status": "parse_error", "issues": [response.text]}
# Usage
result = review_screenshot(
"screenshots/settings_dark_iphone15pro.png",
context="Settings screen, dark mode, iPhone 15 Pro"
)
print(result)
# {"status": "issues_found", "issues": ["Label on row 3 appears to extend beyond its container"]}Batch Processing Before a Release
Once the single-image version worked, I added batch processing to handle a full release screenshot set.
import google.generativeai as genai
from pathlib import Path
import json, re, time
from typing import NamedTuple
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-3.2-flash")
class ScreenshotCheckResult(NamedTuple):
file: str
status: str
issues: list[str]
def batch_review(screenshot_dir: str, config_json: str = None) -> list[ScreenshotCheckResult]:
"""
Review all PNG screenshots in a directory.
config_json: {"filename": "screen description"} mapping for better accuracy.
"""
configs = {}
if config_json:
with open(config_json) as f:
configs = json.load(f)
screenshots = sorted(Path(screenshot_dir).glob("*.png"))
results = []
for i, path in enumerate(screenshots):
context = configs.get(path.name, "")
print(f"[{i+1}/{len(screenshots)}] Checking {path.name}...")
image_data = path.read_bytes()
prompt = f"""
Mobile app screenshot QA review.
{f"Screen: {context}" if context else ""}
Check for:
- Text clipping or overflow
- Layout breaks or element overlap
- Contrast issues (especially dark mode)
- Tap target sizing problems
JSON response only: {{"status": "ok" or "issues_found", "issues": ["..."]}}
"""
try:
response = model.generate_content([
prompt,
{"mime_type": "image/png", "data": image_data}
])
json_match = re.search(r'\{.*\}', response.text, re.DOTALL)
if json_match:
data = json.loads(json_match.group())
results.append(ScreenshotCheckResult(
file=path.name,
status=data.get("status", "unknown"),
issues=data.get("issues", [])
))
except Exception as e:
results.append(ScreenshotCheckResult(
file=path.name, status="error", issues=[str(e)]
))
# Rate limit buffer
if i < len(screenshots) - 1:
time.sleep(1)
return results
def print_report(results: list[ScreenshotCheckResult]):
ok_count = sum(1 for r in results if r.status == "ok")
issue_count = sum(1 for r in results if r.status == "issues_found")
print(f"\n{'='*50}")
print(f"Screenshot QA Report")
print(f"{'='*50}")
print(f"✅ No issues: {ok_count}")
print(f"❌ Issues found: {issue_count}")
for r in results:
if r.status == "issues_found":
print(f"\n📱 {r.file}")
for issue in r.issues:
print(f" → {issue}")
# Run it
results = batch_review("./release_screenshots/", "./screenshot_configs.json")
print_report(results)Your screenshot_configs.json looks like this:
{
"home_light_iphone15.png": "Home screen, light mode, iPhone 15",
"home_dark_iphone15pro.png": "Home screen, dark mode, iPhone 15 Pro",
"settings_light.png": "Settings screen, light mode",
"settings_dark.png": "Settings screen, dark mode"
}What I Learned After Three Months of Using This
Accuracy is better than I expected for the things that matter most. It won't catch a 2px misalignment, but the "white text on white background" class of problems — the ones that actually land in your crash reports and reviews — it catches reliably. That covers around 90% of the visual bugs I used to ship.
Context descriptions are the biggest lever. The single most impactful improvement I made was adding proper screen descriptions to each file. Without knowing it's a dark mode screenshot, Gemini sometimes interprets it as light mode and misses contrast issues entirely. Always include light mode or dark mode in the context string.
Why Gemini 3.2 Flash specifically. I tested this on 2.5 Flash first. Both work, but 3.2 Flash gives more specific descriptions of what's wrong — sometimes including which UI element is affected. The cost difference between the two is small enough that 3.2 Flash is the better choice here.
One image per request. You can technically pass multiple images in one API call, but the responses get mixed together and accuracy drops. One image per request is slower but consistently more accurate.
Hooking It Into GitHub Actions
Here's the GitHub Actions config I use for release branches:
# .github/workflows/ui-qa.yml
name: UI Screenshot QA
on:
workflow_dispatch:
push:
branches: [release/*]
jobs:
screenshot-qa:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Capture screenshots
run: |
xcodebuild test \
-scheme "MyApp" \
-destination "platform=iOS Simulator,name=iPhone 15" \
-testPlan "ScreenshotCapture"
- name: Run Gemini Vision QA
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
pip install google-generativeai --quiet
python scripts/qa_screenshots.py ./screenshots/ --config screenshot_configs.json
- name: Upload QA report
uses: actions/upload-artifact@v4
with:
name: qa-report
path: qa_report.txtThe report artifact only matters when something fails, so it doesn't add noise to the normal workflow.
Where This Doesn't Help
I want to be honest about the limits. This approach doesn't catch animation problems, interaction state issues, or anything that requires actually tapping through the app. VoiceOver accessibility testing also can't be done from a static screenshot.
Think of it as automating the part of your QA that currently means scrolling through screenshots in Finder. It's not a replacement for XCTest or Espresso — it's what happens before those, when you're deciding whether the build is worth deeper testing at all.
Where to Start
Set up the single-image version first and run it on your last 10 screenshots. See what Gemini catches and whether it matches what you would have flagged manually. Once you have a feel for how it interprets your app's UI style, adding the batch runner takes another 15 minutes.
The script is small enough that the real investment is building screenshot_configs.json with accurate descriptions for your screens — that's where the quality improvement actually comes from.
Related reading: