PlayWright 框架(二)

🍰 Playwright用于跨浏览器测试,支持Chrome、Firefox等,提供一个简单而强大的API模拟用户在浏览器中的交互操作。

1 方法汇总

  • 方法汇总
    • Playwright中的测试层级:Browser > Context > Page > Locator > ElementHandle。
    • Page对象:直接在整个页面范围内进行元素查找和判断。
      • page.is_hidden(selector: str)·····判断指定选择器对应的元素是否隐藏
      • page.is_visible(selector: str)····判断指定选择器对应的元素是否可见
      • page.is_enabled(selector: str)····判断指定选择器对应的元素是否可用
      • page.is_editable(selector: str)···判断指定选择器对应的元素是否可编辑
      • page.is_disabled(selector: str)···判断指定选择器对应的元素是否不可用
      • page.is_checked(selector: str)····判断指定选择器对应的单选Radio或复选Checkbox,元素是否被选中
    • Locator对象:通过Page页面对象的定位方法,例如使用page.locator(selector)来获取ElementHandle元素句柄。
      • locator.is_hidden()···············判断该定位器对象对应的元素是否隐藏
      • locator.is_visible()··············判断该定位器对象对应的元素是否可见
      • locator.is_enabled()··············判断该定位器对象对应的元素是否可用
      • locator.is_editable()·············判断该定位器对象对应的元素是否可编辑
      • locator.is_disabled()·············判断该定位器对象对应的元素是否不可用
      • locator.is_checked()··············判断该定位器对象对应的单选Radio或复选Checkbox,元素是否被选中
    • ElementHandle对象:通过使用page.query_selector()方法,来调用返回的ElementHandle元素句柄,一般不常用。
      • element_handle.is_hidden()········判断该元素句柄对象对应的元素是否隐藏
      • element_handle.is_visible()·······判断该元素句柄对象对应的元素是否可见
      • element_handle.is_enabled()·······判断该元素句柄对象对应的元素是否可用
      • element_handle.is_editable()······判断该元素句柄对象对应的元素是否可编辑
      • element_handle.is_disabled()······判断该元素句柄对象对应的元素是否不可用
      • element_handle.is_checked()·······判断该元素句柄对象对应的单选Radio或复选Checkbox,元素是否被选中
    • Expect常用的断言方法
      • expect(api_response).to_be_ok()·········断言API响应是否成功
      • Page页面断言
        • expect(page).to_have_url()··········断言页面的URL是否符合预期
        • expect(page).not_to_have_url()······断言页面的URL是否不存在
        • expect(page).to_have_title()········断言页面的标题是否符合预期
        • expect(page).not_to_have_title()····断言页面的标题是否不存在
      • expect(locator).to_be_empty()···········断言locator对应的元素是否为空
      • expect(locator).to_be_hidden()··········断言locator对应的元素是否隐藏
      • expect(locator).to_be_visible()·········断言locator对应的元素是否可见
      • expect(locator).to_be_enabled()·········断言locator对应的元素是否可用
      • expect(locator).to_be_editable()········断言locator对应的元素是否可编辑
      • expect(locator).to_be_disabled()········断言locator对应的元素是否不可用
      • expect(locator).to_be_focused()·········断言locator对应的元素是否处于焦点状态
      • expect(locator).to_be_checked()·········断言locator对应的复选框或单选框元素是否被选中
      • expect(locator).to_have_id()············断言locator对应的元素是否具有特定的id
      • expect(locator).to_have_count()·········断言locator对应的元素数量是否符合预期
      • expect(locator).to_have_value()·········断言locator对应的元素的值是否符合预期
      • expect(locator).to_have_text()··········断言locator对应的元素是否具有特定的文本
      • expect(locator).to_have_class()·········断言locator对应的元素是否具有特定的类名
      • expect(locator).to_have_css()···········断言locator对应的元素是否具有特定的CSS样式
      • expect(locator).to_have_values()········断言locator对应的元素的值集合是否符合预期
      • expect(locator).to_have_attribute()·····断言locator对应的元素是否具有特定的属性
      • expect(locator).to_have_js_property()···断言locator对应的元素是否具有特定的JavaScript属性
      • expect(locator).to_contain_text()·······断言locator对应的元素是否包含特定的文本

1-1 页面导航*

  • 页面导航
    • 异步导航(Async navigation)
      • 指等待页面完成导航的操作,页面完成导航后再执行后续的操作。
      • 可使用page.goto()page.wait_for_navigation()方法来等待页面导航完成。
      • 异步导航通常用于单一的页面导航,等待页面跳转,或加载完毕后再进行后续操作。
      • 触发的导航:指代导航到新的URL、在同一网页中刷新、后退或前进,这四种情况。
    • 多重导航(Multiple navigations)
      • 多重导航指连续进行多次页面导航的操作,每次导航都需要等待页面完成跳转或加载。
      • 可使用page.goto()page.expect_navigation()方法来明确等待特定的导航完成。
      • 通常用于需进行连续多次页面跳转的情况,如应用程序中执行多步骤操作,或测试期间模拟用户浏览。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import asyncio
from playwright.async_api import async_playwright


async def example():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()

async def on_navigation(page, **kwargs): # 监听页面导航事件
print("页面导航完成")
page.on("navigation", on_navigation) # 添加导航事件监听器
await page.goto("https://www.baidu.com/") # 导航到指定URL
try:
async with page.expect_navigation(): # 等待页面导航完成
await page.click("//*[@id='s-top-left']/a[1]")
await page.reload() # 为啥不加这行会报错???
except TimeoutError:
print("页面导航超时")
print("页面导航完成,新页面加载成功")

await browser.close() # 关闭浏览器

asyncio.run(example()) # 运行代码

1-2 高级模式

  • 高级模式
    • page.wait_for_function():用于等待指定的JavaScript函数返回true或非空结果。
    • 可用于检查页面上的某些条件是否满足,例如:等待特定元素出现或特定文本被更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import asyncio
from playwright.async_api import async_playwright


async def wait_for_example():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()

await page.goto("https://www.baidu.com/") # 打开页面
await page.wait_for_function( # 等待页面上的标题元素出现
"() => document.querySelector('title') !== null"
)
title_text = await page.evaluate( # 检查标题文本是否符合预期
"document.querySelector('title').textContent"
)
if title_text == "百度一下,你就知道":
print("标题文本符合预期")
else:
print("标题文本不符合预期")

await browser.close() # 关闭浏览器对象

asyncio.run(wait_for_example())

1-3 svg元素操作

  • svg元素操作
    • SVG即Scalable Vector Graphics,由W3C制定,用于描述二维矢量图形的XML(可扩展标记语言)文件格式。
    • 无法用普通的标签定位到,如$x('//svg'),只能通过name()函数定位,如$x("//*[name()='svg']")
    • 如果页面上有多个svg元素,//*[name()="svg"]将定位出全部的svg元素。
      • 可以通过父元素来进行区分,例如://*[@id="box1"]//*[name()="svg"]
      • 可使用and组合其他属性,例如://*[name()="svg" and @width="500"]
    • 定位svg上的子元素,仍然可以通过name()函数定位,如$x("//*[name()='svg']/*[name()='path']")
    • svg元素下的circle是可拖动的,例如:往右拖动100个像素,那么cx值就由原先的cx="100"变为cx=200
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://www.w3school.com.cn/svg/circle1.svg")

