Day 18: 前端 UI 自动化测试
学习目标
完成今天的学习后,你将能够:
- 使用 Playwright 进行现代前端自动化测试
- 掌握 Selenium 进阶用法
- 实现页面对象模式 (Page Object Model)
- 进行跨浏览器测试和视觉回归测试
技术原理
Playwright vs Selenium
| 特性 | Playwright | Selenium |
|---|---|---|
| 速度 | 快 | 较慢 |
| 安装 | 简单 | 需要驱动 |
| 浏览器 | Chromium/Firefox/WebKit | 所有主流 |
| 自动等待 | 内置 | 需手动处理 |
| 网络拦截 | 支持 | 有限 |
页面对象模式
测试用例 → 页面对象 → 元素定位
↓
业务方法代码案例
案例1:Playwright 基础用法
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Playwright基础用法
功能:演示Playwright的基本操作
"""
from playwright.sync_api import sync_playwright, Page, Browser, BrowserContext
import pytest
from typing import Optional
class PlaywrightTest:
"""Playwright测试类"""
def __init__(self):
self.playwright = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
def setup(self, headless: bool = True):
"""初始化浏览器"""
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=headless)
self.context = self.browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'
)
self.page = self.context.new_page()
def teardown(self):
"""关闭浏览器"""
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
def navigate(self, url: str):
"""导航到页面"""
self.page.goto(url)
print(f"已导航到: {url}")
def click(self, selector: str):
"""点击元素"""
self.page.click(selector)
print(f"已点击: {selector}")
def fill(self, selector: str, value: str):
"""填写表单"""
self.page.fill(selector, value)
print(f"已填写: {selector} = {value}")
def get_text(self, selector: str) -> str:
"""获取文本"""
return self.page.text_content(selector)
def screenshot(self, filename: str):
"""截图"""
self.page.screenshot(path=filename)
print(f"截图已保存: {filename}")
class TestPlaywrightBasic:
"""Playwright基础测试"""
def setup_method(self):
"""每个测试方法前执行"""
self.test = PlaywrightTest()
self.test.setup(headless=True)
def teardown_method(self):
"""每个测试方法后执行"""
self.test.teardown()
def test_page_navigation(self):
"""测试页面导航"""
self.test.navigate("https://example.com")
# 验证页面标题
assert "Example" in self.test.page.title()
# 验证页面内容
content = self.test.page.text_content("h1")
assert "Example Domain" in content
def test_page_elements(self):
"""测试页面元素"""
self.test.navigate("https://example.com")
# 检查元素存在
assert self.test.page.locator("h1").is_visible()
assert self.test.page.locator("p").count() > 0
# 获取元素属性
link = self.test.page.locator("a")
href = link.get_attribute("href")
assert href is not None
def test_screenshot(self):
"""测试截图"""
self.test.navigate("https://example.com")
self.test.screenshot("test_screenshot.png")
assert self.test.page.screenshot() is not None
class TestPlaywrightAdvanced:
"""Playwright高级用法"""
def setup_method(self):
self.test = PlaywrightTest()
self.test.setup(headless=True)
def teardown_method(self):
self.test.teardown()
def test_wait_for_selector(self):
"""测试等待元素"""
self.test.navigate("https://example.com")
# 等待元素出现
self.test.page.wait_for_selector("h1")
assert self.test.page.locator("h1").is_visible()
def test_form_interaction(self):
"""测试表单交互"""
self.test.navigate("https://example.com")
# Playwright示例页面没有表单,这里演示方法
# 实际项目中:
# self.test.page.fill("input[name='username']", "testuser")
# self.test.page.fill("input[name='password']", "password")
# self.test.page.click("button[type='submit']")
assert True # 占位测试
def test_javascript_execution(self):
"""测试JavaScript执行"""
self.test.navigate("https://example.com")
# 执行JavaScript
title = self.test.page.evaluate("document.title")
assert title == "Example Domain"
# 修改页面
self.test.page.evaluate("document.title = 'Modified Title'")
assert self.test.page.title() == "Modified Title"
def test_network_interception(self):
"""测试网络拦截"""
self.test.navigate("https://example.com")
# 拦截请求
def handle_route(route):
route.continue_()
self.test.page.route("**/*", handle_route)
# 刷新页面
self.test.page.reload()
assert self.test.page.title() == "Example Domain"
# pytest fixtures
@pytest.fixture(scope="session")
def playwright_browser():
"""Playwright浏览器fixture"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture(scope="function")
def page(playwright_browser):
"""页面fixture"""
context = playwright_browser.new_context()
page = context.new_page()
yield page
context.close()
class TestWithFixtures:
"""使用fixture的测试"""
def test_example_with_fixture(self, page):
"""使用fixture的测试"""
page.goto("https://example.com")
assert page.title() == "Example Domain"
if __name__ == "__main__":
pytest.main([__file__, '-v'])案例2:Page Object Model
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
页面对象模式
功能:实现Page Object Model设计模式
"""
from playwright.sync_api import Page, Locator
from dataclasses import dataclass
from typing import Optional
@dataclass
class UserData:
"""用户数据"""
username: str
password: str
email: str
class BasePage:
"""基础页面类"""
def __init__(self, page: Page):
self.page = page
def navigate(self, url: str):
"""导航"""
self.page.goto(url)
def get_title(self) -> str:
"""获取标题"""
return self.page.title()
def screenshot(self, filename: str):
"""截图"""
self.page.screenshot(path=filename)
class LoginPage(BasePage):
"""登录页面"""
# 元素定位器
URL = "https://example.com/login"
# 使用data-testid定位更稳定
USERNAME_INPUT = "[data-testid='username']"
PASSWORD_INPUT = "[data-testid='password']"
LOGIN_BUTTON = "[data-testid='login-button']"
ERROR_MESSAGE = "[data-testid='error-message']"
def __init__(self, page: Page):
super().__init__(page)
def open(self):
"""打开登录页面"""
self.navigate(self.URL)
return self
def fill_username(self, username: str):
"""填写用户名"""
self.page.fill(self.USERNAME_INPUT, username)
return self
def fill_password(self, password: str):
"""填写密码"""
self.page.fill(self.PASSWORD_INPUT, password)
return self
def click_login(self):
"""点击登录按钮"""
self.page.click(self.LOGIN_BUTTON)
return self
def login(self, username: str, password: str):
"""登录操作"""
self.fill_username(username)
self.fill_password(password)
self.click_login()
return self
def get_error_message(self) -> Optional[str]:
"""获取错误消息"""
try:
error = self.page.locator(self.ERROR_MESSAGE)
if error.is_visible():
return error.text_content()
except:
pass
return None
def is_login_successful(self) -> bool:
"""检查登录是否成功"""
# 假设登录成功后会跳转到仪表盘
return "dashboard" in self.page.url
class DashboardPage(BasePage):
"""仪表盘页面"""
URL = "https://example.com/dashboard"
# 元素定位器
USER_MENU = "[data-testid='user-menu']"
LOGOUT_BUTTON = "[data-testid='logout-button']"
WELCOME_TEXT = "[data-testid='welcome-text']"
def __init__(self, page: Page):
super().__init__(page)
def open(self):
"""打开仪表盘"""
self.navigate(self.URL)
return self
def get_welcome_text(self) -> str:
"""获取欢迎文本"""
return self.page.text_content(self.WELCOME_TEXT)
def click_user_menu(self):
"""点击用户菜单"""
self.page.click(self.USER_MENU)
return self
def logout(self):
"""退出登录"""
self.click_user_menu()
self.page.click(self.LOGOUT_BUTTON)
return self
def is_loaded(self) -> bool:
"""检查页面是否加载完成"""
try:
self.page.wait_for_selector(self.WELCOME_TEXT, timeout=5000)
return True
except:
return False
class ProductPage(BasePage):
"""产品页面"""
URL = "https://example.com/products"
# 元素定位器
PRODUCT_LIST = "[data-testid='product-list']"
PRODUCT_ITEM = "[data-testid='product-item']"
SEARCH_INPUT = "[data-testid='search-input']"
SEARCH_BUTTON = "[data-testid='search-button']"
ADD_TO_CART_BUTTON = "[data-testid='add-to-cart']"
CART_COUNT = "[data-testid='cart-count']"
def __init__(self, page: Page):
super().__init__(page)
def open(self):
"""打开产品页面"""
self.navigate(self.URL)
return self
def search_product(self, keyword: str):
"""搜索产品"""
self.page.fill(self.SEARCH_INPUT, keyword)
self.page.click(self.SEARCH_BUTTON)
return self
def get_product_count(self) -> int:
"""获取产品数量"""
return self.page.locator(self.PRODUCT_ITEM).count()
def add_product_to_cart(self, index: int = 0):
"""添加产品到购物车"""
products = self.page.locator(self.PRODUCT_ITEM)
if products.count() > index:
products.nth(index).locator(self.ADD_TO_CART_BUTTON).click()
return self
def get_cart_count(self) -> int:
"""获取购物车数量"""
count_text = self.page.text_content(self.CART_COUNT)
return int(count_text) if count_text else 0
# 使用Page Object的测试
class TestLoginPage:
"""登录页面测试"""
def test_successful_login(self, page):
"""测试成功登录"""
login_page = LoginPage(page)
login_page.open()
login_page.login("testuser", "password123")
assert login_page.is_login_successful()
def test_failed_login(self, page):
"""测试失败登录"""
login_page = LoginPage(page)
login_page.open()
login_page.login("wronguser", "wrongpass")
error = login_page.get_error_message()
assert error is not None
assert "Invalid" in error
class TestDashboardPage:
"""仪表盘页面测试"""
def test_dashboard_loads(self, page):
"""测试仪表盘加载"""
dashboard = DashboardPage(page)
dashboard.open()
assert dashboard.is_loaded()
assert "Welcome" in dashboard.get_welcome_text()
class TestProductPage:
"""产品页面测试"""
def test_product_search(self, page):
"""测试产品搜索"""
product_page = ProductPage(page)
product_page.open()
product_page.search_product("phone")
# 等待搜索结果
page.wait_for_selector("[data-testid='product-item']")
assert product_page.get_product_count() > 0
def test_add_to_cart(self, page):
"""测试添加到购物车"""
product_page = ProductPage(page)
product_page.open()
initial_count = product_page.get_cart_count()
product_page.add_product_to_cart(0)
# 等待购物车更新
page.wait_for_timeout(1000)
assert product_page.get_cart_count() == initial_count + 1
if __name__ == "__main__":
pytest.main([__file__, '-v'])案例3:Selenium 进阶用法
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Selenium进阶用法
功能:演示Selenium的高级特性
"""
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
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import TimeoutException
import pytest
from typing import Optional
class SeleniumAdvanced:
"""Selenium高级用法"""
def __init__(self, headless: bool = True):
self.options = Options()
if headless:
self.options.add_argument('--headless')
self.options.add_argument('--no-sandbox')
self.options.add_argument('--disable-dev-shm-usage')
self.options.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=self.options)
self.wait = WebDriverWait(self.driver, 10)
def quit(self):
"""关闭浏览器"""
self.driver.quit()
def find_element_safe(self, by: By, value: str, timeout: int = 10) -> Optional[object]:
"""安全查找元素"""
try:
return WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
except TimeoutException:
return None
def click_when_ready(self, by: By, value: str, timeout: int = 10):
"""等待元素可点击后点击"""
element = WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((by, value))
)
element.click()
def scroll_to_element(self, by: By, value: str):
"""滚动到元素"""
element = self.driver.find_element(by, value)
self.driver.execute_script("arguments[0].scrollIntoView(true);", element)
def take_screenshot(self, filename: str):
"""截图"""
self.driver.save_screenshot(filename)
def execute_javascript(self, script: str, *args):
"""执行JavaScript"""
return self.driver.execute_script(script, *args)
class TestSeleniumAdvanced:
"""Selenium高级测试"""
def setup_method(self):
self.selenium = SeleniumAdvanced(headless=True)
def teardown_method(self):
self.selenium.quit()
def test_page_with_wait(self):
"""测试带等待的页面"""
self.selenium.driver.get("https://example.com")
# 等待元素出现
h1 = self.selenium.find_element_safe(By.TAG_NAME, "h1")
assert h1 is not None
assert "Example" in h1.text
def test_javascript_execution(self):
"""测试JavaScript执行"""
self.selenium.driver.get("https://example.com")
# 获取页面标题
title = self.selenium.execute_javascript("return document.title")
assert title == "Example Domain"
# 修改页面内容
self.selenium.execute_javascript(
"document.querySelector('h1').textContent = 'Modified'"
)
h1 = self.selenium.driver.find_element(By.TAG_NAME, "h1")
assert h1.text == "Modified"
def test_screenshot(self):
"""测试截图"""
self.selenium.driver.get("https://example.com")
self.selenium.take_screenshot("selenium_screenshot.png")
# 验证截图文件存在
import os
assert os.path.exists("selenium_screenshot.png")
# 清理
os.remove("selenium_screenshot.png")
# 多浏览器测试
class CrossBrowserTest:
"""跨浏览器测试"""
@staticmethod
def get_chrome_driver(headless: bool = True):
"""获取Chrome驱动"""
options = Options()
if headless:
options.add_argument('--headless')
return webdriver.Chrome(options=options)
@staticmethod
def get_firefox_driver(headless: bool = True):
"""获取Firefox驱动"""
options = webdriver.FirefoxOptions()
if headless:
options.add_argument('--headless')
return webdriver.Firefox(options=options)
def test_chrome(self):
"""Chrome浏览器测试"""
driver = self.get_chrome_driver()
try:
driver.get("https://example.com")
assert "Example" in driver.title
finally:
driver.quit()
def test_firefox(self):
"""Firefox浏览器测试"""
try:
driver = self.get_firefox_driver()
try:
driver.get("https://example.com")
assert "Example" in driver.title
finally:
driver.quit()
except Exception as e:
pytest.skip(f"Firefox不可用: {e}")
# 页面对象模式(Selenium版本)
class SeleniumLoginPage:
"""Selenium登录页面"""
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def open(self, url: str):
"""打开页面"""
self.driver.get(url)
return self
def login(self, username: str, password: str):
"""登录"""
username_input = self.wait.until(
EC.presence_of_element_located((By.NAME, "username"))
)
username_input.clear()
username_input.send_keys(username)
password_input = self.driver.find_element(By.NAME, "password")
password_input.clear()
password_input.send_keys(password)
submit_button = self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
submit_button.click()
return self
def is_logged_in(self) -> bool:
"""检查是否登录成功"""
try:
self.wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".dashboard"))
)
return True
except TimeoutException:
return False
if __name__ == "__main__":
pytest.main([__file__, '-v'])案例4:视觉回归测试
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视觉回归测试
功能:比较页面截图,检测视觉变化
"""
from playwright.sync_api import Page
import hashlib
import os
from PIL import Image
import numpy as np
from typing import Tuple
class VisualRegression:
"""视觉回归测试"""
def __init__(self, baseline_dir: str = "baseline", current_dir: str = "current"):
self.baseline_dir = baseline_dir
self.current_dir = current_dir
os.makedirs(baseline_dir, exist_ok=True)
os.makedirs(current_dir, exist_ok=True)
def capture_screenshot(self, page: Page, name: str, full_page: bool = True) -> str:
"""截图并保存"""
filepath = os.path.join(self.current_dir, f"{name}.png")
page.screenshot(path=filepath, full_page=full_page)
return filepath
def compare_images(self, baseline_path: str, current_path: str,
threshold: float = 0.95) -> Tuple[bool, float]:
"""比较两张图片"""
if not os.path.exists(baseline_path):
return False, 0.0
baseline = Image.open(baseline_path)
current = Image.open(current_path)
# 调整大小以匹配
if baseline.size != current.size:
current = current.resize(baseline.size)
# 转换为numpy数组
baseline_array = np.array(baseline)
current_array = np.array(current)
# 计算相似度
if baseline_array.shape != current_array.shape:
return False, 0.0
# 计算像素差异
diff = np.abs(baseline_array.astype(float) - current_array.astype(float))
similarity = 1 - (diff.mean() / 255)
return similarity >= threshold, similarity
def update_baseline(self, name: str):
"""更新基准图片"""
current_path = os.path.join(self.current_dir, f"{name}.png")
baseline_path = os.path.join(self.baseline_dir, f"{name}.png")
if os.path.exists(current_path):
import shutil
shutil.copy(current_path, baseline_path)
print(f"基准图片已更新: {baseline_path}")
def check_visual_regression(self, page: Page, name: str,
update_baseline: bool = False) -> dict:
"""检查视觉回归"""
# 截图
current_path = self.capture_screenshot(page, name)
baseline_path = os.path.join(self.baseline_dir, f"{name}.png")
# 更新基准
if update_baseline:
self.update_baseline(name)
return {'passed': True, 'similarity': 1.0, 'message': '基准已更新'}
# 比较
passed, similarity = self.compare_images(baseline_path, current_path)
return {
'passed': passed,
'similarity': similarity,
'baseline': baseline_path,
'current': current_path,
'message': '视觉检查通过' if passed else f'视觉差异: {similarity:.2%}'
}
class TestVisualRegression:
"""视觉回归测试"""
def setup_method(self):
self.visual = VisualRegression()
def test_example_page_visual(self, page):
"""测试示例页面视觉"""
page.goto("https://example.com")
result = self.visual.check_visual_regression(page, "example_page")
assert result['passed'], result['message']
def test_update_baseline(self, page):
"""更新基准图片"""
page.goto("https://example.com")
result = self.visual.check_visual_regression(
page, "example_page", update_baseline=True
)
assert result['passed']
# 使用方法
"""
1. 首次运行,更新基准图片:
pytest --update-baseline
2. 后续运行,检查视觉回归:
pytest
3. 查看差异:
- 基准图片在 baseline/ 目录
- 当前图片在 current/ 目录
"""课后练习
练习1:Playwright 测试
- 编写一个完整的页面交互测试
- 实现表单提交和验证
- 测试动态加载内容
练习2:Page Object 实现
- 为一个真实网站设计Page Object
- 实现至少3个页面对象
- 编写对应的测试用例
练习3:视觉回归测试
- 为项目页面创建基准截图
- 实现自动化视觉检查
- 配置CI/CD集成
常见问题
Q1: Playwright和Selenium如何选择?
A: 新项目推荐Playwright,旧项目维护可继续使用Selenium。
Q2: 如何处理动态内容?
A: 使用等待机制,如Playwright的自动等待或Selenium的显式等待。
Q3: 视觉测试如何处理动态内容?
A: 对动态区域进行屏蔽或使用更宽松的阈值。
下一步学习
完成今天的学习后,建议你:
- 为自己的项目编写UI测试
- 实现Page Object模式
- 准备进入Day 19的学习:脚本自动化实战
明天我们将学习如何使用Python进行文件批处理和办公自动化。