本文导读:Selenium是Web自动化测试的事实标准,但初学者常常被元素定位失败、脚本不稳定、维护成本高等问题困扰。本文将从实战角度出发,带你深入理解Selenium的核心技术,掌握八大定位策略、三种等待机制,并最终构建一个企业级的自动化测试框架。
一、Selenium生态全景:不仅仅是WebDriver
1.1 Selenium家族成员
Selenium生态
Selenium WebDriver
Selenium IDE
Selenium Grid
"核心组件
浏览器自动化API"
"支持语言
Java/Python/JS/C#/Ruby"
"浏览器插件
录制回放工具"
"适合场景
快速原型、教学演示"
"分布式测试
并行执行"
"跨浏览器/平台
远程执行"
1.2 Selenium的工作原理
python
"""
Selenium WebDriver工作原理:
1. 测试脚本通过WebDriver API发送命令
2. 浏览器驱动(ChromeDriver/GeckoDriver)接收命令
3. 驱动将命令转换为浏览器的原生指令
4. 浏览器执行操作并返回结果
5. 结果通过驱动传回测试脚本
"""
二、元素定位:自动化测试的基石
2.1 八大定位策略详解
Selenium提供了8种传统定位策略,每种都有其适用场景:
| 定位策略 | 方法 | 适用场景 | 优缺点 |
|---|---|---|---|
| ID | By.ID |
元素有唯一ID | ⭐⭐⭐⭐⭐ 最快、最稳定 |
| Name | By.NAME |
表单元素有name属性 | ⭐⭐⭐⭐ 次之,但可能不唯一 |
| Class Name | By.CLASS_NAME |
元素有特定class | ⭐⭐ 复合类名不支持 |
| Tag Name | By.TAG_NAME |
批量获取同类型元素 | ⭐ 不精确,易匹配多个 |
| Link Text | By.LINK_TEXT |
精确匹配链接文本 | ⭐⭐⭐ 适合a标签 |
| Partial Link Text | By.PARTIAL_LINK_TEXT |
模糊匹配链接文本 | ⭐⭐ 可能匹配多个 |
| CSS Selector | By.CSS_SELECTOR |
复杂定位需求 | ⭐⭐⭐⭐⭐ 灵活强大 |
| XPath | By.XPATH |
无法用其他方式定位 | ⭐⭐⭐ 强大但性能稍差 |
2.2 实战示例:百度搜索页定位
python
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
import time
class BaiduPageLocators:
"""百度首页元素定位器"""
# 1. ID定位 - 最推荐的方式
SEARCH_INPUT_ID = (By.ID, "kw")
# 2. Name定位 - 表单元素常用
SEARCH_INPUT_NAME = (By.NAME, "wd")
# 3. Class Name定位 - 注意:不支持复合类名
SEARCH_BUTTON_CLASS = (By.CLASS_NAME, "s_btn")
# 4. CSS Selector定位 - 灵活强大
SEARCH_INPUT_CSS = (By.CSS_SELECTOR, "#kw")
SEARCH_BUTTON_CSS = (By.CSS_SELECTOR, "input[type='submit']")
# 5. XPath定位 - 绝对路径和相对路径
SEARCH_INPUT_XPATH_ABSOLUTE = (By.XPATH, "/html/body/div[1]/div[3]/form/span[1]/input")
SEARCH_INPUT_XPATH_RELATIVE = (By.XPATH, "//input[@id='kw']")
# 6. 链接文本定位 - 精确匹配
NEWS_LINK = (By.LINK_TEXT, "新闻")
# 7. 部分链接文本定位 - 模糊匹配
MAP_LINK_PARTIAL = (By.PARTIAL_LINK_TEXT, "地图")
# 8. 标签名定位 - 获取所有input元素
ALL_INPUTS = (By.TAG_NAME, "input")
class BaiduSearchTest:
"""百度搜索测试类"""
def __init__(self):
self.driver = webdriver.Chrome()
self.driver.maximize_window()
self.wait = WebDriverWait(self.driver, 10)
def test_all_locators(self):
"""演示各种定位方法的使用"""
# 打开百度首页
self.driver.get("https://www.baidu.com")
time.sleep(2) # 等待页面加载
# 1. ID定位 - 输入搜索关键词
search_input = self.driver.find_element(*BaiduPageLocators.SEARCH_INPUT_ID)
search_input.clear()
search_input.send_keys("Selenium 定位实战")
print("✓ ID定位成功")
# 2. Class Name定位 - 点击搜索按钮
search_btn = self.driver.find_element(*BaiduPageLocators.SEARCH_BUTTON_CLASS)
search_btn.click()
print("✓ Class Name定位成功")
time.sleep(3)
self.driver.back() # 返回首页
# 3. CSS Selector定位 - 更灵活的定位方式
search_input_css = self.driver.find_element(*BaiduPageLocators.SEARCH_INPUT_CSS)
search_input_css.clear()
search_input_css.send_keys("CSS选择器测试")
print("✓ CSS Selector定位成功")
search_btn_css = self.driver.find_element(*BaiduPageLocators.SEARCH_BUTTON_CSS)
search_btn_css.click()
print("✓ CSS选择器按钮定位成功")
time.sleep(3)
self.driver.back()
# 4. XPath定位 - 处理复杂结构
search_input_xpath = self.driver.find_element(*BaiduPageLocators.SEARCH_INPUT_XPATH_RELATIVE)
search_input_xpath.clear()
search_input_xpath.send_keys("XPath测试")
print("✓ XPath定位成功")
# 5. 链接文本定位 - 点击新闻链接
news_link = self.driver.find_element(*BaiduPageLocators.NEWS_LINK)
print(f"✓ 链接文本定位成功: {news_link.text}")
# 6. 获取所有input元素
all_inputs = self.driver.find_elements(*BaiduPageLocators.ALL_INPUTS)
print(f"✓ 页面上共有 {len(all_inputs)} 个input元素")
def tearDown(self):
"""测试清理"""
time.sleep(3)
self.driver.quit()
# 运行测试
if __name__ == "__main__":
test = BaiduSearchTest()
test.test_all_locators()
test.tearDown()
2.3 定位策略选择指南
python
class LocatorStrategyGuide:
"""定位策略选择指南"""
@staticmethod
def recommend_strategy(element_info):
"""
根据元素特征推荐最佳定位策略
Args:
element_info: 元素特征字典
"""
recommendations = []
# 1. 如果有唯一ID,首选ID
if element_info.get('has_unique_id'):
recommendations.append({
'strategy': 'ID',
'priority': '最高',
'reason': 'ID在页面中唯一,定位最快最稳定',
'example': 'driver.find_element(By.ID, "username")'
})
# 2. 如果有唯一Name属性,次选Name
elif element_info.get('has_unique_name'):
recommendations.append({
'strategy': 'Name',
'priority': '高',
'reason': 'Name属性通常是唯一的,特别是表单元素',
'example': 'driver.find_element(By.NAME, "email")'
})
# 3. 如果是链接元素
elif element_info.get('is_link'):
if element_info.get('exact_text_match'):
recommendations.append({
'strategy': 'Link Text',
'priority': '中',
'reason': '精确匹配链接文本,直观易懂',
'example': 'driver.find_element(By.LINK_TEXT, "登录")'
})
else:
recommendations.append({
'strategy': 'Partial Link Text',
'priority': '中',
'reason': '适用于动态生成的部分链接文本',
'example': 'driver.find_element(By.PARTIAL_LINK_TEXT, "注册")'
})
# 4. 默认推荐CSS Selector
recommendations.append({
'strategy': 'CSS Selector',
'priority': '推荐通用方案',
'reason': 'CSS选择器灵活强大,性能好,可读性强',
'example': 'driver.find_element(By.CSS_SELECTOR, ".btn-primary[type=\'submit\']")'
})
# 5. XPath作为备选方案
recommendations.append({
'strategy': 'XPath',
'priority': '备选方案',
'reason': 'XPath功能最强大,但性能稍差,适合复杂DOM结构',
'example': 'driver.find_element(By.XPATH, "//div[@class=\'container\']//button[text()=\'提交\']")'
})
return recommendations
# 示例:为不同元素推荐定位策略
element_cases = [
{"name": "登录按钮", "has_unique_id": True, "is_link": False},
{"name": "邮箱输入框", "has_unique_name": True, "is_link": False},
{"name": "关于我们链接", "is_link": True, "exact_text_match": True},
{"name": "动态生成的菜单项", "is_link": True, "exact_text_match": False},
{"name": "复杂表格中的单元格", "has_unique_id": False, "is_link": False}
]
for case in element_cases:
print(f"\n=== 为 '{case['name']}' 推荐的定位策略 ===")
recs = LocatorStrategyGuide.recommend_strategy(case)
for r in recs[:2]: # 只显示前两个推荐
print(f"【{r['strategy']}】{r['priority']}: {r['reason']}")
print(f" 示例: {r['example']}")
2.4 高级定位技巧
python
class AdvancedLocatorTechniques:
"""高级定位技巧"""
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def locate_by_multiple_attributes(self):
"""组合多个属性定位"""
# CSS选择器组合属性
element = self.driver.find_element(By.CSS_SELECTOR,
"input[type='text'][name='username'][data-role='user-input']")
# XPath组合条件
element = self.driver.find_element(By.XPATH,
"//input[@type='text' and @name='username' and contains(@class, 'input-field')]")
return element
def locate_by_text_content(self):
"""通过文本内容定位"""
# 精确文本匹配
element = self.driver.find_element(By.XPATH,
"//button[text()='提交订单']")
# 包含文本匹配
element = self.driver.find_element(By.XPATH,
"//div[contains(text(), '验证码已发送')]")
# 忽略大小写匹配
element = self.driver.find_element(By.XPATH,
"//*[translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = 'success']")
return element
def locate_by_hierarchy(self):
"""通过层级关系定位"""
# 父节点 -> 子节点
element = self.driver.find_element(By.CSS_SELECTOR,
"div.container > ul > li.active > a")
# 兄弟节点
element = self.driver.find_element(By.XPATH,
"//label[@for='username']/following-sibling::input")
# 祖先节点
element = self.driver.find_element(By.XPATH,
"//input[@id='submit']/ancestor::form")
return element
def locate_by_position(self):
"""通过位置索引定位"""
# 第一个元素
first = self.driver.find_element(By.CSS_SELECTOR,
"ul.products li:first-child")
# 最后一个元素
last = self.driver.find_element(By.CSS_SELECTOR,
"ul.products li:last-child")
# 第n个元素(nth-child)
third = self.driver.find_element(By.CSS_SELECTOR,
"ul.products li:nth-child(3)")
# 奇数/偶数元素
odd_rows = self.driver.find_elements(By.CSS_SELECTOR,
"table tr:nth-child(odd)")
even_rows = self.driver.find_elements(By.CSS_SELECTOR,
"table tr:nth-child(even)")
return first, last, third
def locate_dynamic_elements(self):
"""定位动态元素"""
# 包含动态ID的部分匹配
element = self.driver.find_element(By.CSS_SELECTOR,
"div[id^='temp_']") # ID以temp_开头
element = self.driver.find_element(By.CSS_SELECTOR,
"div[id$='_container']") # ID以_container结尾
element = self.driver.find_element(By.CSS_SELECTOR,
"div[id*='_user_']") # ID包含_user_
return element
三、等待机制:解决不稳定测试的关键
3.1 为什么需要等待?
大多数Web应用程序使用Ajax和JavaScript动态加载内容。当浏览器加载页面时,元素可能在不同时间间隔加载,这导致了竞态条件 ------脚本执行速度和页面加载速度的竞赛。不恰当的等待是导致不稳定测试的主要来源。
3.2 三种等待策略对比
| 等待类型 | 实现方式 | 作用范围 | 优缺点 | 适用场景 |
|---|---|---|---|---|
| 强制等待 | time.sleep() |
当前线程 | ❌ 固定时间,效率低 | 几乎不推荐使用 |
| 隐式等待 | driver.implicitly_wait() |
全局,所有元素 | ⭐⭐ 简单但粗粒度 | 作为基础等待 |
| 显式等待 | WebDriverWait + ExpectedConditions |
特定元素 | ⭐⭐⭐⭐⭐ 精准控制 | 核心交互元素 |
3.3 强制等待:最原始的方式
python
import time
class ForceWaitDemo:
"""强制等待演示 - 不推荐使用"""
def bad_practice(self):
"""糟糕的实践"""
driver = webdriver.Chrome()
driver.get("https://example.com")
# 固定等待,不管页面是否加载完成
time.sleep(5) # 如果页面2秒加载完,浪费3秒
# 如果页面6秒加载完,5秒不够,测试失败
element = driver.find_element(By.ID, "dynamic-content")
element.click()
# 到处都是time.sleep
time.sleep(3)
another_element = driver.find_element(By.CLASS_NAME, "result")
driver.quit()
def better_approach(self):
"""更好的实践"""
driver = webdriver.Chrome()
driver.get("https://example.com")
# 使用显式等待替代固定等待
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
element = wait.until(
EC.element_to_be_clickable((By.ID, "dynamic-content"))
)
element.click()
# 等待下一个元素
result = wait.until(
EC.visibility_of_element_located((By.CLASS_NAME, "result"))
)
driver.quit()
3.4 隐式等待:全局设置
隐式等待告诉WebDriver在抛出异常之前等待一定时间。这是一个全局设置,适用于整个会话期间的每个元素定位调用。
python
class ImplicitWaitDemo:
"""隐式等待演示"""
def setup_implicit_wait(self):
"""设置隐式等待"""
driver = webdriver.Chrome()
# 设置隐式等待为10秒
driver.implicitly_wait(10)
# 一旦设置,在整个会话期间都有效
driver.get("https://example.com")
# 这个find_element会等待最多10秒,直到元素出现
element1 = driver.find_element(By.ID, "element1")
# 这个也会等待最多10秒
element2 = driver.find_element(By.CLASS_NAME, "element2")
return driver
def implicit_wait_mechanism(self):
"""
隐式等待的工作原理:
1. 立即检查元素是否存在
2. 如果存在,立即返回
3. 如果不存在,每隔500ms轮询一次
4. 在超时时间内,一旦元素出现就立即返回
5. 如果超时仍未出现,抛出NoSuchElementException
"""
driver = webdriver.Chrome()
driver.implicitly_wait(10)
import time
start = time.time()
try:
# 尝试定位一个不存在的元素
element = driver.find_element(By.ID, "non_existent_element")
except:
elapsed = time.time() - start
print(f"等待了 {elapsed:.2f} 秒后抛出异常")
# 输出:等待了 10.01 秒后抛出异常
driver.quit()
def implicit_wait_caveats(self):
"""隐式等待的注意事项"""
# 警告1:不要混合使用隐式等待和显式等待
driver = webdriver.Chrome()
driver.implicitly_wait(10) # 设置隐式等待10秒
wait = WebDriverWait(driver, 15) # 显式等待15秒
# 这可能会导致意想不到的等待时间(可能长达20-25秒)
element = wait.until(
EC.presence_of_element_located((By.ID, "some-element"))
)
# 警告2:隐式等待对查找元素有效,但对元素状态(如可点击)无效
# 需要等待元素可点击时,仍需显式等待
driver.quit()
3.5 显式等待:精准控制
显式等待是在代码中添加的循环,用于轮询应用程序,直到特定条件评估为真。这是处理复杂等待场景的最佳选择。
python
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.common.exceptions import TimeoutException, ElementNotInteractableException
class ExplicitWaitDemo:
"""显式等待演示"""
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def wait_for_presence(self):
"""等待元素存在(在DOM中存在)"""
element = self.wait.until(
EC.presence_of_element_located((By.ID, "dynamic-element"))
)
return element
def wait_for_visibility(self):
"""等待元素可见(存在且显示)"""
element = self.wait.until(
EC.visibility_of_element_located((By.CLASS_NAME, "loading-content"))
)
return element
def wait_for_clickable(self):
"""等待元素可点击(可见且启用)"""
button = self.wait.until(
EC.element_to_be_clickable((By.ID, "submit-button"))
)
return button
def wait_for_text_present(self):
"""等待元素包含特定文本"""
element = self.wait.until(
EC.text_to_be_present_in_element((By.ID, "status"), "成功")
)
return element
def wait_for_element_to_disappear(self):
"""等待元素消失(如加载动画)"""
success = self.wait.until(
EC.invisibility_of_element_located((By.CLASS_NAME, "loading-spinner"))
)
return success
def wait_for_alert(self):
"""等待弹窗出现"""
alert = self.wait.until(EC.alert_is_present())
return alert
def wait_for_frame_and_switch(self):
"""等待iframe可用并切换"""
frame = self.wait.until(
EC.frame_to_be_available_and_switch_to_it((By.ID, "frame-id"))
)
return frame
def wait_for_title_contains(self):
"""等待标题包含特定文本"""
success = self.wait.until(EC.title_contains("Dashboard"))
return success
def wait_for_url_contains(self):
"""等待URL包含特定文本"""
success = self.wait.until(EC.url_contains("success.html"))
return success
def wait_for_multiple_conditions(self):
"""等待多个条件组合"""
# 等待元素出现且可点击
element = self.wait.until(
lambda driver: (
EC.presence_of_element_located((By.ID, "my-element"))(driver) and
EC.element_to_be_clickable((By.ID, "my-element"))(driver)
)
)
return element
def custom_wait_condition(self):
"""自定义等待条件"""
def element_has_css_class(driver):
"""自定义条件:检查元素是否有特定CSS类"""
element = driver.find_element(By.ID, "my-element")
return "active" in element.get_attribute("class")
# 使用自定义条件
result = self.wait.until(element_has_css_class)
return result
def wait_with_custom_message(self):
"""带自定义错误信息的等待"""
try:
element = self.wait.until(
EC.presence_of_element_located((By.ID, "critical-element"))
)
return element
except TimeoutException:
# 自定义超时信息
self.driver.save_screenshot("timeout_error.png")
print("等待超时:关键元素未在10秒内加载")
raise
3.6 Fluent Wait:高度可定制的等待
Fluent Wait是显式等待的增强版,允许设置轮询频率、忽略特定异常等。
python
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import NoSuchElementException, ElementNotInteractableException
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
class FluentWaitDemo:
"""Fluent Wait等待演示"""
def setup_fluent_wait(self, driver):
"""配置Fluent Wait"""
# 创建Fluent Wait实例
wait = WebDriverWait(
driver,
timeout=10, # 总超时时间10秒
poll_frequency=0.5, # 每0.5秒轮询一次
ignored_exceptions=[
NoSuchElementException, # 忽略元素不存在异常
ElementNotInteractableException # 忽略元素不可交互异常
]
)
return wait
def wait_with_fluent(self, driver):
"""使用Fluent Wait等待"""
wait = self.setup_fluent_wait(driver)
# 等待元素出现并可交互
element = wait.until(
lambda d: d.find_element(By.ID, "dynamic-button")
)
# 等待元素变为可点击
element = wait.until(
EC.element_to_be_clickable((By.ID, "dynamic-button"))
)
return element
def advanced_fluent_example(self, driver):
"""高级Fluent Wait示例"""
# 配置Fluent Wait
wait = WebDriverWait(
driver,
timeout=20,
poll_frequency=0.2, # 每200ms轮询一次
ignored_exceptions=[
NoSuchElementException,
ElementNotInteractableException
]
)
# 复杂的等待条件
def complex_condition(driver):
"""复杂等待条件"""
try:
# 检查多个条件
element = driver.find_element(By.ID, "status")
if element.is_displayed() and element.is_enabled():
text = element.text
return "完成" in text or "成功" in text
except:
pass
return False
# 等待复杂条件满足
result = wait.until(complex_condition)
# 或者在等待时执行操作
result = wait.until(
lambda d: d.find_element(By.ID, "input-field").send_keys("test") or True
)
return result
3.7 等待策略最佳实践
python
class WaitStrategyGuide:
"""等待策略最佳实践指南"""
@staticmethod
def best_practices():
"""等待策略最佳实践"""
practices = {
"通用原则": [
" 优先使用显式等待,避免强制等待",
" 不要混合使用隐式等待和显式等待",
" 设置合理的超时时间(通常5-10秒)",
" 针对关键元素使用特定等待条件"
],
"页面加载等待": [
"使用: driver.get() 和 driver.navigate().to()",
"说明: Selenium默认等待页面加载完成(readyState=complete)",
"扩展: 可配置pageLoadStrategy为'eager'或'none'"
],
"元素存在等待": [
"使用: EC.presence_of_element_located()",
"适用: 元素已在DOM中,但可能未显示",
"示例: 动态添加到页面的元素"
],
"元素可见等待": [
"使用: EC.visibility_of_element_located()",
"适用: 需要与元素交互(点击、输入)",
"示例: 弹出框、加载完成后显示的内容"
],
"元素可点击等待": [
"使用: EC.element_to_be_clickable()",
"适用: 点击按钮、链接等",
"示例: 提交按钮、导航链接"
],
"元素消失等待": [
"使用: EC.invisibility_of_element_located()",
"适用: 等待加载动画消失",
"示例: loading spinner、进度条"
]
}
return practices
@staticmethod
def wait_decision_tree():
"""等待策略决策树"""
return """
┌─────────────────────┐
│ 开始等待决策 │
└──────────┬──────────┘
↓
┌─────────────────────┐
│ 是否知道具体元素 │
└──────────┬──────────┘
是↓ ↓否
┌──────────┐ ┌──────────┐
│显式等待 │ │隐式等待 │
│精准控制 │ │通用兜底 │
└────┬─────┘ └────┬─────┘
↓ ↓
┌─────────────────────┐
│ 等待什么条件? │
└──────────┬──────────┘
↓ ↓ ↓
┌───────┐ ┌───────┐ ┌───────┐
│存在性 │ │可见性 │ │可点击性│
│presence│ │visibility│ │clickable│
└───────┘ └───────┘ └───────┘
"""
@staticmethod
def wait_code_template():
"""等待代码模板"""
return '''
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
class PageObject:
"""页面对象基类"""
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10) # 默认10秒超时
def wait_for_element(self, locator, condition="visible", timeout=None):
"""
等待元素的核心方法
Args:
locator: (By.ID, "element-id") 格式的定位器
condition: 等待条件 (presence/visible/clickable)
timeout: 自定义超时时间
"""
wait = WebDriverWait(self.driver, timeout or 10)
conditions = {
"presence": EC.presence_of_element_located,
"visible": EC.visibility_of_element_located,
"clickable": EC.element_to_be_clickable
}
if condition in conditions:
return wait.until(conditions[condition](locator))
else:
raise ValueError(f"不支持的等待条件: {condition}")
def wait_for_text(self, locator, text, timeout=None):
"""等待元素包含特定文本"""
wait = WebDriverWait(self.driver, timeout or 10)
return wait.until(
EC.text_to_be_present_in_element(locator, text)
)
def wait_for_disappear(self, locator, timeout=None):
"""等待元素消失"""
wait = WebDriverWait(self.driver, timeout or 10)
return wait.until(
EC.invisibility_of_element_located(locator)
)
'''
四、框架封装:从脚本到工程
4.1 为什么要封装?
原始Selenium脚本存在以下问题:
- 代码重复:每个测试都要写相同的find_element、等待逻辑
- 维护困难:元素定位器散落在各个测试中
- 可读性差:测试逻辑被技术细节淹没
- 不稳定:缺乏统一的异常处理
4.2 基础层封装:BasePage
python
"""
base_page.py - 页面基类
对所有Selenium操作进行二次封装,提供统一的异常处理和日志记录
"""
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import TimeoutException, NoSuchElementException
import logging
import time
from pathlib import Path
class BasePage:
"""
页面基类,所有页面对象都应继承此类
提供统一的元素操作方法和等待机制,封装了Selenium的底层细节
"""
def __init__(self, driver):
"""
初始化页面基类
Args:
driver: WebDriver实例
"""
self.driver = driver
self.wait = WebDriverWait(driver, 10)
self.logger = logging.getLogger(self.__class__.__name__)
# ==================== 元素定位基础方法 ====================
def find_element(self, locator, timeout=10):
"""
查找单个元素,带显式等待
Args:
locator: (By.ID, "element-id") 格式的定位器
timeout: 超时时间
Returns:
WebElement: 找到的元素
"""
try:
wait = WebDriverWait(self.driver, timeout)
element = wait.until(EC.presence_of_element_located(locator))
self.logger.debug(f"元素定位成功: {locator}")
return element
except TimeoutException:
self.logger.error(f"元素定位超时: {locator}")
self.take_screenshot("element_timeout")
raise
def find_elements(self, locator, timeout=10):
"""
查找多个元素
Args:
locator: 定位器
timeout: 超时时间
"""
try:
wait = WebDriverWait(self.driver, timeout)
elements = wait.until(EC.presence_of_all_elements_located(locator))
self.logger.debug(f"找到 {len(elements)} 个元素: {locator}")
return elements
except TimeoutException:
self.logger.error(f"元素列表定位超时: {locator}")
return []
# ==================== 等待条件封装 ====================
def wait_element_visible(self, locator, timeout=10):
"""等待元素可见"""
try:
element = self.wait.until(EC.visibility_of_element_located(locator))
self.logger.debug(f"元素可见: {locator}")
return element
except TimeoutException:
self.logger.error(f"元素不可见: {locator}")
self.take_screenshot("element_not_visible")
raise
def wait_element_clickable(self, locator, timeout=10):
"""等待元素可点击"""
try:
element = self.wait.until(EC.element_to_be_clickable(locator))
self.logger.debug(f"元素可点击: {locator}")
return element
except TimeoutException:
self.logger.error(f"元素不可点击: {locator}")
self.take_screenshot("element_not_clickable")
raise
def wait_element_disappear(self, locator, timeout=10):
"""等待元素消失"""
try:
result = self.wait.until(EC.invisibility_of_element_located(locator))
self.logger.debug(f"元素消失: {locator}")
return result
except TimeoutException:
self.logger.error(f"元素未消失: {locator}")
return False
def wait_for_text(self, locator, text, timeout=10):
"""等待元素包含特定文本"""
try:
result = self.wait.until(
EC.text_to_be_present_in_element(locator, text)
)
self.logger.debug(f"元素包含文本 '{text}': {locator}")
return result
except TimeoutException:
self.logger.error(f"元素未包含文本 '{text}': {locator}")
return False
# ==================== 基础操作封装 ====================
def click(self, locator, timeout=10):
"""
点击元素
Args:
locator: 元素定位器
timeout: 等待时间
"""
element = self.wait_element_clickable(locator, timeout)
try:
element.click()
self.logger.info(f"点击元素: {locator}")
except Exception as e:
self.logger.error(f"点击元素失败: {locator}, 错误: {str(e)}")
self.take_screenshot("click_error")
raise
def input_text(self, locator, text, timeout=10):
"""
输入文本
Args:
locator: 元素定位器
text: 要输入的文本
timeout: 等待时间
"""
element = self.wait_element_visible(locator, timeout)
try:
element.clear()
element.send_keys(text)
self.logger.info(f"输入文本 '{text}' 到元素: {locator}")
except Exception as e:
self.logger.error(f"输入文本失败: {locator}, 错误: {str(e)}")
self.take_screenshot("input_error")
raise
def get_text(self, locator, timeout=10):
"""获取元素文本"""
element = self.wait_element_visible(locator, timeout)
text = element.text
self.logger.debug(f"获取元素文本 '{text}': {locator}")
return text
def get_attribute(self, locator, attribute, timeout=10):
"""获取元素属性"""
element = self.find_element(locator, timeout)
value = element.get_attribute(attribute)
self.logger.debug(f"获取元素属性 {attribute}='{value}': {locator}")
return value
# ==================== 高级操作封装 ====================
def select_by_text(self, locator, text, timeout=10):
"""根据文本选择下拉选项"""
element = self.find_element(locator, timeout)
select = Select(element)
select.select_by_visible_text(text)
self.logger.info(f"选择下拉选项 '{text}': {locator}")
def select_by_value(self, locator, value, timeout=10):
"""根据值选择下拉选项"""
element = self.find_element(locator, timeout)
select = Select(element)
select.select_by_value(value)
self.logger.info(f"选择下拉值 '{value}': {locator}")
def select_by_index(self, locator, index, timeout=10):
"""根据索引选择下拉选项"""
element = self.find_element(locator, timeout)
select = Select(element)
select.select_by_index(index)
self.logger.info(f"选择下拉索引 '{index}': {locator}")
def hover(self, locator, timeout=10):
"""鼠标悬停"""
element = self.find_element(locator, timeout)
actions = ActionChains(self.driver)
actions.move_to_element(element).perform()
self.logger.info(f"鼠标悬停: {locator}")
def drag_and_drop(self, source_locator, target_locator, timeout=10):
"""拖拽元素"""
source = self.find_element(source_locator, timeout)
target = self.find_element(target_locator, timeout)
actions = ActionChains(self.driver)
actions.drag_and_drop(source, target).perform()
self.logger.info(f"拖拽元素: {source_locator} -> {target_locator}")
def press_enter(self, locator, timeout=10):
"""在元素上按回车"""
element = self.find_element(locator, timeout)
element.send_keys(Keys.RETURN)
self.logger.info(f"按回车键: {locator}")
def scroll_to_element(self, locator, timeout=10):
"""滚动到元素位置"""
element = self.find_element(locator, timeout)
self.driver.execute_script("arguments[0].scrollIntoView(true);", element)
self.logger.info(f"滚动到元素: {locator}")
def scroll_to_bottom(self):
"""滚动到页面底部"""
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
self.logger.info("滚动到页面底部")
# ==================== 弹窗处理 ====================
def accept_alert(self, timeout=10):
"""接受弹窗"""
try:
alert = self.wait.until(EC.alert_is_present())
alert.accept()
self.logger.info("接受弹窗")
except TimeoutException:
self.logger.warning("弹窗未出现")
def dismiss_alert(self, timeout=10):
"""取消弹窗"""
try:
alert = self.wait.until(EC.alert_is_present())
alert.dismiss()
self.logger.info("取消弹窗")
except TimeoutException:
self.logger.warning("弹窗未出现")
def get_alert_text(self, timeout=10):
"""获取弹窗文本"""
try:
alert = self.wait.until(EC.alert_is_present())
text = alert.text
self.logger.info(f"弹窗文本: {text}")
return text
except TimeoutException:
self.logger.warning("弹窗未出现")
return None
# ==================== iframe处理 ====================
def switch_to_frame(self, locator, timeout=10):
"""切换到iframe"""
try:
frame = self.wait.until(EC.frame_to_be_available_and_switch_to_it(locator))
self.logger.info(f"切换到iframe: {locator}")
return frame
except TimeoutException:
self.logger.error(f"iframe不可用: {locator}")
raise
def switch_to_default_content(self):
"""切回主文档"""
self.driver.switch_to.default_content()
self.logger.info("切回主文档")
# ==================== 辅助方法 ====================
def take_screenshot(self, name=None):
"""截图"""
if name is None:
name = f"screenshot_{int(time.time())}"
screenshot_dir = Path("screenshots")
screenshot_dir.mkdir(exist_ok=True)
filepath = screenshot_dir / f"{name}.png"
self.driver.save_screenshot(str(filepath))
self.logger.info(f"截图已保存: {filepath}")
return str(filepath)
def execute_script(self, script, *args):
"""执行JavaScript"""
result = self.driver.execute_script(script, *args)
self.logger.debug(f"执行JS: {script[:50]}...")
return result
def is_element_present(self, locator, timeout=5):
"""判断元素是否存在"""
try:
self.find_element(locator, timeout)
return True
except:
return False
def is_element_visible(self, locator, timeout=5):
"""判断元素是否可见"""
try:
self.wait_element_visible(locator, timeout)
return True
except:
return False
4.3 页面对象层:LoginPage
python
"""
login_page.py - 登录页面对象
继承BasePage,实现具体的页面操作
"""
from selenium.webdriver.common.by import By
from base_page import BasePage
class LoginPage(BasePage):
"""
登录页面对象模型
封装登录页面的所有元素和操作,测试用例通过调用这些方法与页面交互
"""
# ==================== 元素定位器 ====================
# 集中管理所有定位器,便于维护
USERNAME_INPUT = (By.ID, "username")
PASSWORD_INPUT = (By.ID, "password")
LOGIN_BUTTON = (By.ID, "loginBtn")
ERROR_MESSAGE = (By.CLASS_NAME, "error-message")
REMEMBER_ME_CHECKBOX = (By.ID, "rememberMe")
FORGOT_PASSWORD_LINK = (By.LINK_TEXT, "Forgot Password?")
REGISTER_LINK = (By.PARTIAL_LINK_TEXT, "Register")
CAPTCHA_IMAGE = (By.CSS_SELECTOR, "img.captcha")
def __init__(self, driver):
"""初始化登录页面"""
super().__init__(driver)
self.url = "https://example.com/login"
# ==================== 页面操作 ====================
def open(self):
"""打开登录页面"""
self.driver.get(self.url)
self.logger.info(f"打开登录页面: {self.url}")
# 等待页面加载完成
self.wait_element_visible(self.USERNAME_INPUT)
return self
def login(self, username, password, remember_me=False):
"""
执行登录操作
Args:
username: 用户名
password: 密码
remember_me: 是否记住我
"""
self.logger.info(f"尝试登录: username={username}, remember_me={remember_me}")
# 输入用户名
self.input_text(self.USERNAME_INPUT, username)
# 输入密码
self.input_text(self.PASSWORD_INPUT, password)
# 是否勾选记住我
if remember_me:
self.click(self.REMEMBER_ME_CHECKBOX)
# 点击登录按钮
self.click(self.LOGIN_BUTTON)
# 返回Dashboard页面对象
from dashboard_page import DashboardPage
return DashboardPage(self.driver)
def login_with_enter_key(self, username, password):
"""使用回车键登录"""
self.input_text(self.USERNAME_INPUT, username)
self.input_text(self.PASSWORD_INPUT, password)
self.press_enter(self.PASSWORD_INPUT)
from dashboard_page import DashboardPage
return DashboardPage(self.driver)
def get_error_message(self):
"""获取登录错误信息"""
if self.is_element_visible(self.ERROR_MESSAGE):
return self.get_text(self.ERROR_MESSAGE)
return None
def is_login_button_enabled(self):
"""检查登录按钮是否可用"""
element = self.find_element(self.LOGIN_BUTTON)
return element.is_enabled()
def click_forgot_password(self):
"""点击忘记密码链接"""
self.click(self.FORGOT_PASSWORD_LINK)
# 可能返回忘记密码页面对象
def click_register(self):
"""点击注册链接"""
self.click(self.REGISTER_LINK)
# 可能返回注册页面对象
# ==================== 验证方法 ====================
def is_at(self):
"""验证是否在登录页面"""
return self.is_element_visible(self.USERNAME_INPUT)
def get_page_title(self):
"""获取页面标题"""
return self.driver.title
def get_login_button_text(self):
"""获取登录按钮文本"""
return self.get_attribute(self.LOGIN_BUTTON, "value")
4.4 DashboardPage
python
"""
dashboard_page.py - 仪表盘页面对象
"""
from selenium.webdriver.common.by import By
from base_page import BasePage
class DashboardPage(BasePage):
"""仪表盘页面对象"""
# 元素定位器
WELCOME_MESSAGE = (By.CSS_SELECTOR, ".welcome-message")
USER_AVATAR = (By.CLASS_NAME, "avatar")
LOGOUT_BUTTON = (By.ID, "logoutBtn")
NAVIGATION_MENU = (By.CSS_SELECTOR, "nav.main-menu")
STATS_CARDS = (By.CLASS_NAME, "stats-card")
def __init__(self, driver):
super().__init__(driver)
# 等待页面加载完成
self.wait_element_visible(self.WELCOME_MESSAGE)
def get_welcome_message(self):
"""获取欢迎信息"""
return self.get_text(self.WELCOME_MESSAGE)
def logout(self):
"""登出系统"""
self.click(self.LOGOUT_BUTTON)
from login_page import LoginPage
return LoginPage(self.driver)
def get_stats_count(self):
"""获取统计卡片数量"""
cards = self.find_elements(self.STATS_CARDS)
return len(cards)
def is_user_logged_in(self):
"""验证用户是否已登录"""
return self.is_element_visible(self.USER_AVATAR)
4.5 测试用例层
python
"""
test_login.py - 登录功能测试用例
使用Page Object编写清晰、可维护的测试
"""
import pytest
from login_page import LoginPage
from dashboard_page import DashboardPage
class TestLogin:
"""登录功能测试类"""
@pytest.fixture(autouse=True)
def setup(self, driver):
"""测试前置条件"""
self.driver = driver
self.login_page = LoginPage(driver).open()
yield
# 测试后清理
self.driver.delete_all_cookies()
def test_valid_login(self):
"""测试有效登录"""
# 执行登录操作
dashboard = self.login_page.login("admin", "admin123")
# 验证登录成功
assert dashboard.is_user_logged_in()
assert "Welcome" in dashboard.get_welcome_message()
def test_invalid_password_login(self):
"""测试无效密码登录"""
self.login_page.login("admin", "wrong_password")
# 验证错误提示
error = self.login_page.get_error_message()
assert "Invalid username or password" in error
assert self.login_page.is_at() # 仍在登录页面
def test_empty_username_login(self):
"""测试空用户名登录"""
self.login_page.input_text(self.login_page.PASSWORD_INPUT, "admin123")
# 验证登录按钮状态
assert not self.login_page.is_login_button_enabled()
def test_remember_me_functionality(self):
"""测试记住我功能"""
self.login_page.login("admin", "admin123", remember_me=True)
# 登出
dashboard = DashboardPage(self.driver)
dashboard.logout()
# 重新打开登录页,验证用户名是否记住
self.login_page.open()
remembered_username = self.login_page.get_attribute(
self.login_page.USERNAME_INPUT, "value"
)
assert remembered_username == "admin"
@pytest.mark.parametrize("username,password,expected_error", [
("", "admin123", "Username is required"),
("admin", "", "Password is required"),
("", "", "Username and password are required"),
])
def test_login_validation(self, username, password, expected_error):
"""参数化测试表单验证"""
if username:
self.login_page.input_text(self.login_page.USERNAME_INPUT, username)
if password:
self.login_page.input_text(self.login_page.PASSWORD_INPUT, password)
# 验证错误提示
if not username or not password:
assert not self.login_page.is_login_button_enabled()
else:
self.login_page.click(self.login_page.LOGIN_BUTTON)
error = self.login_page.get_error_message()
assert expected_error in error
4.6 配置与工具类
python
"""
config.py - 配置文件
"""
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
class Config:
"""全局配置"""
# 测试环境
ENV = os.getenv("TEST_ENV", "staging")
# URL配置
BASE_URL = os.getenv("BASE_URL", "https://example.com")
# 浏览器配置
BROWSER = os.getenv("BROWSER", "chrome")
HEADLESS = os.getenv("HEADLESS", "false").lower() == "true"
# 超时配置
DEFAULT_TIMEOUT = int(os.getenv("DEFAULT_TIMEOUT", "10"))
PAGE_LOAD_TIMEOUT = int(os.getenv("PAGE_LOAD_TIMEOUT", "30"))
# 截图配置
SCREENSHOT_ON_FAILURE = os.getenv("SCREENSHOT_ON_FAILURE", "true").lower() == "true"
SCREENSHOT_DIR = "reports/screenshots"
python
"""
driver_factory.py - WebDriver工厂
"""
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from webdriver_manager.firefox import GeckoDriverManager
from config import Config
import logging
class DriverFactory:
"""WebDriver工厂类,负责创建和配置浏览器驱动"""
@staticmethod
def create_driver(browser=None):
"""
创建WebDriver实例
Args:
browser: 浏览器类型,默认使用配置
"""
browser = browser or Config.BROWSER
logger = logging.getLogger(__name__)
if browser.lower() == "chrome":
options = webdriver.ChromeOptions()
if Config.HEADLESS:
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-blink-features=AutomationControlled")
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=options)
logger.info("Chrome驱动初始化成功")
elif browser.lower() == "firefox":
options = webdriver.FirefoxOptions()
if Config.HEADLESS:
options.add_argument("--headless")
service = Service(GeckoDriverManager().install())
driver = webdriver.Firefox(service=service, options=options)
logger.info("Firefox驱动初始化成功")
else:
raise ValueError(f"不支持的浏览器: {browser}")
# 设置超时
driver.set_page_load_timeout(Config.PAGE_LOAD_TIMEOUT)
driver.implicitly_wait(Config.DEFAULT_TIMEOUT)
driver.maximize_window()
return driver
python
"""
conftest.py - pytest配置
"""
import pytest
from driver_factory import DriverFactory
from config import Config
import logging
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
@pytest.fixture(scope="function")
def driver():
"""浏览器驱动fixture,每个测试函数独立使用"""
driver = DriverFactory.create_driver()
yield driver
driver.quit()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""测试失败时自动截图"""
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed and Config.SCREENSHOT_ON_FAILURE:
if "driver" in item.funcargs:
driver = item.funcargs["driver"]
test_name = item.name
screenshot_path = f"{Config.SCREENSHOT_DIR}/{test_name}.png"
driver.save_screenshot(screenshot_path)
print(f"\n测试失败,截图已保存: {screenshot_path}")
五、实战演练:完整的测试流程
5.1 测试场景:电商网站登录-搜索-下单流程
python
"""
test_e2e_purchase.py - 端到端购买流程测试
"""
import pytest
from login_page import LoginPage
from search_page import SearchPage
from product_page import ProductPage
from cart_page import CartPage
from checkout_page import CheckoutPage
class TestPurchaseFlow:
"""完整购买流程测试"""
def test_complete_purchase_flow(self, driver):
"""测试完整购买流程"""
# 1. 登录
login_page = LoginPage(driver).open()
dashboard = login_page.login("test_user", "Test@123")
assert dashboard.is_user_logged_in()
# 2. 搜索商品
search_page = SearchPage(driver)
search_page.search("iPhone 15")
results = search_page.get_search_results()
assert len(results) > 0
# 3. 选择商品
product_page = search_page.click_first_result()
product_name = product_page.get_product_name()
product_price = product_page.get_price()
# 4. 加入购物车
product_page.add_to_cart()
assert product_page.get_cart_count() == "1"
# 5. 查看购物车
cart_page = product_page.go_to_cart()
assert product_name in cart_page.get_cart_items()
assert cart_page.get_total_price() == product_price
# 6. 结算
checkout_page = cart_page.proceed_to_checkout()
checkout_page.fill_shipping_info({
"name": "Test User",
"address": "123 Test St",
"phone": "1234567890"
})
# 7. 确认订单
order_page = checkout_page.place_order()
assert "Order Confirmed" in order_page.get_confirmation_message()
assert order_page.get_order_number() is not None
六、最佳实践与常见问题
6.1 元素定位最佳实践
| 实践 | 说明 |
|---|---|
| 优先使用ID | ID在页面中唯一,定位最快最稳定 |
| CSS选择器优于XPath | CSS选择器性能更好,可读性更强 |
| 避免绝对路径 | 绝对XPath脆弱,页面结构变化就会失效 |
| 使用相对路径 | 基于元素特征定位,更稳定 |
| 定位器集中管理 | 将定位器定义为类常量,便于维护 |
| 优先使用可读性好的定位器 | 让团队成员能理解定位的是什么元素 |
6.2 等待策略最佳实践
| 场景 | 推荐方案 |
|---|---|
| 页面加载 | 配置pageLoadStrategy,或等待特定元素出现 |
| 元素出现 | EC.presence_of_element_located |
| 元素可点击 | EC.element_to_be_clickable |
| 元素可见 | EC.visibility_of_element_located |
| 元素消失 | EC.invisibility_of_element_located |
| 弹窗出现 | EC.alert_is_present |
| iframe可用 | EC.frame_to_be_available_and_switch_to_it |
| 标题变化 | EC.title_contains |
6.3 框架设计最佳实践
| 实践 | 说明 |
|---|---|
| 使用Page Object模式 | 封装页面细节,测试用例更清晰 |
| 继承BasePage | 公共方法放在基类,避免重复代码 |
| 集中管理定位器 | 所有定位器定义为类常量,便于维护 |
| 数据驱动测试 | 测试数据与脚本分离 |
| 参数化测试 | 使用pytest.mark.parametrize |
| 自动截图 | 失败时自动截图,便于调试 |
| 日志记录 | 记录关键操作,便于排查问题 |
6.4 常见问题与解决方案
问题1:元素定位失败
python
# 原因:页面未加载完成、元素在iframe中、元素动态变化
# 解决方案:
# 1. 使用显式等待
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "dynamic-element"))
)
# 2. 检查是否在iframe中
driver.switch_to.frame("frame-name")
# 3. 使用更灵活的定位器(如包含动态部分)
element = driver.find_element(By.CSS_SELECTOR, "div[id^='temp_']")
问题2:元素不可交互
python
# 原因:元素被遮挡、不可见、禁用
# 解决方案:
# 1. 等待元素可点击
element = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "button"))
)
# 2. 使用JavaScript强制点击
driver.execute_script("arguments[0].click();", element)
# 3. 先滚动到元素位置
driver.execute_script("arguments[0].scrollIntoView(true);", element)
问题3:测试不稳定
python
# 原因:网络延迟、服务器响应慢、资源加载
# 解决方案:
# 1. 使用合理的等待策略,避免固定等待
# 2. 添加失败重试机制
@pytest.mark.flaky(reruns=3, reruns_delay=2)
def test_flaky_scenario():
# 测试代码
pass
# 3. 隔离测试,避免依赖
# 4. 使用稳定环境(如测试专用服务器)
结语:从脚本到工程,从自动化到智能化
Selenium是Web自动化测试的基石,但掌握Selenium本身不是终点。真正的价值在于:
- 解决实际问题:提高回归测试效率,保障产品质量
- 工程化思维:从零散脚本到可维护框架
- 持续优化:根据反馈不断改进测试策略
- 拥抱新技术:关注AI测试、视觉测试等新方向
下一篇文章预告 :接口测试全攻略:Postman+Newman实现API自动化
我们将深入接口测试的世界,学习如何使用Postman设计API测试,通过Newman集成到CI/CD流水线,实现从UI到接口的全方位自动化覆盖。
附录:完整项目结构
selenium-framework/
├── src/
│ ├── pages/ # 页面对象
│ │ ├── base_page.py
│ │ ├── login_page.py
│ │ ├── dashboard_page.py
│ │ └── ...
│ ├── tests/ # 测试用例
│ │ ├── test_login.py
│ │ ├── test_search.py
│ │ └── ...
│ ├── utils/ # 工具类
│ │ ├── driver_factory.py
│ │ ├── logger.py
│ │ └── ...
│ └── config/ # 配置文件
│ ├── config.py
│ └── .env
├── reports/ # 测试报告
│ ├── screenshots/
│ └── html/
├── test_data/ # 测试数据
│ ├── login_data.json
│ └── ...
├── logs/ # 运行日志
├── conftest.py # pytest配置
├── pytest.ini # pytest配置
├── requirements.txt # 依赖
└── README.md # 项目说明
欢迎在评论区交流:
- 你在Selenium使用中遇到过哪些棘手的问题?
- 对于Page Object模式封装有什么心得?
- 期待在接口测试文章中看到哪些内容?
点赞 + 收藏 + 关注,不错过后续14篇干货更新!