circle = page.locator(
"//*[name()='svg']/*[name()='circle']"
) # svg元素定位
print(circle.bounding_box())
box = circle.bounding_box()

circle.evaluate( # 添加事件监听
"node => node.addEventListener('mousedown', function()"
"{console.log('目标元素被鼠标down了');});"
)

page.mouse.move( # svg元素拖拽
x=box["x"] + box["width"] / 2,
y=box["y"] + box["height"] / 2
)
page.mouse.down()
page.mouse.move(
x=box["x"] + box["width"] / 2 + 100,
y=box["y"] + box["height"] / 2
)
page.mouse.up(button="middle")
page.pause() # F12的console查看

browser.close() # 关闭浏览器对象

1-4 日期控件输入

  • 日期控件输入
    • 输入框是日期控件时,先看能否直接输入日期,可以直接输入的情况下就不用点开了。
    • 若有readonly属性就不能直接输入,这种情况下可以使用JavaScript去掉属性再输入。

(1) 直接输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(
r"http://wyeditor.com/layui_demo/%E6%97%A5%E6%9C%9F"
r"%E5%92%8C%E6%97%B6%E9%97%B4%E7%BB%84%E4%BB%B6.html"
)
page.locator(
"//*[@id='LayEdit_267858']"
).fill("2023-09-01") # 日期控件直接输入
page.pause()

browser.close() # 关闭浏览器对象

(2) readonly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(r"http://www.jemui.com/jedate/#5")
page.locator("//*[@id='ymd01']").hover() # 定位悬停到具体位置
page.wait_for_timeout(1000)
js1 = "document.getElementById('ymd01').removeAttribute('readonly');"
page.evaluate(js1) # 去掉readonly属性
js2 = "document.getElementById('ymd01').value='2023-09-01 15:15:15';"
page.evaluate(js2)
page.pause()

browser.close() # 关闭浏览器对象

1-5 Trace Viewer

  • Trace Viewer
    • 执行自动化用例的过程中,出现一些不稳定偶然性的Bug,需复现还原Bug出现的过程。
    • Playwright Trace Viewer可探索记录Playwright的测试跟踪,直观查看操作期间的情况。
    • 查看
      • 使用Playwright CLI在terminal中输入playwright show-trace trace.zip,打开跟踪。
      • 通过单击每个操作,或使用时间轴悬停来查看测试的痕迹,并查看操作前后页面的状态。
      • 在每个步骤中检查日志、源和网络,跟踪查看器创建一个DOM快照,可与其完全交互,打开devtools等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()

context.tracing.start( # 记录跟踪
screenshots=True, snapshots=True, sources=True
)
page = context.new_page()
page.goto("https://www.baidu.com/")
page.locator("//*[@id='s-hotsearch-wrapper']/div/a[1]/div").click()
context.tracing.stop(path="./file/trace.zip") # 保存为.zip文件

browser.close() # 关闭浏览器对象

1-6 table表格标签

  • table表格标签
    • table表格的常用标签:table(一个表格)、tr(一行)、th(表头单元格)、td(内容单元格)。
    • 使用xpath定位table表格数据,例如/html/body/div[1]/table[1]/tbody/tr[1]/td[2]

(1) 表格定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("http://www.bootstrapmb.com/item/10229")
with context.expect_page() as new_page_info:
page.get_by_text("预 览").click()
page.wait_for_timeout(1000)
new_page = new_page_info.value # 切换到预览table页
print(new_page.title())

new_page.wait_for_load_state("networkidle") # 等待页面加载完毕
iframe = new_page.frame_locator("//*[@id='iframe']")
n1 = iframe.locator("//table[2]/tbody/tr")
# n1 = iframe.locator("/html/body/div[1]/table[2]/tbody/tr")
# 报错:playwright._impl._api_types.Error: Unexpected token "/" while parsing selector
print(n1.count()) # 获取内容单元格总行数
n2 = iframe.locator("//*[@id='one-part-th']")
print(n2.inner_text()) # 获取表头单元格的第二行数据
n3 = iframe.locator("//table[2]/tbody/tr/td[4]") # 不同tr提取同一td[4]
for td in n3.all():
print(td.inner_text()) # 获取内容单元格第4列的数据

browser.close() # 关闭浏览器对象

(2) 数据获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("http://www.bootstrapmb.com/item/14227")
with context.expect_page() as new_page_info:
page.get_by_text("预 览").click()
page.wait_for_timeout(1000)
new_page = new_page_info.value # 切换到预览table页
print(new_page.title())

new_page.wait_for_load_state("networkidle") # 等待页面加载完毕
iframe = new_page.frame_locator("//*[@id='iframe']")

col_name = iframe.locator("//*[@id='table-0']/div/div[1]")
col_text = [item.inner_text() for item in col_name.all()]
print(col_text) # 列表形式单列输出

tit_name = iframe.locator("//*[@id='table-0']/div[1]")
tit_list = [item.inner_text() for item in tit_name.all()]
print("标题:", [item.strip() for item in tit_list[0].split("\n")])

num = iframe.locator("//*[@id='table-0']/div").count() # 获取总行数
print("行数:", num)

for n in range(2, num+1):
element = r"//*[@id='table-0']/" + "div[" + f"{n}" + "]"
row_name = iframe.locator(element)
row_text = [item.inner_text() for item in row_name.all()]
row_list = [item.strip() for item in row_text[0].split("\n")]
if "" in row_list: # 处理第11行之后的数据
row_list = [item.strip() for item in row_list if item.strip()]
print(row_list) # 打印所有的数据行

browser.close() # 关闭浏览器对象

1-7 图片相似度对比

  • 图片相似度对比
    • pip命令安装airtest和aircv:pip install airtestpip install aircv
    • Python安装目录中,找到Lib\site-packages\airtest\aircv\cal_confidence.py。
    • 相似度对比时需用到cal_confidence.py中的函数cal_ccoeff_confidence()
    • 对比图时需要将图片的大小设为一致,可以使用cv2.resize()方法进行处理。
    • cv2.resize(src, dsize, dst=None, fx=None, fy=None, interpolation=None)
      • src源图像、dsize图像的大小、fxwidth方向的缩放比例。
      • fyheight方向的缩放比例、interpolation指定插值的方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import cv2
from PIL import Image
from playwright.sync_api import sync_playwright
import airtest.aircv.cal_confidence as cal_confidence # 引用函数


with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=False)
page = browser.new_page()
page.goto(
"https://img.zcool.cn/community/012e9a5ecfd"
"26aa80120662183278c.jpg@1280w_1l_2o_100sh.jpg"
)
screenshot_path = "pictures/image1.png"
page.screenshot(path=screenshot_path) # 截图保存到本地
browser.close() # 关闭浏览器对象

