Capture Failing UI Tests as Video with Pytest, Selenium, and FFmpeg
This guide shows how to automatically record videos of failed Selenium UI tests in a Pytest environment using a pure‑Python solution with FFmpeg, storing only the necessary clips, integrating them into Allure reports, and optimizing performance across Windows, macOS, and Linux.
Why video recording matters for UI test flakiness
Intermittent UI test failures (missing elements, unresponsive clicks, rendering glitches) are difficult to diagnose with only screenshots and logs. Recording the test execution provides a continuous, replayable context that serves as a debugging tool.
Core objectives
Record video only when a test case fails, reducing storage usage.
Keep recording lightweight and non‑blocking.
Attach videos automatically to Allure reports.
Support Windows, macOS, and Linux.
Why FFmpeg?
FFmpeg is an industry‑standard audio‑video processing tool. It offers real‑time screen capture, hardware‑accelerated H.264 encoding, and low CPU overhead, enabling on‑the‑fly MP4 generation without large intermediate files.
Step 1 – Install dependencies
Install FFmpeg for your operating system and the required Python packages.
# Windows
# Download from https://www.gyan.dev/ffmpeg/builds/ and add the <em>bin</em> folder to PATH
# macOS
brew install ffmpeg
# Linux (Ubuntu)
sudo apt install ffmpeg
# Python packages
pip install selenium pytest allure-pytest opencv-pythonVerify the installation with ffmpeg -version.
Step 2 – Implement the video recorder
# utils/video_recorder.py
import os
import subprocess
import threading
import time
from pathlib import Path
class VideoRecorder:
def __init__(self, output_path: str, fps: int = 10):
self.output_path = Path(output_path)
self.fps = fps
self.process = None
self._stop_event = threading.Event()
self._thread = None
def start(self):
"""Start recording in a background thread."""
if os.name == 'nt': # Windows
cmd = [
'ffmpeg', '-f', 'gdigrab', '-framerate', str(self.fps),
'-i', 'desktop', '-vcodec', 'libx264', '-preset', 'ultrafast',
'-pix_fmt', 'yuv420p', '-y', str(self.output_path)
]
else: # macOS / Linux
display = os.environ.get('DISPLAY', ':0.0')
cmd = [
'ffmpeg', '-f', 'x11grab', '-framerate', str(self.fps),
'-s', '1920x1080', '-i', display,
'-vcodec', 'libx264', '-preset', 'ultrafast',
'-pix_fmt', 'yuv420p', '-y', str(self.output_path)
]
startupinfo = None
if os.name == 'nt':
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
startupinfo=startupinfo
)
self._stop_event.clear()
self._thread = threading.Thread(target=self._monitor, daemon=True)
self._thread.start()
def stop(self):
"""Stop recording."""
if self.process:
self._stop_event.set()
try:
self.process.stdin.write(b'q')
self.process.stdin.flush()
except Exception:
pass
self.process.wait(timeout=5)
if self.process.poll() is None:
self.process.kill()
self.process = None
def _monitor(self):
"""Monitor recording status."""
while not self._stop_event.is_set():
time.sleep(0.1)Key parameters: -preset ultrafast for speed, -pix_fmt yuv420p for browser compatibility, and a default fps of 10 to balance clarity and file size.
Step 3 – Hook into Pytest + Selenium
# conftest.py
import pytest, allure, os, tempfile
from utils.video_recorder import VideoRecorder
from selenium import webdriver
@pytest.fixture(scope="function")
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new") # remove for visual debugging
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
@pytest.fixture(scope="function")
def video_recorder(request):
"""Video recorder fixture."""
test_name = request.node.name
video_path = os.path.join(tempfile.gettempdir(), f"{test_name}.mp4")
recorder = VideoRecorder(video_path)
recorder.start()
yield recorder
recorder.stop()
if hasattr(request.node, 'rep_call') and request.node.rep_call.failed:
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
with open(video_path, "rb") as f:
allure.attach(f.read(),
name="Test video",
attachment_type=allure.attachment_type.MP4) # test_login.py
def test_login_failure(driver, video_recorder):
"""Intentionally fail to trigger video capture."""
driver.get("https://example.com/login")
driver.find_element("id", "username").send_keys("admin")
driver.find_element("id", "password").send_keys("wrong_password")
driver.find_element("id", "submit").click()
assert "Welcome" in driver.page_sourceAdvanced optimizations
Record only the browser window
Adjust the FFmpeg command to include -offset_x, -offset_y, and -video_size based on the window rectangle obtained via pygetwindow. This reduces file size and protects privacy.
Dynamic quality settings
if os.getenv("CI"):
fps = 5 # lower frame rate in CI
preset = "superfast"
else:
fps = 10
preset = "ultrafast"Automatic cleanup of old videos
import shutil
from datetime import datetime, timedelta
from pathlib import Path
import tempfile
def cleanup_old_videos():
tmp_dir = Path(tempfile.gettempdir())
for video in tmp_dir.glob("test_*.mp4"):
if datetime.now() - datetime.fromtimestamp(video.stat().st_mtime) > timedelta(hours=24):
video.unlink()Common pitfalls and solutions
FFmpeg not found : Ensure the FFmpeg binary is in PATH or use an absolute path.
Empty video file : Verify that FFmpeg has permission to capture the screen; on macOS/Linux you may need to grant screen‑recording permission.
Stuttering recordings : Reduce fps to 5‑10 and use the ultrafast preset.
CI without GUI : Headless mode cannot capture the screen; consider generating a video from a sequence of screenshots instead.
Large files : Limit recording duration (e.g., 60 s) or transcode with higher compression.
Conclusion
Integrating video capture turns UI automation into a “black box” that records the exact moment of failure. The recorded MP4 can be attached to Allure reports, enabling developers to reproduce issues instantly and reducing the “works on my machine” debate.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
