Python helps QA work in five areas: UI test automation, API testing, log analysis, load testing, and CI integration. What they share is that none of them can be done repeatedly by hand.
One piece of advice up front: trying to introduce all five at once will fail. Add them one at a time, in order of how quickly they pay off. This article gives working code for each, plus the parts that trip people up.
Sponsored
Where Python actually helps
| Problem | Tool | Effort |
|---|---|---|
| Regression testing keeps growing | Playwright / Selenium | High |
| API changes slip through | pytest + requests | Low |
| Reading error logs by eye takes hours | pandas | Low |
| Load testing is not happening | Locust | Medium |
| Tests only run on someone’s laptop | GitHub Actions / Jenkins | Medium |
Start with API testing or log analysis. Both take a few dozen lines and pay off the same day. UI testing has a large payoff too, but a high maintenance cost—it can wait.
1. UI automation: prefer Playwright
For new work, Playwright is the better choice. Its automatic waiting eliminates most of the flakiness that plagues Selenium suites.
| Selenium | Playwright | |
|---|---|---|
| Waiting | You write it (WebDriverWait) |
Automatic |
| Browser setup | Selenium Manager handles it | playwright install |
| Failure diagnosis | Implement screenshots yourself | Tracing built in |
| Existing material | Abundant | Newer |
Playwright
pip install pytest-playwright
playwright install chromium
from playwright.sync_api import Page, expect
def test_login(page: Page):
page.goto("https://example.com/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("password")
page.get_by_role("button", name="Log in").click()
# waits automatically for the element to appear
expect(page.get_by_role("heading", name="My page")).to_be_visible()
Use get_by_role and get_by_label. Finding elements by CSS class means tests break on every redesign; finding them by what users perceive—role and label—survives implementation changes.
Selenium
Existing suites are fine to keep. But waiting and cleanup are non-negotiable.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
button = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-testid='submit']"))
)
button.click()
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "[data-testid='result']"))
)
finally:
driver.quit()
Never use time.sleep()—too slow on fast machines, too short on slow ones. Details in test automation with Selenium, Appium and Jenkins.
Sponsored
2. API testing: pytest and requests
The lightest to adopt and the fastest to pay off. A few dozen lines gives you regression detection.
pip install pytest requests
import pytest
import requests
BASE_URL = "https://jsonplaceholder.typicode.com"
@pytest.fixture(scope="session")
def session():
s = requests.Session()
s.headers.update({"Accept": "application/json"})
yield s
s.close()
def test_get_post(session):
res = session.get(f"{BASE_URL}/posts/1", timeout=10)
assert res.status_code == 200
data = res.json()
assert data["id"] == 1
assert isinstance(data["userId"], int) # check the type as well
assert data["title"] # not an empty string
@pytest.mark.parametrize("post_id,expected", [
(1, 200),
(100, 200),
(99999, 404), # nonexistent id
])
def test_status_codes(session, post_id, expected):
res = session.get(f"{BASE_URL}/posts/{post_id}", timeout=10)
assert res.status_code == expected
- Always pass
timeout, or a hanging server hangs your test run forever - Assert types, not just presence. A
userIdarriving as a string slips pastassert "userId" in data - Use
parametrizefor boundaries—drop in the values from equivalence partitioning and boundary value analysis
Running Postman collections in CI
If you already have Postman scenarios, you do not need to rewrite them. Newman runs them directly.
npm install -g newman
newman run collection.json \
-e environment.json \
--reporters cli,junit \
--reporter-junit-export results.xml
Postman scripting is covered in Pre-request and Post-response in Postman.
Postman or pytest depends on your team. Postman if non-engineers write tests; pytest if you want them in code review.
3. Log analysis: stop reading by eye
This is the first thing to automate.
import pandas as pd
df = pd.read_csv("logs.csv", parse_dates=["timestamp"], encoding="utf-8-sig")
# errors only
errors = df[df["level"] == "ERROR"]
# most frequent first
counts = errors["error_message"].value_counts()
print(counts.head(10))
# occurrences by hour
by_hour = errors.set_index("timestamp").resample("1h").size()
print(by_hour[by_hour > 0])
Bucketing by time with resample() is the useful part. “They cluster at a particular hour” points you at a batch job or a deployment. Totals alone never reveal that.
Charts with non-Latin labels
Without configuration, Japanese labels render as boxes.
pip install matplotlib matplotlib-fontja
import matplotlib.pyplot as plt
import matplotlib_fontja # importing is enough
fig, ax = plt.subplots(figsize=(10, 5))
counts.head(10).sort_values().plot(kind="barh", ax=ax)
ax.set_title("Top 10 error messages")
ax.set_xlabel("Occurrences")
plt.tight_layout()
fig.savefig("errors.png", dpi=150, bbox_inches="tight")
- Use horizontal bars (
barh)—error messages are long and unreadable on a vertical axis - Call
sort_values()first, or the chart is not a comparison
More on plotting in data visualisation with Pandas and Matplotlib.
Simple anomaly detection
# flag hours that deviate from the norm
mean = by_hour.mean()
std = by_hour.std()
threshold = mean + 3 * std
spikes = by_hour[by_hour > threshold]
if not spikes.empty:
print("Unusual error volume detected")
print(spikes)
Fixed thresholds (“alert above 100”) do not survive contact with reality—error counts rise with traffic. Deviation from the mean is easier to maintain.
Sponsored
4. Load testing: Locust
Writing scenarios in Python makes it easier to reproduce real user behaviour than a GUI tool.
pip install locust
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
# wait one to three seconds between actions
wait_time = between(1, 3)
def on_start(self):
"""runs once per virtual user"""
res = self.client.post("/api/login", json={
"email": "user@example.com",
"password": "password",
})
self.token = res.json().get("token")
@task(3) # weight 3: runs three times as often
def view_top(self):
self.client.get("/", name="top page")
@task(1)
def search(self):
self.client.get("/search?q=test", name="search")
@task(1)
def view_detail(self):
# a shared name keeps variable URLs in one row of the report
self.client.get("/items/123", name="/items/[id]")
- Always set
wait_time—without it virtual users hammer without pause, producing load nobody generates in reality - Use
nameto group URLs./items/123and/items/456reported separately make results unreadable - Weight the tasks to match real usage. Hitting every page equally does not reflect anything
Run it headless in CI.
locust -f locustfile.py \
--headless \
--users 100 \
--spawn-rate 10 \
--run-time 3m \
--host https://staging.example.com \
--html report.html
Do not set --spawn-rate equal to the user count. Everyone arriving at once is not a situation that occurs.
Reading results
| Metric | How to read it |
|---|---|
| Mean | Indicative only; outliers distort it |
| 95th percentile | What users experience. Judge on this |
| Failure rate | Anything above zero needs a cause |
| RPS | Where it plateaus is your ceiling |
Only run load tests against environments you are authorised to test. Never against production or a service you do not control.
5. CI integration
Tests that live on a laptop deliver less than half their value.
name: API Test
on:
pull_request:
schedule:
- cron: '0 0 * * *' # nightly
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pytest tests/ --junitxml=results.xml
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
- name: Upload results
if: always() # keep artefacts even on failure
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
results.xml
screenshots/
if: always()matters. Without it, a failing run leaves no artefacts to investigate- Pass credentials from
secrets, never in code
Screenshots on UI test failure
# conftest.py
import pytest
from pathlib import Path
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
page = item.funcargs.get("page")
if page:
Path("screenshots").mkdir(exist_ok=True)
page.screenshot(path=f"screenshots/{item.name}.png", full_page=True)
Without this, a failed UI test in CI is essentially undiagnosable.
The order to adopt them
Work through them by payoff speed.
- 1. Log analysis: dozens of lines, effective immediately, needs nobody’s approval
- 2. API testing: less brittle than UI, cheap to maintain. Start with core endpoints
- 3. CI integration: get the above running automatically
- 4. UI testing: three to five primary flows. Do not chase coverage
- 5. Load testing: when you actually need it
The worst outcome is a suite whose failures nobody looks at. From that moment it is a liability. Establishing that failures always get acted on matters more than the count.
Summary
- For new UI tests prefer Playwright, locating elements with
get_by_role - With Selenium,
WebDriverWaitandtry/finallyare mandatory. Nevertime.sleep() - API tests need
timeout, and should assert types as well as presence - Existing Postman collections can run in CI via Newman—no rewrite needed
- Use
resample()for logs, andmatplotlib-fontjafor non-Latin chart labels - Detect anomalies by deviation from the mean, not a fixed threshold
- Locust needs
wait_timeandname. Judge on the 95th percentile - In CI use
if: always()and save screenshots on failure - Adopt in this order: logs → API → CI → UI → load
Before choosing tools, decide what you are trying to protect. That framing is covered in what to protect with E2E tests.