image = Image.open(screenshot_path) # 将保存下来的图片裁剪成两半
half1 = image.crop((0, 0, image.width // 2, image.height))
half2 = image.crop((image.width // 2, 0, image.width, image.height))
half1.save("pictures/image2.png") # 左图
half2.save("pictures/image3.png") # 右图

im1 = cv2.resize(cv2.imread("pictures/image2.png"), (100, 100))
im2 = cv2.resize(cv2.imread("pictures/image3.png"), (100, 100))
res = cal_confidence.cal_ccoeff_confidence(im1, im2) # 相似度对比
print(res)

2 插件编写用例

  • 插件编写用例
    • pytest-playwright插件完美地继承了pytest框架和playwright基础使用的封装,满足基本工作需求。
    • pip install pytest-playwright -i http://pypi.douban.com/simple/ –trusted-host pypi.douban.com
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import re                                                       # test_plugin.py
from playwright.sync_api import Page, expect


def test_homepage(page: Page): # 网页测试
page.goto("https://playwright.dev/") # 访问指定的URL
expect(page).to_have_title(re.compile("Playwright")) # 判断页面标题是否包含"Playwright"
get_started = page.get_by_role("link", name="Get started") # 获取元素role为link,name为Get started
expect(get_started).to_have_attribute("href", "/docs/intro")
get_started.click() # 点击获取到的元素
expect(page).to_have_url(re.compile(".*intro")) # 正则判断页面的URL是否包含"intro"

# pytest test_plugin.py # 无头模式运行,测试结果和日志在终端显示
# pytest test_plugin.py --headed # 浏览器UI运行

2-1 CLI参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pytest                                                          # 默认在chromium上运行测试
pytest ./test_*/ # 运行一组测试文件
pytest test_plugin.py # 运行单个测试文件
pytest test_plugin.py -k "test_homepage" # 使用函数名运行测试
pytest test_plugin.py --headed # 有头模式运行测试(默认无头)
pytest test_plugin.py --browser webkit # 特定浏览器运行测试
pytest test_plugin.py --browser webkit --browser firefox # 多个浏览器运行测试
pytest test_plugin.py --browser-channel chrome # 指定浏览器通道运行测试
pytest test_plugin.py --slowmo 100 # 指定延迟不超过100ms来减缓测试执行速度
pytest test_plugin.py --device="iPhone 12" # 模拟iPhone 12设备运行测试
pytest test_plugin.py --output="my_results" --video=on # 测试生成的工作目录(默认test-results)
pytest test_plugin.py --tracing=on # 为每个测试记录轨迹
pytest test_plugin.py --video=on # 为每次测试录制视频
pytest test_plugin.py --screenshot=on # 截图(on、默认off、retain-on-failure)

pip install pytest-xdist
pytest --numprocesses auto # 多进程运行测试

2-2 用例编写

  • 用例编写
    • 浏览器上下文使得页面在测试之间被隔离,相当于一个全新的浏览器配置文件,每个测试都会获得一个新环境。
    • 可以使用各种fixture在测试之前或者之后执行代码,并在它们之间共享对象,例如:beforeEach、afterEach等。
    • 内置fixture
      • Function scope:这些固定装置在测试功能中请求时创建,并在测试结束时销毁。
        • page:用于测试的新浏览器页面。
        • context:用于测试的新浏览器上下文。
      • Session scope:这些固定装置在测试函数中请求时创建,并在所有测试结束时销毁。
        • browser_name:浏览器名称作为字符串。
        • browser:由Playwright启动的浏览器实例。
        • browser_channel:浏览器通道作为字符串。
        • browser_type:当前浏览器的BrowserType实例。
        • is_chromium,is_webkit,is_firefox:相应浏览器类型的布尔值。
      • 自定义fixture选项:对于browser和context ,使用以下fixture来自定义启动选项。
        • browser_context_args:覆盖browser.new_context()的选项,返回一个字典。
        • browser_type_launch_args:覆盖browser_type.launch()的启动参数,返回一个字典。
1
2
3
4
5
6
7
8
from playwright.sync_api import Page                             # test_example.py


def test_baidu(page: Page): # 充当测试用例
page.goto("https://www.baidu.com/") # 结合conftest.py使用
page.wait_for_timeout(3000)

page.close()

(1) 跳过浏览器

1
2
3
4
5
6
7
8
import pytest                                                    # test_skip.py


@pytest.mark.skip_browser("firefox")
def test_example(page):
page.goto("https://playwright.dev/")

# pytest test_skip.py --browser firefox # 执行用例查看效果

(2) 指定浏览器

1
2
3
4
5
6
7
8
9
10
import pytest                                                   # test_browser.py


@pytest.mark.only_browser("firefox") # 【方法一】指定浏览器
def test_example(page):
page.goto("https://playwright.dev/")

# pytest test_browser.py --headed # 默认chromium执行
# pytest test_browser.py --browser firefox # 【方法二】
# pytest test_browser.py --browser-channel chromium # 命令行参数指定浏览器

(3) 配置base-url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test_example(page):                                         # test_base_url.py
page.goto("/Login.aspx") # 用到了pytest-base-url插件


"""
# pytest test_base_url.py --base-url https://mall.cnki.net # 【方法一】命令行参数访问

# 另外:PlayWright框架(一)的上下文代码中,base_url参数是在new_context()新建上下文时使用的
# 即browser.new_context(base_url="https://www.baidu.com") # 【方法二】新建上下文使用
# 后续page.goto("/")使用时,只需要填入相对地址"/"就可以访问了

[pytest] # 【方法三】pytest.ini中配置
base_url=https://mall.cnki.net
# pytest test_base_url.py --headed
"""

(4) 设置手机设备

1
2
3
4
5
6
7
8
9
10
11
12
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, playwright):
iphone_11 = playwright.devices["iPhone 11 Pro"] # 指定手机型号运行用例
return {
**browser_context_args,
**iphone_11,
}

# pytest test_example.py --headed # 执行用例查看效果

(5) 忽略https错误

1
2
3
4
5
6
7
8
9
10
11
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"ignore_https_errors": True
}

# pytest test_example.py --headed # 执行用例查看效果

(6) 持久的context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pytest                                                   # conftest.py
from typing import Dict
from playwright.sync_api import BrowserType


@pytest.fixture(scope="session")
def context( # 测试中的所有页面
browser_type: BrowserType, # 都是从持久上下文创建的
browser_type_launch_args: Dict,
browser_context_args: Dict):
context = browser_type.launch_persistent_context("./foobar", **{
**browser_type_launch_args,
**browser_context_args,
"locale": "de-DE",
})
yield context
context.close()

# pytest test_example.py --headed # 执行用例查看效果

(7) 浏览器窗口大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest                                                   # conftest.py


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {
"width": 1920,
"height": 1080,
}
}

# pytest test_example.py --headed # 执行用例查看效果

2-3 多进程执行

  • 多进程执行
    • 原则
      • 用例之间相互独立,没有依赖,每个用例都可独立运行。
      • 用例执行之间没有顺序要求,随机顺序都可以正常执行。
      • 每个用例都能重复执行,运行结果不会影响到其他用例。
    • 使用PIP包管理工具安装pytest-xdist插件:pip install pytest-xdist
    • 线程(Thread)是进程(Process)内的实际执行单位,一进程可包含多个线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
url = "https://www.baidu.com/"                                  # test_process.py


def test_case1():
print("case1------")
assert url == "https://www.baidu.com/"


def test_case2():
print("case2------")
assert url == "https://www.baidu.com/"


def test_case3():
print("case3------")
assert url == "https://www.baidu.com/"

# pytest test_process.py -n 2 # 并行执行用例
# pytest test_process.py -n auto # 自动获取CPU核数,执行速度慢

2-4 命令行选项

  • 命令行选项
    • 测试分布算法配置:指在分布式测试执行期间使用的算法和相关设置。
      • 通过测试分布,可以将测试用例并行在多个浏览器、设备或环境中执行,以加快测试执行速度。
      • 常见的测试分布算法配置选项
        • 轮询:将待执行的测试用例依次轮流分发给可用的执行器,以均匀地分配测试负载。
        • 随机:随机选择可用的执行器来运行测试用例,从而可能在某些情况下提高测试效率。
        • 基于负载:根据执行器的负载情况动态分发测试用例,以确保执行器的负载大致相等。
    • 在Playwright中,dist命令行选项用于配置测试分布算法。
      • --repeat-each 3:每个测试用例将被执行3次。
      • --workers 3:使用3个工作进程来执行测试用例。
      • --shard 1/3:使用3个分片中的第1个分片来执行测试用例。
      • --max-failures 3:如果测试用例失败的次数达到3次,则测试执行将停止。
      • --dist loadfile:使用基于负载的分布算法,并根据测试文件来分发测试用例。
      • --dist loadgroup:使用基于负载的分布算法,并根据测试分组来分发测试用例。
        • 使用@pytest.mark.xdist_group(name=" ")装饰器为测试用例指定不同的分组名称。
        • 当执行测试时,pytest-xdist插件可以并行地执行同一分组的测试用例,提高执行效率。
      • --dist loadscope:使用基于负载的分布算法,并按作用域来动态分配测试用例。
      • --dist load(默认方式):使用基于负载的分布算法,根据执行器的负载情况来动态地分配测试用例。
      • --dist no:禁用测试分布,即不进行分布式测试执行,而是在单个执行器上依次执行所有测试用例。
      • --dist worksteal:使用worksteal算法分发用例,算法允许空闲的执行器从繁忙的执行器那里窃取用例以平衡负载。
      • --max-sched-chunk=3:限制并发执行的最大数量为3,从而在多线程进行测试时可以限制同时执行的测试用例数量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pytest                                                   # test_xdist_group.py

url = "https://www.baidu.com/"


@pytest.mark.xdist_group(name="group1") # 装饰器指定分组名称group1
def test_case1(): # 并行执行group1分组的用例
print("case1------")
assert url == "https://www.baidu.com/"


class TestA:
@pytest.mark.xdist_group("group1")
def test_case2(self):
print("case2------")
assert url == "https://www.baidu.com/"


class TestB:
def test_case3(self):
print("case3------")
assert url == "https://www.baidu.com/"

# pytest -n auto test_xdist_group.py

2-5 插件结合使用

1
2
3
4
5
6
7
8
9
10
11
12
13
def test_case1(base_url):                                       # test_xdist_url.py
print(base_url) # pytest-xdist与pytest-base-url结合使用
assert base_url == "https://www.baidu.com/" # 会遇到xdist不适配的情况,可以参看解决方案


def test_case2(base_url):
print(base_url)
assert base_url == "https://www.baidu.com/"


def test_case3(base_url):
print(base_url)
assert base_url == "https://www.baidu.com/"

(1) pytest.ini

1
2
3
4
5
6
7
[pytest]                                                         # pytest.ini

base_url=https://www.baidu.com/

# pytest test_xdist_url.py # 执行正常
# pytest test_xdist_url.py -n 2 # 执行报错,会获取不到base_url
# don't run configure on xdist worker nodes # 源码文件注释,说明不适配xdist

(2) 解决方案一

1
2
3
4
5
6
7
8
9
10
11
12
@pytest.fixture(scope="session")                                # PyCharm中,Ctrl+右键点击base_url
def base_url(request): # 加载plugin.py文件,找到左侧代码段
"""Return a base URL"""
config = request.config
# base_url = config.getoption("base_url") # 修改该行代码为下一行代码内容,保存
base_url = config.getoption("base_url") or config.getini("base_url")
if base_url is not None:
return base_url

# 再次执行命令pytest test_xdist_url.py -n 2,就不会再报错了,随后执行pytest命令时都出现以下问题
# UnicodeDecodeError: 'gbk' codec can't decode byte 0xa1 in position 185: illegal multibyte sequence
# 将pytest.ini文件的编码格式由UTF-8改为GBK即可解决(不清楚是不是由于修改源文件内容引起的,不推荐使用该解决方案)

(3) 解决方案二

1
2
3
[pytest]                                                         # pytest.ini

addopts = --base-url=https://www.baidu.com/ # 不配置ini参数,配置命令行参数

3 页面对象模型

  • 页面对象模型
    • POM是一种软件测试设计模式,用于在自动化测试中组织和管理网页的元素和操作。
    • 该模式将每个网页视为一个独立的对象,并且使用对象来表示该页面的元素和行为。
    • 基本原则是将页面结构和行为与测试代码分离开来,若结构发生变化,只需更新页面对象的代码。
    • 以测试注册页面为例
      • 根据输入框的内容,可以编写多个有效等价和无效等价的测试用例。
      • 用例操作是在页面元素上点点点,将元素定位和操作封装成一个类。
    • 搭建JForum论坛
      • xampp-windows-x64-5.6.40-1-VC11-installer.exe:下载,傻瓜式安装。
        • MySQL将my.ini文件中的端口3306修改为3311,Apache将httpd.conf文件中的端口80修改为81。
        • /Xampp/phpMyAdmin/config.inc.php文件添加内容$cfg['Servers'][$i]['port'] = '3311';
        • 启动Apache、Tomcat、MySQL,下载“jforum-2.7.0.war”剪贴到Xampp/tomcat/webapps目录。
      • 若浏览器正常访问“http://localhost:81/phpmyadmin/”,说明文件配置正确。
        • phpMyAdmin中新建一个名为jforum,编码为utf8_general_ci的数据库。
        • phpMyAdmin界面,点击账户,将用户名为root的权限密码全部改为123456。
        • /Xampp/phpMyAdmin/config.inc.php文件,改['password'] = '123456';
      • 浏览器访问“http://127.0.0.1:8080/jforum-2.7.0/install.jsp”,部署本地论坛。
      • 数据库端口3311,数据库用户账号为root,密码123456,系统管理员密码123456。
      • 部署完成后访问论坛首页:http://127.0.0.1:8080/jforum-2.7.0/forums/list.page
      • 注意:访问论坛时只需启动Tomcat和MySQL,访问数据库则要开启Apache和MySQL。
1
2
3
4
5
6
7
8
cases:                                                           # 用于放置测试用例
- test_register.py
- __init__.py
models: # 用于放置封装好的类文件
- register_page.py # conftest.py的配置优先于pytest.ini
- __init.py # 但仍然受到pytest.ini命令行选项的影响
- conftest.py # 用于定义共享的fixtures和钩子函数hooks
- pytest.ini # 配置文件,用于设置全局的测试选项和插件

3-1 封装成类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from playwright.sync_api import Page                            # register_page.py


class RegisterPage:
def __init__(self, page: Page):
self.page = page
self.pop_message = None
self.locator_username = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[3]/td[2]/input"
) # 定位会员名称
self.locator_email = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[4]/td[2]/input"
) # 定位电子邮箱
self.locator_password = page.locator(
"//*[@id='password']"
) # 定位密码
self.locator_password_confirm = page.locator(
"//*[@id='formregister']/table[2]/tbody/tr[6]/td[2]/input"
) # 定位确认密码
# self.locator_register_btn = page.locator(
# "//*[@id="formregister"]/table[2]/tbody/tr[7]/td/input[1]"
# ) # 定位确定按钮
self.locator_register_btn = page.get_by_role("button", name="确定")

