在测试自动化中,使用设计模式可以创建更好、更易于维护的测试代码。以下是常见模式的分解,包含示例和优点。
页面对象模型
页面对象模型是一种设计模式,其中每个网页或UI组件都由一个专门的类来表示。这些类定义了如何定位元素并在其对应的应用程序部分执行操作,从而使测试能够以有组织且可维护的方式与UI交互。
# pages/login_page.py
from selenium.webdriver.common.by import By
class LoginPage:
def __init__(self, driver):
self.driver = driver
self.username = (By.ID, "USERNAME")
self.password = (By.ID, "PASSWORD")
self.submit_button = (By.ID, "login")
def login(self, username, password):
self.driver.find_element(*self.username).send_keys(username)
self.driver.find_element(*self.password).send_keys(password)
self.driver.find_element(*self.submit_button).click()
# tests/test_login.py
import pytest
from pages.login_page import LoginPage
def test_successful_login(browser):
login_page = LoginPage(browser)
login_page.login("valid_username", "valid_password")
assert "Medium" in browser.title
优点:
- 模块化与可重用代码: 使代码模块化、可读且可重用。
- 提高可读性: 保持测试代码简洁且易于更新,因为每个页面的元素和操作都在一个类中。
- 更易于维护: 定位器或操作的更改只需在页面类中更新。
缺点:
- 初始设置复杂: 引入许多页面类增加了初始复杂性。
- 维护开销: 如果UI频繁更改,可能难以维护。
- 代码重复风险: 庞大或结构不良的页面类可能导致重复代码(例如共享元素)。
- 对简单测试过度设计: 对于非常简单的测试可能没有必要。
基于 Fixture 的设计
Pytest fixture 是一个强大的功能,通过提供可重用的资源来简化测试的设置和清理。无需在每个测试中设置像 Web 驱动或测试数据这样的通用工具,fixture 可以直接提供它们。这种方法使你的测试更简洁、更易于管理且更一致。Fixture 甚至可以相互依赖,并自动处理清理工作,确保每个测试都有一个可靠的起点。
# conftest.py
import pytest
from selenium import webdriver
from myapi import APIClient
@pytest.fixture(scope="session")
def api_client():
client = APIClient(base_url="https://api.medium.com")
yield client
client.close()
@pytest.fixture(scope="function")
def browser():
driver = webdriver.Chrome()
driver.maximize_window()
yield driver
driver.quit()
# tests/test_api_user.py
def test_get_user(api_client):
user = api_client.get_user(user_id="12ab34cd56ef")
assert user["name"] == "MediumWriter"
assert user["username"] == "@mediumwriter"
# tests/test_homepage.py
def test_homepage_title(browser):
browser.get("https://medium.com")
assert "Medium – Where good ideas find you" in browser.title
def test_navigation_menu(browser):
browser.get("https://medium.com")
nav_items = browser.find_elements_by_css_selector("nav ul li")
assert len(nav_items) > 0
def test_search_functionality(browser):
browser.get("https://medium.com")
search_button = browser.find_element_by_css_selector("[aria-label='Search']")
search_button.click()
search_input = browser.find_element_by_css_selector("input[type='search']")
search_input.send_keys("Python testing")
search_input.submit()
results = browser.find_elements_by_css_selector("article")
assert len(results) > 0
优点:
- 清晰的设置: Fixture 使测试设置清晰明了,并可在测试间重用。
- 灵活的作用域: 你可以为每个测试、每个文件或整个测试运行设置一次资源。
- 可重用性: Fixture 可以使用其他 fixture,保持你的设置代码井然有序。
- 自动清理: Pytest 为你处理关闭资源和其他清理任务。
- 可扩展: 适用于小型和大型测试项目。
缺点:
- 学习曲线: 对于初学者来说,fixture 系统可能有点令人困惑。
- 过度复杂化: 过多的 fixture 或它们之间复杂的连接会使测试更难理解。
数据驱动测试
数据驱动测试将测试逻辑与测试数据分离。一个测试函数使用不同的输入/输出对运行多次。在 pytest 中,这通常通过 @pytest.mark.parametrize 或加载外部数据集(CSV、JSON 等)来完成。这种模式让你可以用最少的代码覆盖许多情况。
import pytest
import requests
# 简单的参数化示例
@pytest.mark.parametrize("username,password,expected", [\
("medium_user1", "medium_pw1", True),\
("medium_user2", "wrong_password", False),\
("medium_user3", "medium_pw3", True),\
])
def test_login_api(username, password, expected):
response = requests.post("https://api.medium.com/v1/users/login",
json={"username": username, "password": password})
assert (response.status_code == 200) == expected
# 从 CSV 加载的示例
import csv
def load_users_from_csv():
with open('testdata/medium_users.csv') as f:
reader = csv.DictReader(f)
return list(reader)
@pytest.mark.parametrize("user", load_users_from_csv())
def test_user_creation(user):
# Medium API 使用令牌进行身份验证
headers = {"Authorization": f"Bearer {user['token']}"}
# 创建新的 Medium 帖子/文章
post_data = {
"title": user["post_title"],
"contentFormat": "html",
"content": f"<h1>{user['post_title']}</h1><p>{user['post_content']}</p>",
"publishStatus": "draft"
}
resp = requests.post(f"https://api.medium.com/v1/users/{user['id']}/posts",
headers=headers,
json=post_data)
assert resp.status_code == 201
# 测试 Medium 的公共端点
@pytest.mark.parametrize("username,expected_status", [\
("@medium", 200),\
("@mediumstaff", 200),\
("@nonexistentuser12345", 404)\
])
def test_public_profile_access(username, expected_status):
response = requests.get(f"https://medium.com/{username}")
assert response.status_code == expected_status
优点:
- 减少代码重复 — 一个测试可以运行多个数据案例。
- 早期创建测试 — 可以在应用程序准备就绪之前编写测试,使用预定义的输入和输出数据。
- 易于扩展 — 添加更多数据以覆盖新案例。
- 更好的覆盖率和更易于维护 — 测试逻辑与数据分离,使更新更简单。
缺点:
- 额外的复杂性 — 管理外部数据(文件、数据库)可能很棘手。
- 需要许多数据文件 — 需要良好的组织以避免混乱。
- 耗时的设置 — 创建和维护数据集需要付出努力,并可能导致错误(重复、不一致)。
- 更难调试 — 排查故障可能需要同时检查代码和数据。
线性/顺序
测试按固定顺序执行,其中每个测试都依赖于前一个测试的成功完成。这适用于每个步骤都建立在前一个步骤之上的流程(如创建 → 登录 → 更新),但如果较早的测试失败,整个链就会变得脆弱。
# test_linear.py
# 测试按顺序运行,每个测试都依赖于前一个
class TestLinearFlow:
user_id = None
def test_01_create_user(self):
# 创建用户
TestLinearFlow.user_id = 123
assert TestLinearFlow.user_id is not None
def test_02_login_user(self):
# 登录要求用户存在
assert TestLinearFlow.user_id is not None
# 登录逻辑在此
def test_03_update_profile(self):
# 更新要求用户已登录
assert TestLinearFlow.user_id is not None
# 更新逻辑在此
你可以尝试使用 pytest-dependency 包来更轻松地设置依赖测试。
# test_user_flow.py
import pytest
@pytest.mark.dependency(name="create_user")
def test_create_user():
# 模拟用户创建
user_id = 123
assert user_id is not None
@pytest.mark.dependency(depends=["create_user"])
def test_login_user():
# 如果 test_create_user 失败或被跳过,此测试将被跳过
logged_in = True # 替换为真实的登录逻辑
assert logged_in is True
@pytest.mark.dependency(depends=["create_user"])
def test_update_profile():
# 如果 create_user 失败,此测试也会被跳过
profile_updated = True # 替换为真实的更新逻辑
assert profile_updated is True
模块化架构
这种方法根据功能域对测试进行分组,从而简化维护并支持并行执行。
# tests/auth/test_authentication.py
class TestAuthentication:
def test_valid_login(self):
assert login("user", "pass") == True
def test_invalid_login(self):
assert login("user", "wrong") == False
# tests/user/test_user_management.py
class TestUserManagement:
def test_create_user(self):
user = create_user("john", "john@test.com")
assert user.name == "john"
def test_delete_user(self):
result = delete_user(123)
assert result == True
# tests/payment/test_payment.py
class TestPayment:
def test_process_payment(self):
result = process_payment(100.0, "card")
assert result.status == "success"
关键字驱动
创建可重用的测试组件,非技术团队成员可以理解和使用。
# keywords.py - 关键字库
class WebKeywords:
def __init__(self, driver):
self.driver = driver
def open_browser(self, url):
self.driver.get(url)
def enter_text(self, locator, text):
element = self.driver.find_element(*locator)
element.send_keys(text)
def click_element(self, locator):
element = self.driver.find_element(*locator)
element.click()
def verify_text(self, locator, expected_text):
element = self.driver.find_element(*locator)
assert element.text == expected_text
# test_keyword_driven.py
class TestKeywordDriven:
def setup_method(self):
self.keywords = WebKeywords(driver)
def test_login_flow(self):
# 使用关键字进行测试
self.keywords.open_browser("https://example.com/login")
self.keywords.enter_text(("id", "username"), "testuser")
self.keywords.enter_text(("id", "password"), "testpass")
self.keywords.click_element(("id", "login-btn"))
self.keywords.verify_text(("class", "welcome"), "Welcome testuser")
混合架构
结合多种方法,在实际项目中非常常见。
# 结合数据驱动 + 模块化 + 关键字驱动
class TestHybridEcommerce:
@pytest.fixture
def keywords(self):
return EcommerceKeywords()
@pytest.mark.parametrize("product_data", [\
{"name": "Laptop", "price": 999, "category": "Electronics"},\
{"name": "Book", "price": 29, "category": "Education"},\
])
def test_add_product(self, keywords, product_data):
# 使用关键字驱动的数据驱动方法
keywords.navigate_to_admin()
keywords.add_product(product_data)
keywords.verify_product_added(product_data["name"])
def test_checkout_flow(self, keywords):
# 结账流程的模块化测试
keywords.add_to_cart("Laptop")
keywords.proceed_to_checkout()
keywords.fill_shipping_details()
keywords.complete_payment()
keywords.verify_order_success()
行为驱动开发
使用自然语言描述,非常适合技术和非技术利益相关者之间的协作。
# 使用 pytest-bdd
from pytest_bdd import scenarios, given, when, then, parsers
# 从 feature 文件加载场景
scenarios('../features/login.feature')
# 步骤定义
@given('我在登录页面')
def on_login_page(browser):
browser.get('https://example.com/login')
@when(parsers.parse('我输入用户名 "{username}" 和密码 "{password}"'))
def enter_credentials(browser, username, password):
browser.find_element(By.ID, 'username').send_keys(username)
browser.find_element(By.ID, 'password').send_keys(password)
@when('我点击登录按钮')
def click_login(browser):
browser.find_element(By.ID, 'login-btn').click()
@then(parsers.parse('我应该看到 "{message}"'))
def verify_message(browser, message):
assert message in browser.page_source
# 不使用 pytest-bdd 的替代 BDD 风格
class TestBDDStyle:
def test_user_login_success(self):
# Given
self.given_user_is_on_login_page()
# When
self.when_user_enters_valid_credentials()
self.when_user_clicks_login()
# Then
self.then_user_should_be_logged_in()
def given_user_is_on_login_page(self):
# 设置代码
pass
def when_user_enters_valid_credentials(self):
# 操作代码
pass
def when_user_clicks_login(self):
# 操作代码
pass
def then_user_should_be_logged_in(self):
# 断言代码
pass
微服务
为分布式系统分离单元测试、集成测试、契约测试和端到端测试。
# test_microservices.py
import pytest
import requests
from unittest.mock import Mock, patch
class TestUserService:
"""用户服务的单元测试"""
def test_user_creation(self):
user_service = UserService()
user = user_service.create_user("john", "john@test.com")
assert user.name == "john"
class TestUserServiceIntegration:
"""用户服务的集成测试"""
def test_user_service_with_database(self):
# 使用真实数据库进行测试
response = requests.post("/api/users", json={"name": "john"})
assert response.status_code == 201
class TestServiceContracts:
"""服务之间的契约测试"""
@patch('payment_service.charge_card')
def test_order_service_payment_contract(self, mock_payment):
mock_payment.return_value = {"status": "success", "transaction_id": "123"}
order_service = OrderService()
result = order_service.process_order(order_data)
# 验证契约
mock_payment.assert_called_with(
amount=order_data['total'],
card_token=order_data['payment']['token']
)
class TestEndToEnd:
"""跨服务的端到端测试"""
def test_complete_order_flow(self):
# 测试跨多个服务的完整流程
user = create_user_via_api("john")
product = create_product_via_api("laptop")
cart = add_to_cart_via_api(user.id, product.id)
order = checkout_via_api(cart.id)
assert order.status == "completed"
基于流水线
使用标记按 CI/CD 流水线中的执行阶段组织测试。
```python
conftest.py - 流水线配置
import pytest
def pytest_configure(config): # 配置不同的测试类型 config.addinivalue_line("markers", "unit: 单元测试") config.addinivalue_line("markers", "integration: 集成测试") config.addinivalue_line("markers", "e2e: 端到端测试") config.addinivalue_line("markers", "smoke: 冒烟测试")
test_pipeline_stages.py
@pytest.mark.unit class TestUnitLevel: """快速的单元测试 - 每次提交时运行"""
def test_calculate_total(self):
assert calculate_total([10, 20, 30]) == 60
def test_validate_email(self):
assert validate_email("test@example.com") == True
@pytest.mark.integration class TestIntegrationLevel: """集成测试 - 在拉取请求时运行"""
def test_database_connection(self):
db = Database()
assert db.connect() == True
def test_api_endpoint(self):
response = requests.get("/api/health")
assert response.status_code == 200
@pytest.mark.smoke class TestSmokeLevel: """关键路径测试 - 部署后运行"""
def test_homepage_loads(self):
response = requests.get("https://production-app.com")
assert response.status_code == 200
def test_user_can_login(self):
# 测试关键的登录功能
pass
@pytest.mark.e2e class TestEndToEndLevel: """完整的系统测试 - 每晚运行"""
def test_complete_user_journey(self):
# 完整的端到端测试逻辑
pass
