测试自动化设计模式

1 天前   出处: Medium  作/译者:明月

在测试自动化中,使用设计模式可以创建更好、更易于维护的测试代码。以下是常见模式的分解,包含示例和优点。

页面对象模型

页面对象模型是一种设计模式,其中每个网页或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

声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
/27 人阅读/0 条评论 发表评论

登录 后发表评论
最新文章