def navigate(self): # 导航到会员注册页面
self.page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/insert.page")
# self.page.click(
# "/html/body/table/tbody/tr[2]/td/table/tbody/tr[3]/td/input[1]"
# ) # 同意协议,没定位到
self.page.get_by_role("button", name="我同意以上条款").click()

def handle_dialog(self):
def on_dialog(dialog):
print("弹窗提示内容:", dialog.message)
self.pop_message = dialog.message
dialog.dismiss()
self.page.on("dialog", on_dialog)

def fill_username(self, username): # 输入会员名称
self.locator_username.fill(username)

def fill_email(self, email): # 输入电子邮箱
self.locator_email.fill(email)

def fill_password(self, password): # 输入密码
self.locator_password.fill(password)

def fill_password_confirm(self, password_confirm): # 输入确认密码
self.locator_password_confirm.fill(password_confirm)

def click_register_button(self): # 点击确定按钮
self.locator_register_btn.wait_for(state="visible")
self.locator_register_btn.click()

3-2 conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest                                                   # conftest.py
from playwright.sync_api import sync_playwright


@pytest.fixture(scope="session")
def context_chrome():
p = sync_playwright().start() # 前置操作代码
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context()
yield context

context.close() # 实现用例后置
browser.close() # 后置操作代码
p.stop()

