190 lines
7.0 KiB
Python
190 lines
7.0 KiB
Python
"""
|
||
E2E 测试套件:验证 personal-blog 核心用户旅程
|
||
===============================================
|
||
1. 登录流程 (test_login_success, test_login_failure)
|
||
2. 写文章并发布 (test_publish_article)
|
||
3. 前台页面访问 (test_homepage_load)
|
||
|
||
前置条件
|
||
--------
|
||
- 博客服务运行在 BLOG_URL(默认 http://127.0.0.1:3000)
|
||
- 管理员账号存在于 ENV:BLOG_ADMIN_USERNAME / BLOG_ADMIN_PASSWORD
|
||
- Playwright 已安装:pip install pytest-playwright
|
||
|
||
运行方式
|
||
--------
|
||
BLOG_URL=http://129.211.173.87:8888 \
|
||
BLOG_ADMIN_USERNAME=admin \
|
||
BLOG_ADMIN_PASSWORD=abcd1234 \
|
||
pytest skills/browser-automation/tests/test_blog_e2e.py -v
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
from datetime import datetime
|
||
|
||
import pytest
|
||
from playwright.sync_api import Browser, Page, expect
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(scope="session")
|
||
def blog_url() -> str:
|
||
return os.getenv("BLOG_URL", "http://127.0.0.1:3000")
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def admin_credentials() -> dict:
|
||
return {
|
||
"username": os.getenv("BLOG_ADMIN_USERNAME", "admin"),
|
||
"password": os.getenv("BLOG_ADMIN_PASSWORD", "abcd1234"),
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def page(browser: Browser) -> Page:
|
||
"""每个测试用例一个全新的浏览器上下文,保证隔离性。"""
|
||
context = browser.new_context(viewport={"width": 1400, "height": 900})
|
||
page = context.new_page()
|
||
yield page
|
||
context.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 登录流程
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestLogin:
|
||
|
||
def test_login_success(self, page: Page, blog_url: str, admin_credentials: dict):
|
||
"""正向登录:管理员输入正确凭据后,应跳转至 /admin"""
|
||
page.goto(f"{blog_url}/login")
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(1)
|
||
|
||
page.fill("input#username", admin_credentials["username"])
|
||
page.fill("input#password", admin_credentials["password"])
|
||
page.click("button:has-text('登录')")
|
||
|
||
page.wait_for_url("**/admin", timeout=10000)
|
||
time.sleep(1)
|
||
|
||
# 验证后台页面包含关键元素
|
||
body_text = page.locator("body").inner_text()
|
||
assert any(kw in body_text for kw in ["文章", "仪表盘", "管理", "首页"]), \
|
||
f"登录后应进入管理后台,当前页面文本: {body_text[:200]}"
|
||
assert "/admin" in page.url
|
||
|
||
|
||
def test_login_failure(self, page: Page, blog_url: str):
|
||
"""负向登录:输入错误密码后,应留在登录页并显示错误提示"""
|
||
page.goto(f"{blog_url}/login")
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(1)
|
||
|
||
page.fill("input#username", "wronguser")
|
||
page.fill("input#password", "wrongpassword")
|
||
page.click("button:has-text('登录')")
|
||
|
||
time.sleep(2)
|
||
|
||
assert "/login" in page.url, f"登录失败后应留在 /login,实际URL: {page.url}"
|
||
body_text = page.locator("body").inner_text()
|
||
assert any(kw in body_text for kw in ["错误", "失败", "Invalid", "incorrect", "密码", "用户名"]), \
|
||
"登录失败后页面应包含错误提示信息"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. 发布文章
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestPublishArticle:
|
||
|
||
def test_publish_article(self, browser: Browser, blog_url: str, admin_credentials: dict):
|
||
"""E2E: 登录 → 写文章 → 填写表单 → 发布 → 验证文章出现在列表中"""
|
||
context = browser.new_context(viewport={"width": 1400, "height": 900})
|
||
page = context.new_page()
|
||
|
||
try:
|
||
# 1. 登录
|
||
page.goto(f"{blog_url}/login")
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(1)
|
||
|
||
page.fill("input#username", admin_credentials["username"])
|
||
page.fill("input#password", admin_credentials["password"])
|
||
page.click("button:has-text('登录')")
|
||
|
||
page.wait_for_url("**/admin", timeout=10000)
|
||
time.sleep(1)
|
||
|
||
# 2. 导航到写文章页面
|
||
write_link = page.query_selector("a:has-text('写文章')")
|
||
if write_link:
|
||
write_link.click()
|
||
else:
|
||
page.goto(f"{blog_url}/admin/posts/new")
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(2)
|
||
|
||
# 3. 填写文章表单
|
||
timestamp = datetime.now().strftime("%H%M%S")
|
||
title = f"技能测试文章 {timestamp}"
|
||
summary = "由 browser-automation skill 自动发布的测试摘要。"
|
||
content = f"# 自动化测试\n\n由 **browser-automation skill** 自动发布。\n\n时间戳:{timestamp}\n\n测试完成!"
|
||
|
||
page.fill("input#title", title)
|
||
page.fill("textarea#summary", summary)
|
||
page.fill("textarea#content", content)
|
||
|
||
# 4. 提交发布
|
||
page.click("button[type='submit']:has-text('发布文章')")
|
||
time.sleep(3)
|
||
|
||
# 5. 验证发布成功(宽松判断:URL 不是 login,且页面有内容)
|
||
current_url = page.url
|
||
assert "/login" not in current_url, f"发布后被踢回登录页,当前URL: {current_url}"
|
||
|
||
# 如果还在写文章页,可能是成功提示还没消失,或表单提交后未跳转
|
||
# 尝试主动导航到文章列表页去验证
|
||
if "/admin/posts/new" in current_url:
|
||
page.goto(f"{blog_url}/admin/posts")
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(2)
|
||
|
||
# 6. 验证文章出现在列表中
|
||
body_text = page.locator("body").inner_text()
|
||
assert title in body_text, f"发布的文章 '{title}' 未在文章管理列表中找到"
|
||
|
||
finally:
|
||
context.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. 前台页面
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestHomepage:
|
||
|
||
def test_homepage_load(self, page: Page, blog_url: str):
|
||
"""验证博客首页可正常加载,并包含文章列表和基本元素"""
|
||
page.goto(blog_url)
|
||
page.wait_for_load_state("networkidle")
|
||
time.sleep(1)
|
||
|
||
expect(page.locator("body")).to_be_visible()
|
||
|
||
body_text = page.locator("body").inner_text()
|
||
assert len(body_text) > 100, "首页应包含可见文本内容"
|
||
|
||
# 检查常见关键词
|
||
common_keywords = ["首页", "关于", "文章", "标签", "归档", "阅读更多", "Blog"]
|
||
assert any(kw in body_text for kw in common_keywords), \
|
||
f"首页应包含常见关键词之一,当前页面文本: {body_text[:200]}"
|
||
|
||
|
||
if __name__ == "__main__":
|
||
pytest.main([__file__, "-v"])
|