3-3 测试用例部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import pytest                                                   # test_register.py
from playwright.sync_api import expect
from models.register_page import RegisterPage


class TestRegister:
@pytest.fixture(autouse=True) # True为作用域内的用例自动调用
def start_for_each(self, context_chrome):
print("访问注册页")
self.page = context_chrome.new_page()
self.register = RegisterPage(self.page)
self.register.navigate()
yield # 将一个函数变成一个生成器
print("关闭注册页")
self.page.close() # 这个生成器用于测试注册页面

def test_register_a(self): # 会员名称为空
self.register.fill_username("") # 字段长度超过直接截断
self.register.fill_email("Amelia@gmail.com") # 这里不做验证
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请填写会员名称输入框"
# expect( # expect断言获取不到内容
# self.register.pop_message
# ).to_contain_text("请填写会员名称输入框")

def test_register_b(self): # 电子邮箱为空
self.register.fill_username("Blima")
self.register.fill_email("")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请正确的填写电子邮件输入框"

def test_register_c(self): # 密码为空
self.register.fill_username("Cing")
self.register.fill_email("Cing@gmail.com")
self.register.fill_password("")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "请填写密码输入框"

def test_register_d(self): # 密码与确认密码不一致
self.register.fill_username("Daijon")
self.register.fill_email("Daijon@gmail.com")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("123456")
self.register.handle_dialog()
self.register.click_register_button()
assert self.register.pop_message == "两次输入的密码不合"

def test_register_e(self): # 成功注册
self.register.fill_username("Elianis")
self.register.fill_email("Elianis@gmail.com")
self.register.fill_password("Aa.123456")
self.register.fill_password_confirm("Aa.123456")
self.register.handle_dialog()
self.register.click_register_button()
expect(self.register.page).to_have_title("My Forum - your board description")


if __name__ == "__main__":
pytest.main()

4 登录相关操作

4-1 身份验证方式

  • 身份验证方式:以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
    • 保存登录cookie:先登录,将cookie保存到本地,通过加载cookie的方式解决重复登录的问题。
    • Web应用程序使用基于cookie或基于令牌的身份验证,其中经过身份验证的状态存储为cookie或本地存储。
    • Playwright提供方法用于从经过身份验证的上下文中检索存储状态,然后创建具有预填充状态的新上下文。
    • 会话存储:会话存储很少用于存储与登录状态相关的信息,常用于特定域,并且不会跨页面加载持续存在。
    • 保存登录的cookie或基于本地存储状态的身份验证,可以跨不同的浏览器使用,取决于应用程序的身份验证模型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from playwright.sync_api import sync_playwright


with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[2]/td[2]/input"
).fill("admin")
page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[3]/td[2]/input"
).fill("123456")
page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[5]/td/input[2]"
).click() # 需要事先创建auth目录,否则报错

storage = context.storage_state(path="auth/state.json") # 保存storageState到指定的文件

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

(1) 生成state.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"cookies": [
{
"name": "JSESSIONID",
"value": "B2BAB0E2E765073EA45904A266F0F1C0",
"domain": "127.0.0.1",
"path": "/jforum-2.7.0",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "jforumUserId",
"value": "2",
"domain": "127.0.0.1",
"path": "/jforum-2.7.0",
"expires": 1732842342.156105,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}

(2) 验证是否免登

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(
storage_state="auth/state.json"
) # 加载本地cookies,实现免登
page = context.new_page()
page.wait_for_timeout(1000)
page.goto("http://127.0.0.1:8080/jforum-2.7.0/forums/list.page")
page.pause() # 打断点查看是否免登

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

(3) 本地会话存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import os
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page() # 导航到特定页面
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
session_storage = page.evaluate( # 获取sessionStorage
"() => JSON.stringify(sessionStorage)"
)
os.environ["SESSION_STORAGE"] = session_storage # 将其存储为环境变量
session_storage = os.environ["SESSION_STORAGE"] # 在新的上下文中设置sessionStorage
context.add_init_script("""(storage => {
if (window.location.hostname === "example.com") {
const entries = JSON.parse(storage)
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value)
}
}
})('""" + session_storage + "')")

session_storage = os.getenv("SESSION_STORAGE") # 检查环境变量是否设置了SESSION_STORAGE
if session_storage:
print("环境变量SESSION_STORAGE已经设置")
else:
print("环境变量SESSION_STORAGE尚未设置")

context.close() # 关闭上下文
browser.close() # 关闭浏览器对象

4-2 绕过登录验证码

  • 绕过登录验证码:以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
    • 浏览器手工登录,再使用Playwright接管页面,绕过登录验证码操作。
    • 找到Chrome属性,复制Chrome的起始位置,添加到Path环境变量下。
    • 参数说明
      • --start-maximized窗口最大化。
      • --incognito隐私模式打开、--new-window直接打开网址。
      • --remote-debugging-port指定运行端口,需确保没被占用。
      • --user-data-dir指定运行浏览器的运行数据,新建目录,不影响系统原数据。
    • chrome.exe --remote-debugging-port=12345 --incognito --start-maximized --user-data-dir=
    • 'D:\Project\Python\demo' --new-window http://127.0.0.1:8080/jforum-2.7.0/user/login.page
    • CMD窗口输入命令启动浏览器,并打开JForum论坛,手动输入账号密码登录后,执行以下Playwright代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
"http://localhost:12345/" # 确保chrome没有执行其他任务
) # 连接现有的Chromium实例
page = browser.contexts[0].pages[0] # 获取page对象
print(page.title())
print(page.url)
page.get_by_role("link", name="进入后台管理").click()

# connect_over_cdp()方法将现有的Chromium浏览器实例与Playwright代码进行绑定
# 而非在Playwright中创建一个新的浏览器实例,不需要再显示地关闭已连接的浏览器实例
# browser.close()

4-3 登录页滑动解锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto("https://www.bootstrapmb.com/item/10579") # 访问滑块效果页

with context.expect_page() as new_page_info: # 切换到滑块页面
page.get_by_role("link", name="预 览").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

iframe = new_page.frame_locator("//*[@id='iframe']") # 滑动模块在iframe里面
slider = iframe.get_by_text("→").bounding_box()
slider_x = slider["x"] + slider["width"] // 2 # 滑块中心点的x坐标
slider_y = slider["y"] + slider["height"] // 2 # 滑块中心点的y坐标
target = iframe.get_by_text("→ 向右滑动").bounding_box()
target_x = target["width"] # 滑块通道总长
print("x坐标:", slider_x, "\ny坐标:", slider_y, "\n最右侧x坐标:", slider_x + target_x)

new_page.mouse.move(slider_x, slider_y)
new_page.mouse.down() # 按住鼠标左键
for _ in range(10): # 循环执行代码10次
slider_x += 50 # 逐步移动鼠标至滑块右侧
new_page.mouse.move(slider_x, slider_y)
print("x坐标+50:", slider_x)
new_page.mouse.up() # 释放鼠标左键

browser.close() # 关闭浏览器对象

4-4 滑块拼图验证码

  • 滑块拼图验证码
    • 首先需要得到滑块背景图,然后计算缺口位置,最后可能还要绕过防爬虫机制,计算滑动轨迹。
    • 滑块背景图可以获取到一个图片地址,缺口可能获取到图片地址,可能由Canvas标签绘制而成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import re
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto("https://www.bootstrapmb.com/item/2880") # 访问滑块拼图页

with context.expect_page() as new_page_info: # 切换到滑块拼图页
page.get_by_role("link", name="预 览").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

iframe = new_page.frame_locator("//*[@id='iframe']") # 滑动模块在iframe里面
iframe.get_by_placeholder("用户名").fill("admin")
iframe.get_by_placeholder("密码").fill("123456")
iframe.get_by_role("button", name="登录").click()
src = iframe.locator("//*[@id='scream']").get_attribute("src")
pic = "https://v.bootstrapmb.com/2018/11/hxge32880/" + src
print(f"背景图地址:{pic}")

canvas_style = iframe.locator(
"//*[@id='imgVer']/div[1]/div[1]/div[2]"
).get_attribute("style")
left_value = re.search(r"left:\s*(-?\d+)px", canvas_style).group(1)
print(f"缺口需移动的距离:{left_value}")

canvas = iframe.locator("#puzzleBox").bounding_box()
canvas_x = canvas["x"] # 左边距
canvas_y = canvas["y"] # 上边距
print(f"canvas位置:左边距={canvas_x}, 上边距={canvas_y}")

slider = iframe.locator(".slider-btn").bounding_box()
print(slider) # 返回滑块的x,y,滑块大小

new_page.mouse.move(x=int(slider["x"]), y=slider["y"] + slider["height"]/2)
new_page.mouse.down(button="middle")
new_page.wait_for_timeout(1000)
new_page.mouse.move(
x=int(slider["x"])-int(left_value), y=slider["y"] + slider["height"]/2
)
new_page.mouse.up()

browser.close() # 关闭浏览器对象

4-5 登录验证码识别

  • 登录验证码识别
    • 思路:先获取到验证码图片,定位元素,对元素截图即可,再使用ddddocr库快速识别。
    • 命令安装ddddocr库:pip install ddddocr -i https://pypi.douban.com/simple
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import ddddocr
from playwright.sync_api import sync_playwright


def handle_dialog(dialog): # 触发弹出窗口的操作未知时使用
print(dialog.message) # 获取对话框中显示的消息
assert dialog.message == "提交成功!"
dialog.dismiss() # 关闭对话框


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context() # 创建上下文
page = context.new_page() # 创建新页面
page.goto("https://www.bootstrapmb.com/item/8462") # 访问验证码页

with context.expect_page() as new_page_info: # 切换到验证码页
page.get_by_role("link", name="预 览").click()
new_page = new_page_info.value
new_page.wait_for_load_state() # 等待页面加载到指定状态

iframe = new_page.frame_locator("//*[@id='iframe']") # 滑动模块在iframe里面
iframe.locator(
"#canvas"
).screenshot(path="pictures/code.png") # 保存验证码

ocr = ddddocr.DdddOcr(show_ad=False) # 识别验证码,实例化
with open("pictures/code.png", "rb") as f: # 打开图片
img_bytes = f.read() # 读取图片
code = ocr.classification(img_bytes) # 识别验证码
print(f"验证码:{code}")

iframe.get_by_placeholder("请输入验证码(不区分大小写)").fill(code)
iframe.get_by_role("button", name="提交").click()
new_page.on("dialog", handle_dialog)

browser.close() # 关闭浏览器对象

4-6 多线程登录账号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from threading import Thread
from playwright.sync_api import sync_playwright


def do_some_thing(username, password):
p = sync_playwright().start()
browser = p.chromium.launch(headless=False, slow_mo=1000)
context = browser.new_context()
page = context.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

page.locator("input[name=\"username\"]").fill(username)
page.locator("input[name=\"password\"]").fill(password)
page.get_by_role("button", name="登入").click()

page.pause() # 暂停查看效果
context.close()
browser.close() # 关闭浏览器对象


users = [["Admin", "123456"], ["Elara", "Aa.123456"], ["Celeste", "Aa.123456"]]
for user in users: # 多线程,3个账号同时操作
thread = Thread(target=do_some_thing, args=user)
thread.start()

5 账号切换操作

  • 账号切换操作
    • 以“页面对象模型”中搭建的“JForum论坛”为例进行说明。
      • 用户的发帖与回帖功能,需使用Admin管理员账号,在后台管理中将验证码关闭。
      • 双账号切换操作:代码实现Admin账号登录进行发帖,Elianis账号登录进行回帖。
      • 这里的conftest.py文件在根目录,会被“页面对象模型”的conftest.py文件取代。
    • conftest.py文件的优先级问题
      • 测试文件所在目录中的conftest.py文件,具有最高的优先级。
      • 测试文件所在父目录的conftest.py文件其次,依次向上查找。
      • 项目根目录中的conftest.py文件优先级最低。
1
2
3
4
5
6
7
8
cases:                                                           # 用于放置测试用例
- test_login.py
- __init__.py # 空
- conftest.py # 用于定义共享的fixtures和钩子函数hooks
models: # 用于放置封装好的类文件
- post_page.py
- login_page.py
- __init__.py # 空

5-1 conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
import pytest                                                   # conftest.py
from playwright.sync_api import sync_playwright


@pytest.fixture(scope="session")
def context_chrome():
p = sync_playwright().start() # 前置操作代码
browser = p.chromium.launch(headless=False, slow_mo=1000)
yield browser

browser.close() # 后置操作代码
p.stop()

5-2 post_page.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from playwright.sync_api import Page                            # post_page.py


class PostPage:
def __init__(self, page: Page):
self.page = page

self.locator_section = page.locator(
"body > table > tbody > tr:nth-child(2) > td > table:nth-child(5) > tbody > tr > td "
"> table.forumline.forumlist > tbody > tr:nth-child(3) > td:nth-child(2) > span.forumlink > a"
) # 定位每日一言版块
self.locator_topic = page.locator(
"body > table > tbody > tr:nth-child(2) > td > table:nth-child(6) "
"> tbody > tr > td > table:nth-child(2) > tbody > tr > td:nth-child(1)"
) # 定位发表主题按钮

self.locator_post = page.get_by_role("link", name="每日英语")
self.locator_english = page.locator(
".bodyline > table:nth-child(2) > tbody > tr > td"
).first

self.locator_subject = page.locator("input[name=\"subject\"]")
self.locator_message = page.locator("textarea[name=\"message\"]")
self.locator_sending = page.get_by_role("button", name="发送")

def navigate_daily_word(self):
self.locator_section.click()
self.locator_topic.click()

def posted(self, subject, message): # 发帖
self.locator_subject.fill(subject)
self.locator_message.fill(message)
self.locator_sending.click()

def navigate_daily_english(self):
self.locator_section.click()
self.locator_post.click()
self.locator_english.click()

def reply(self, message): # 回帖
self.locator_message.fill(message)
self.locator_sending.click()

5-3 login_page.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import Page                            # login_page.py


class LoginPage:
def __init__(self, page: Page):
self.page = page
self.locator_username = page.locator("input[name=\"username\"]")
self.locator_password = page.locator("input[name=\"password\"]")
self.locator_login_btn = page.get_by_role("button", name="登入")

def navigate(self): # 导航到登录页面
self.page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")

def login(self, username, password): # 登录
self.locator_username.fill(username)
self.locator_password.fill(password)
self.locator_login_btn.click()

5-4 test_login.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import pytest                                                   # test_login.py
import requests
from models.post_page import PostPage
from models.login_page import LoginPage


def message_content():
response = requests.get("https://api.vvhan.com/api/dailyEnglish?type=sj")
if response.status_code == 200:
data = response.json() # 解析JSON响应
zh_content = data["data"]["zh"] # 获取zh和en内容
en_content = data["data"]["en"]
print(f"{en_content}") # 打印内容
print(f"{zh_content}")
return f"{en_content}" + "/n" + f"{zh_content}"
else:
print("API获取每日英语失败。")
return "API获取每日英语失败。"


class TestMoreAccounts: # 多账号切换操作
"""
测试流程:
step--Admin登录,发帖
step--Elianis登录,回帖
:return:
"""
@pytest.fixture(autouse=True) # True为作用域内的用例自动调用
def start_for_each(self, context_chrome):
print("访问登录页")

admin_context = context_chrome.new_context() # 管理员登录
admin_page = admin_context.new_page()
self.user_admin_login = LoginPage(admin_page)
self.user_admin_login.navigate() # 每个账户操作
self.user_admin_post = PostPage(admin_page) # 都将产生两个浏览器???

general_context = context_chrome.new_context() # 普通用户登录
general_page = general_context.new_page()
self.uesr_general_login = LoginPage(general_page)
self.uesr_general_login.navigate()
self.user_general_reply = PostPage(general_page)

yield # 将一个函数变成一个生成器
print("关闭登录页")
admin_context.close()
general_context.close() # 关闭上下文

def test_admin(self):
self.user_admin_login.login("Admin", "123456")
self.user_admin_post.navigate_daily_word()
self.user_admin_post.posted("每日英语", message_content())

def test_general(self):
self.uesr_general_login.login("Elianis", "Aa.123456")
self.user_general_reply.navigate_daily_english()
self.user_general_reply.reply(message_content())


if __name__ == "__main__":
pytest.main()

6 Mock接口返回

  • Mock接口返回
    • Playwright提供API,用来模拟和修改网络请求,包括HTTP和HTTPS。
    • 页面执行的任何请求包括XHR和获取请求,都可以被跟踪、修改和模拟。
    • 使用page.route()方法创建Route对象,指定要拦截的请求URL,或使用正则表达式进行匹配。
    • 创建Route对象后,通过调用route.abort()route.fulfill()等来控制请求的进一步处理。

6-1 方法

  • 方法
    • route.abort(),中止请求,可以选择指定的错误代码。
    • route.fetch(),执行请求并在不满足的情况下获取结果,以修改完成响应。
    • route.fulfill(),用于模拟完成请求,即手动提供响应数据,并结束请求。
    • route.fallback(),用于指定当请求未匹配到任何拦截规则时候的回退行为。
    • route.continue_(),继续请求,使其按照正常的流程继续发送,并接收响应。

(1) abort()*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com"):
print(f"Intercepted request to: {request.url}")
route.abort() # 中止请求


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com/smog") # 报错,无法访问网站???
browser.close() # 关闭浏览器对象

(2) fetch()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright


def handle(route):
response = route.fetch() # 获取网络请求的响应
json = response.json() # 将响应转换成Json格式
json["result"]["big_red_dog"] = [] # 添加响应数据big_red_dog,属性值设为空
route.fulfill(response=response, json=json)


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route("https://api.oioweb.cn/api/SoulWords", handle) # 暂停后,在当前页面访问接口地址
page.pause() # F12查看响应数据的添加情况
browser.close() # 关闭浏览器对象

(3) fulfill()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://api.oioweb.cn/api/SoulWords"):
print(f"Intercepted request to: {request.url}")
route.fulfill(
status=200, # 状态码
body="{'message': 'Hello, World!'}", # 响应体
headers={"Content-Type": "application/json"} # 响应头
) # 模拟完成请求
else:
route.continue_() # 条件不符则允许请求正常进行


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route(
"**/*", lambda route, request: intercept_request(route, request)
) # 监听请求并拦截
page.goto("https://api.oioweb.cn/api/SoulWords")
page.pause()
browser.close() # 关闭浏览器对象

(4) fallback()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com/smog"):
print(f"Intercepted request to: {request.url}")
route.abort() # 中止请求
else:
print(f"Fallback: {request.url}") # 打印回退消息
route.fallback() # 触发回退行为,允许请求正常继续


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com")
browser.close() # 关闭浏览器对象

(5) continue_()*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://www.amap.com"):
print(f"Intercepted request to: {request.url}")
route.continue_() # 继续请求,报错???


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route( # 监听请求并拦截
"**/*", lambda route, request: intercept_request(route, request)
)
page.goto("https://www.amap.com/smog")
browser.close() # 关闭浏览器对象

6-2 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from playwright.sync_api import sync_playwright


def intercept_request(route, request):
if request.url.startswith("https://api.oioweb.cn/api/SoulWords"):
print(f"Intercepted request to: {request.url}")
route.fulfill(
status=200, # 状态码
body="{'message': 'Hello, World!'}", # 响应体
headers={"Content-Type": "application/json"} # 响应头
) # 模拟完成请求
print(route.request) # 获取当前请求的相关信息或属性
else:
route.continue_() # 条件不符则允许请求正常进行


with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.route(
"**/*", lambda route, request: intercept_request(route, request)
) # 监听请求并拦截
page.goto("https://api.oioweb.cn/api/SoulWords")
browser.close() # 关闭浏览器对象

7 JavaScript脚本

  • JavaScript脚本
    • 页面对象执行JavaScript脚本
      • Playwright使用page.evaluate()执行JavaScript代码并返回调用执行的结果。
      • 使用page.evaluate_handle()执行JavaScript代码,并返回执行结果的句柄。
    • 定位元素执行JavaScript脚本
      • 通过locator.evaluate()方法,对定位到的元素执行JavaScript代码。
      • 使用locator.evaluate_all()对定位的所有元素执行JavaScript代码。

7-1 页面Js*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()

print(page.evaluate("1 + 2")) # 3
x = 10
print(page.evaluate(f"1 + {x}")) # 11

res = page.evaluate("() => 'Hello World!'", ) # 执行一个函数
print(res) # Hello World!
res = page.evaluate(
"([a, b]) => a+b+'World!'", ["Hello", " "] # 执行的函数可以带参数
)
print(res) # hello world

page.goto("https://www.baidu.com/")
title = page.evaluate("document.title") # 获取页面的title
print(title)

page = browser.new_page() # 需要开启JForum论坛服务
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
js = """
document.querySelector("input[name='username']").value="admin";
document.querySelector("input[name='password']").value="123456";
document.querySelector("input[name='login']").click();
"""
page.evaluate(js)

a_handle = page.evaluate_handle("document.body") # 返回句柄JSHandle,报错了???
result_handle = page.evaluate_handle("body => body.innerHTML", a_handle)
print(result_handle.json_value())
result_handle.dispose() # 释放result_handle对象,避免内存泄漏

browser.close() # 关闭浏览器对象

7-2 元素Js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://127.0.0.1:8080/jforum-2.7.0/user/login.page")
username = page.locator(
"//*[@id='loginform']/table[2]/tbody/tr[2]/td/table/tbody/tr[2]/td[2]/input"
)
username.evaluate("node => node.value='admin'") # 输入框输入内容
input_value = username.evaluate("node => node.value")
print(input_value) # 获取输入框内容

page.goto("https://www.baidu.com/")
links = page.locator("#s-top-left>a")
res = links.evaluate_all("nodes => nodes.length") # 定位全部元素
print(res) # nodes.length获取元素个数

browser.close() # 关闭浏览器对象

8 Pyinstaller打包

  • Pyinstaller打包
    • Playwright与Pyinstaller三方插件结合使用,可用来创建独立的可执行文件。
    • 例如:要打包一个files_pack.py文件。
      • 先在当前文件夹中设置环境变量:set PLAYWRIGHT_BROWSERS_PATH=0
      • 然后命令安装文件中使用到的浏览器:playwright install chromium
      • 最后使用命令查看浏览器的安装路径:playwright install --dry-run
      • 可以看到chromium-1048和ffmpeg-1008这两个文件,后缀数字是版本号。
    • 打包files_pack.py文件命令如下,打包后的可执行文件可在当前目录的dist文件夹中找到。
      • 依赖文件较多,首次打包需加载好一会:pyinstaller -F files_pack.py
      • 打包时自定义ico图标:pyinstaller -F files_pack.py -i favicon.ico
      • ico图标制作可以使用地址“在线制作ico图标”,提供图片线上转换即可。

8-1 files_pack.py

1
2
3
4
5
6
7
8
from playwright.sync_api import sync_playwright                  # files_pack.py

with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://www.baidu.com")
page.screenshot(path="pictures/install.png")
browser.close() # 关闭浏览器对象

8-2 依赖环境配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
(base) D:\Program\...> set PLAYWRIGHT_BROWSERS_PATH=0
(base) D:\Program\...> playwright install chromium
(base) D:\Program\...> playwright install --dry-run
browser: chromium version 111.0.5563.19
Install location: C:\Users\...\chromium-1048
Download url: https://playwright.azureedge.net/builds/chromium/1048/chromium-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/chromium/1048/chromium-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/chromium/1048/chromium-win64.zip

browser: firefox version 109.0
Install location: C:\Users\...\firefox-1378
Download url: https://playwright.azureedge.net/builds/firefox/1378/firefox-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/firefox/1378/firefox-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/firefox/1378/firefox-win64.zip

browser: webkit version 16.4
Install location: C:\Users\...\webkit-1792
Download url: https://playwright.azureedge.net/builds/webkit/1792/webkit-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/webkit/1792/webkit-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/webkit/1792/webkit-win64.zip

browser: ffmpeg
Install location: C:\Users\...\ffmpeg-1008
Download url: https://playwright.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip
Download fallback 1: https://playwright-akamai.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip
Download fallback 2: https://playwright-verizon.azureedge.net/builds/ffmpeg/1008/ffmpeg-win64.zip

(base) D:\Program\...> pyinstaller -F files_pack.py
(base) D:\Program\...> pyinstaller -F files_pack.py -i pictures/favicon.ico

8-3 打包路径问题*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Traceback (most recent call last):
File "files_pack.py", line 4, in <module>
File "playwright\sync_api\_generated.py", line 14155, in launch
File "playwright\_impl\_sync_base.py", line 115, in _sync
File "playwright\_impl\_browser_type.py", line 94, in launch
File "playwright\_impl\_connection.py", line 59, in send
File "playwright\_impl\_connection.py", line 514, in wrap_api_call
playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at
C:\Users\...\playwright\driver\package\.local-browsers\chromium-1048\chrome-win\chrome.exe
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated. ║
║ Please run the following command to download new browsers: ║
║ ║
║ playwright install ║
║ ║
║ <3 Playwright Team ║
╚════════════════════════════════════════════════════════════╝
[9672] Failed to execute script 'files_pack' due to unhandled exception!
  • 说明Playwright浏览器驱动没有被正确打包到exe文件中。
    • 【方法一】手动添加浏览器驱动到打包目录:执行仍然报同个错误,不知道参数设置是否有问题?
    • pyinstaller --add-data="C:\Users\...\chromium-1048;playwright/driver" files_pack.py
    • 【方法二】使用--hidden-import参数,指定需要打包的隐藏模块,将其强制打包到exe文件中。
    • pyinstaller --hidden-import=playwright._impl._browser_type files_pack.py:执行会闪退。
    • 【方法三】使用--onefile参数,将所有的文件都打包到可执行文件exe中,就可避免路径问题了。
    • pyinstaller --onefile files_pack.pyexe文件执行还是会报错。
    • 【方法四】代码中使用Playwright的install()函数来安装浏览器驱动。
    • 不知道是否环境问题,在其他电脑上打包后,可执行文件能成功被执行。
1
2
3
4
5
6
7
8
9
from playwright.sync_api import sync_playwright                  # files_pack.py

with sync_playwright() as p:
p.install() # 安装浏览器驱动
browser = p.chromium.launch(headless=False, slow_mo=1000)
page = browser.new_page()
page.goto("http://www.baidu.com")
page.screenshot(path="pictures/install.png")
browser.close() # 关闭浏览器对象

PlayWright 框架(二)
https://stitch-top.github.io/2023/11/02/ce-shi-kuang-jia/tf05-playwright/tf02-playwright-kuang-jia-er/
作者
Dr.626
发布于
2023年11月2日 21:10:10
许可协议