markdown
# conftest.py 执行流程图
```mermaid
graph TD
A[测试开始 - pytest启动] --> B[加载conftest.py]
B --> C[执行session级别fixture<br/>clear_extract()]
C --> D[禁用ResourceWarning]
C --> E[清空yaml数据]
C --> F[清理报告临时文件]
D --> G[测试用例收集阶段]
E --> G
F --> G
G --> H[测试用例执行阶段]
H --> I[执行各个测试用例]
I --> J[测试完成]
J --> K[执行pytest_terminal_summary钩子]
K --> L[生成测试摘要]
L --> M{是否启用钉钉通知?}
M -->|是| N[发送钉钉通知]
M -->|否| O{是否启用邮件通知?}
N --> O
O -->|是| P[发送邮件通知]
O -->|否| Q[测试结束]
P --> Q
style C fill:#e1f5fe
style K fill:#f3e5f5
style L fill:#f3e5f5
style M fill:#f3e5f5
style N fill:#f3e5f5
style O fill:#f3e5f5
style P fill:#f3e5f5
详细说明
1. clear_extract() fixture (session级别)
- 执行时机: 整个测试会话开始前执行一次
- 作用域: session级别,整个测试过程只执行一次
- 自动执行: 通过autouse=True自动调用,无需显式引用
- 执行内容 :
- 禁用ResourceWarning告警
- 清空YAML数据文件
- 清理报告目录下的临时文件
2. pytest_terminal_summary钩子函数
- 执行时机: 所有测试执行完成后的终端摘要阶段
- 触发条件: 测试会话结束前自动调用
- 执行内容 :
- 生成测试结果摘要(总用例数、通过数、失败数等)
- 根据配置决定是否发送钉钉通知
- 根据配置决定是否发送邮件通知
3. 测试执行流程
- pytest启动并加载conftest.py
- 自动执行clear_extract() fixture
- 收集测试用例
- 执行测试用例
- 测试完成,调用pytest_terminal_summary钩子
- 生成并发送通知(根据配置)
- 测试结束
测试完成后钉钉机器人发送&邮箱发送
conftest代码
dingTalkRobot
python
import urllib.parse
import requests
import time
import hmac
import hashlib
import base64
import json
from typing import Optional, Dict, Any, List, Union
from dataclasses import dataclass
from enum import Enum
from abc import ABC, abstractmethod
from common.recordlog import logs
from conf.operationConfig import OperationConfig
"""
dingRobot:钉钉群机器人
Author: tsukiyomi
"""
class MessageType(Enum):
TEXT = "text"
MARKDOWN = "markdown"
LINK = "link"
ACTION_CARD = "actionCard"
FEED_CARD = "feedCard"
# 简化配置类,完全移除access_token
@dataclass
class DingTalkConfig:
"""钉钉机器人配置类
配置与代码分离
- 配置信息从外部读取,支持多环境配置
- 仅支持加签方式,不使用access_token
"""
webhook_url: str
secret: str
timeout: int = 10 # 超时时间可配置,默认10秒
max_retries: int = 3 # 添加重试次数配置
retry_delay: int = 2 # 重试延迟时间(秒)
@classmethod
def from_config(cls, config: Optional[OperationConfig] = None) -> 'DingTalkConfig':
"""从配置文件加载配置
工厂方法模式
- 提供工厂方法,支持多种配置来源
"""
config = config or OperationConfig()
try:
return cls(
webhook_url=config.get_section_for_data('DINGTALK', 'webhook_url'),
secret=config.get_section_for_data('DINGTALK', 'secret'),
timeout=int(config.get_section_for_data('DINGTALK', 'timeout', '10')),
max_retries=int(config.get_section_for_data('DINGTALK', 'max_retries', '3')),
retry_delay=int(config.get_section_for_data('DINGTALK', 'retry_delay', '2'))
)
except Exception as e:
logs.error(f"加载钉钉配置失败: {e}")
# 配置加载失败时的降级处理
raise ValueError(f"钉钉机器人配置不完整或格式错误: {e}")
class SignatureGenerator:
"""签名生成器
单一职责原则
- 独立签名生成逻辑,提高代码可测试性和复用性
"""
@staticmethod
def generate(secret: str) -> tuple[str, str]:
"""生成钉钉机器人签名
Args:
secret: 钉钉机器人密钥
Returns:
(timestamp, sign): 时间戳和签名的元组
Raises:
ValueError: 当密钥为空时
"""
if not secret:
raise ValueError("密钥不能为空")
# 使用更精确的时间戳生成方式
# 使用int直接截断,避免四舍五入
timestamp = str(int(time.time() * 1000))
# 使用f-string格式化,更简洁高效
str_to_sign = f'{timestamp}\n{secret}'
# 计算签名
secret_enc = secret.encode('utf-8')
str_to_sign_enc = str_to_sign.encode('utf-8')
hmac_code = hmac.new(secret_enc, str_to_sign_enc, digestmod=hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return timestamp, sign
# 消息构建器基类,支持多种消息类型
class MessageBuilder(ABC):
"""消息构建器抽象基类
策略模式
- 使用策略模式,便于扩展不同消息类型
"""
@abstractmethod
def build(self, **kwargs) -> Dict[str, Any]:
"""构建消息体"""
pass
class TextMessageBuilder(MessageBuilder):
"""文本消息构建器"""
def build(
self,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> Dict[str, Any]:
"""构建文本消息
更灵活的@功能
- 支持@指定手机号或用户ID
Args:
content: 消息内容
at_mobiles: 需要@的手机号列表
at_user_ids: 需要@的用户ID列表
is_at_all: 是否@所有人
Returns:
消息字典
"""
# 参数验证
if not content:
raise ValueError("消息内容不能为空")
# 消息长度限制检查
# 钉钉文本消息限制为2048个字符
if len(content) > 2048:
logs.warning(f"消息内容超过2048字符限制,将被截断: {len(content)}")
content = content[:2045] + "..."
message = {
"msgtype": MessageType.TEXT.value,
"text": {
"content": content
},
"at": {
"isAtAll": is_at_all
}
}
# 动态添加@列表,避免空列表
if at_mobiles:
message["at"]["atMobiles"] = at_mobiles
if at_user_ids:
message["at"]["atUserIds"] = at_user_ids
return message
class MarkdownMessageBuilder(MessageBuilder):
"""Markdown消息构建器 """
def build(
self,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> Dict[str, Any]:
"""构建Markdown消息"""
if not title or not text:
raise ValueError("标题和内容都不能为空")
message = {
"msgtype": MessageType.MARKDOWN.value,
"markdown": {
"title": title,
"text": text
},
"at": {
"isAtAll": is_at_all
}
}
if at_mobiles:
message["at"]["atMobiles"] = at_mobiles
if at_user_ids:
message["at"]["atUserIds"] = at_user_ids
return message
class DingTalkBot:
"""钉钉机器人客户端"""
def __init__(self, config: Optional[DingTalkConfig] = None):
"""初始化钉钉机器人
依赖注入
- 优化:支持外部注入配置,提高灵活性
"""
self.config = config or DingTalkConfig.from_config()
self.signature_generator = SignatureGenerator()
# 使用消息构建器注册表,便于扩展新的消息类型
self.message_builders: Dict[MessageType, MessageBuilder] = {
MessageType.TEXT: TextMessageBuilder(),
MessageType.MARKDOWN: MarkdownMessageBuilder(),
}
# 复用session,提高性能
self.session = requests.Session()
self.session.headers.update({'Content-Type': 'application/json;charset=utf-8'})
def _build_url(self) -> str:
"""构建完整的Webhook URL
URL构建逻辑独立
- 独立成方法,便于测试和维护
- 直接使用配置的webhook_url,仅添加签名参数
"""
timestamp, sign = self.signature_generator.generate(self.config.secret)
# 解析配置的webhook_url并添加签名参数
parsed_url = urllib.parse.urlparse(self.config.webhook_url)
query_params = urllib.parse.parse_qs(parsed_url.query)
# 添加签名参数
query_params['timestamp'] = timestamp
query_params['sign'] = sign
# 重新构建URL
new_query = urllib.parse.urlencode(query_params, doseq=True)
new_url = urllib.parse.urlunparse((
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment
))
return new_url
def _send_with_retry(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""发送消息with重试机制
添加重试机制
- 添加指数退避重试,提高可靠性
"""
last_exception = None
for attempt in range(self.config.max_retries):
try:
# 每次重试重新生成签名,避免签名过期问题
url = self._build_url()
logs.info(f"发送钉钉消息,第{attempt + 1}次尝试...")
response = self.session.post(
url,
json=message,
timeout=self.config.timeout
)
# 状态码检查
response.raise_for_status()
# 解析响应并验证
result = response.json()
# 钉钉API返回码检查
# 钉钉成功返回 {"errcode": 0, "errmsg": "ok"}
if result.get('errcode') == 0:
logs.info(f"钉钉消息发送成功: {result}")
return result
else:
# 详细的错误信息
error_msg = f"钉钉API返回错误: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
logs.error(error_msg)
# 以下错误不应重试
# 例如:关键词不匹配(310000)、IP不在白名单(310000)等
non_retryable_codes = [310000, 400001, 400002]
if result.get('errcode') in non_retryable_codes:
raise ValueError(error_msg)
last_exception = Exception(error_msg)
except requests.exceptions.Timeout as e:
logs.warning(f"请求超时 (尝试 {attempt + 1}/{self.config.max_retries}): {e}")
last_exception = e
except requests.exceptions.RequestException as e:
logs.warning(f"网络请求失败 (尝试 {attempt + 1}/{self.config.max_retries}): {e}")
last_exception = e
except json.JSONDecodeError as e:
logs.error(f"响应解析失败: {e}")
last_exception = e
except ValueError:
# 不可重试的错误,直接抛出
raise
# 指数退避策略
if attempt < self.config.max_retries - 1:
delay = self.config.retry_delay * (2 ** attempt)
logs.info(f"等待{delay}秒后重试...")
time.sleep(delay)
# 所有重试都失败
raise Exception(f"发送钉钉消息失败,已重试{self.config.max_retries}次: {last_exception}")
def send_text(
self,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> bool:
"""发送文本消息
Args:
content: 消息内容
at_mobiles: 需要@的手机号列表
at_user_ids: 需要@的用户ID列表
is_at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
builder = self.message_builders[MessageType.TEXT]
message = builder.build(
content=content,
at_mobiles=at_mobiles,
at_user_ids=at_user_ids,
is_at_all=is_at_all
)
result = self._send_with_retry(message)
return result.get('errcode') == 0
except Exception as e:
logs.error(f"发送文本消息失败: {e}")
return False
def send_markdown(
self,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> bool:
"""发送Markdown消息 """
try:
builder = self.message_builders[MessageType.MARKDOWN]
message = builder.build(
title=title,
text=text,
at_mobiles=at_mobiles,
at_user_ids=at_user_ids,
is_at_all=is_at_all
)
result = self._send_with_retry(message)
return result.get('errcode') == 0
except Exception as e:
logs.error(f"发送Markdown消息失败: {e}")
return False
def send_test_report(
self,
total: int,
success: int,
failed: int,
error: int,
duration: Optional[float] = None,
is_at_all: bool = True
) -> bool:
"""发送测试报告"""
# 计算通过率
executed = success + failed + error
pass_rate = f"{(success / executed * 100):.2f}%" if executed > 0 else "N/A"
# 使用Markdown格式化测试报告
title = "接口测试报告"
# 根据测试结果使用不同的emoji
status_emoji = "✅" if failed == 0 and error == 0 else "❌"
text = f"""## {status_emoji} 接口自动化测试报告
### 📊 测试概况
- **总用例数**: {total}
- **执行用例数**: {executed}
- **通过数**: {success} ✅
- **失败数**: {failed} ❌
- **错误数**: {error} ⚠️
### 📈 执行统计
- **通过率**: {pass_rate}
- **执行时间**: {f'{duration:.2f}秒' if duration else 'N/A'}
- **测试时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}
### 📝 详情
> 详细测试结果请查看测试报告附件
"""
return self.send_markdown(title, text, is_at_all=is_at_all)
def __enter__(self):
"""上下文管理器入口
支持上下文管理器
- 支持with语句,自动管理资源
"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口,关闭会话"""
self.session.close()
# 保持向后兼容的函数接口
# - 提供兼容函数,同时建议使用新接口
def send_dd_msg(content_str: str, at_all: bool = True) -> str:
"""向钉钉机器人推送结果(向后兼容接口)
Args:
content_str: 发送的内容
at_all: @全员,默认为True
Returns:
响应文本
"""
logs.warning("send_dd_msg函数已弃用,建议使用DingTalkBot类")
try:
bot = DingTalkBot()
success = bot.send_text(content_str, is_at_all=at_all)
return json.dumps({"success": success})
except Exception as e:
logs.error(f"发送钉钉消息失败: {e}")
return json.dumps({"success": False, "error": str(e)})
dingTalkRobot
python
import urllib.parse
import requests
import time
import hmac
import hashlib
import base64
import json
from typing import Optional, Dict, Any, List, Union
from dataclasses import dataclass
from enum import Enum
from abc import ABC, abstractmethod
from common.recordlog import logs
from conf.operationConfig import OperationConfig
class MessageType(Enum):
TEXT = "text"
MARKDOWN = "markdown"
LINK = "link"
ACTION_CARD = "actionCard"
FEED_CARD = "feedCard"
# 【优化点2】使用数据类管理配置
# - 原因:原代码敏感信息硬编码,安全性差且不便管理
# - 优化:使用dataclass管理配置,支持从配置文件或环境变量读取
@dataclass
class DingTalkConfig:
"""钉钉机器人配置类
配置与代码分离
- 配置信息从外部读取,支持多环境配置
"""
webhook_url: str
access_token: str
secret: str
timeout: int = 10 # 【优化点4】超时时间可配置,默认10秒
max_retries: int = 3 # 【优化点5】添加重试次数配置
retry_delay: int = 2 # 【优化点6】重试延迟时间(秒)
@classmethod
def from_config(cls, config: Optional[OperationConfig] = None) -> 'DingTalkConfig':
"""从配置文件加载配置
工厂方法模式
- 提供工厂方法,支持多种配置来源
"""
config = config or OperationConfig()
try:
return cls(
webhook_url=config.get_section_for_data('DINGTALK', 'webhook_url'),
access_token=config.get_section_for_data('DINGTALK', 'access_token'),
secret=config.get_section_for_data('DINGTALK', 'secret'),
timeout=int(config.get_section_for_data('DINGTALK', 'timeout', '10')),
max_retries=int(config.get_section_for_data('DINGTALK', 'max_retries', '3')),
retry_delay=int(config.get_section_for_data('DINGTALK', 'retry_delay', '2'))
)
except Exception as e:
logs.error(f"加载钉钉配置失败: {e}")
# 【优化点8】配置加载失败时的降级处理
raise ValueError(f"钉钉机器人配置不完整或格式错误: {e}")
class SignatureGenerator:
"""签名生成器
单一职责原则
- 独立签名生成逻辑,提高代码可测试性和复用性
"""
@staticmethod
def generate(secret: str) -> tuple[str, str]:
"""生成钉钉机器人签名
Args:
secret: 钉钉机器人密钥
Returns:
(timestamp, sign): 时间戳和签名的元组
Raises:
ValueError: 当密钥为空时
"""
# 【优化点11】参数验证
if not secret:
raise ValueError("密钥不能为空")
# 【优化点12】使用更精确的时间戳生成方式
# - 原因:round可能导致精度损失
# - 优化:使用int直接截断,避免四舍五入
timestamp = str(int(time.time() * 1000))
# 【优化点13】使用f-string格式化,更简洁高效
str_to_sign = f'{timestamp}\n{secret}'
# 计算签名
secret_enc = secret.encode('utf-8')
str_to_sign_enc = str_to_sign.encode('utf-8')
hmac_code = hmac.new(secret_enc, str_to_sign_enc, digestmod=hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return timestamp, sign
# 【优化点14】消息构建器基类,支持多种消息类型
class MessageBuilder(ABC):
"""消息构建器抽象基类
策略模式
- 使用策略模式,便于扩展不同消息类型
"""
@abstractmethod
def build(self, **kwargs) -> Dict[str, Any]:
"""构建消息体"""
pass
class TextMessageBuilder(MessageBuilder):
"""文本消息构建器"""
def build(
self,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> Dict[str, Any]:
"""构建文本消息
更灵活的@功能
- 支持@指定手机号或用户ID
Args:
content: 消息内容
at_mobiles: 需要@的手机号列表
at_user_ids: 需要@的用户ID列表
is_at_all: 是否@所有人
Returns:
消息字典
"""
# 【优化点18】参数验证
if not content:
raise ValueError("消息内容不能为空")
# 【优化点19】消息长度限制检查
# 钉钉文本消息限制为2048个字符
if len(content) > 2048:
logs.warning(f"消息内容超过2048字符限制,将被截断: {len(content)}")
content = content[:2045] + "..."
message = {
"msgtype": MessageType.TEXT.value,
"text": {
"content": content
},
"at": {
"isAtAll": is_at_all
}
}
# 【优化点20】动态添加@列表,避免空列表
if at_mobiles:
message["at"]["atMobiles"] = at_mobiles
if at_user_ids:
message["at"]["atUserIds"] = at_user_ids
return message
class MarkdownMessageBuilder(MessageBuilder):
"""Markdown消息构建器 """
def build(
self,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> Dict[str, Any]:
"""构建Markdown消息"""
if not title or not text:
raise ValueError("标题和内容都不能为空")
message = {
"msgtype": MessageType.MARKDOWN.value,
"markdown": {
"title": title,
"text": text
},
"at": {
"isAtAll": is_at_all
}
}
if at_mobiles:
message["at"]["atMobiles"] = at_mobiles
if at_user_ids:
message["at"]["atUserIds"] = at_user_ids
return message
class DingTalkBot:
"""钉钉机器人客户端"""
def __init__(self, config: Optional[DingTalkConfig] = None):
"""初始化钉钉机器人
依赖注入
- 优化:支持外部注入配置,提高灵活性
"""
self.config = config or DingTalkConfig.from_config()
self.signature_generator = SignatureGenerator()
# 使用消息构建器注册表,便于扩展新的消息类型
self.message_builders: Dict[MessageType, MessageBuilder] = {
MessageType.TEXT: TextMessageBuilder(),
MessageType.MARKDOWN: MarkdownMessageBuilder(),
}
# 复用session,提高性能
self.session = requests.Session()
self.session.headers.update({'Content-Type': 'application/json;charset=utf-8'})
def _build_url(self) -> str:
"""构建完整的Webhook URL
URL构建逻辑独立
- 独立成方法,便于测试和维护
"""
timestamp, sign = self.signature_generator.generate(self.config.secret)
#使用URL参数字典,更安全可靠
params = {
'access_token': self.config.access_token,
'timestamp': timestamp,
'sign': sign
}
# 使用requests库的params参数自动处理URL编码
return f"{self.config.webhook_url}?{urllib.parse.urlencode(params)}"
def _send_with_retry(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""发送消息with重试机制
添加重试机制
- 添加指数退避重试,提高可靠性
"""
last_exception = None
for attempt in range(self.config.max_retries):
try:
# 每次重试重新生成签名,避免签名过期问题
url = self._build_url()
logs.info(f"发送钉钉消息,第{attempt + 1}次尝试...")
response = self.session.post(
url,
json=message,
timeout=self.config.timeout
)
# 状态码检查
response.raise_for_status()
# 解析响应并验证
result = response.json()
# 钉钉API返回码检查
# 钉钉成功返回 {"errcode": 0, "errmsg": "ok"}
if result.get('errcode') == 0:
logs.info(f"钉钉消息发送成功: {result}")
return result
else:
# 详细的错误信息
error_msg = f"钉钉API返回错误: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
logs.error(error_msg)
# 以下错误不应重试
# 例如:关键词不匹配(310000)、IP不在白名单(310000)等
non_retryable_codes = [310000, 400001, 400002]
if result.get('errcode') in non_retryable_codes:
raise ValueError(error_msg)
last_exception = Exception(error_msg)
except requests.exceptions.Timeout as e:
logs.warning(f"请求超时 (尝试 {attempt + 1}/{self.config.max_retries}): {e}")
last_exception = e
except requests.exceptions.RequestException as e:
logs.warning(f"网络请求失败 (尝试 {attempt + 1}/{self.config.max_retries}): {e}")
last_exception = e
except json.JSONDecodeError as e:
logs.error(f"响应解析失败: {e}")
last_exception = e
except ValueError:
# 不可重试的错误,直接抛出
raise
# 【优化点35】指数退避策略
if attempt < self.config.max_retries - 1:
delay = self.config.retry_delay * (2 ** attempt)
logs.info(f"等待{delay}秒后重试...")
time.sleep(delay)
# 所有重试都失败
raise Exception(f"发送钉钉消息失败,已重试{self.config.max_retries}次: {last_exception}")
def send_text(
self,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> bool:
"""发送文本消息
Args:
content: 消息内容
at_mobiles: 需要@的手机号列表
at_user_ids: 需要@的用户ID列表
is_at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
builder = self.message_builders[MessageType.TEXT]
message = builder.build(
content=content,
at_mobiles=at_mobiles,
at_user_ids=at_user_ids,
is_at_all=is_at_all
)
result = self._send_with_retry(message)
return result.get('errcode') == 0
except Exception as e:
logs.error(f"发送文本消息失败: {e}")
return False
def send_markdown(
self,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False
) -> bool:
"""发送Markdown消息 """
try:
builder = self.message_builders[MessageType.MARKDOWN]
message = builder.build(
title=title,
text=text,
at_mobiles=at_mobiles,
at_user_ids=at_user_ids,
is_at_all=is_at_all
)
result = self._send_with_retry(message)
return result.get('errcode') == 0
except Exception as e:
logs.error(f"发送Markdown消息失败: {e}")
return False
def send_test_report(
self,
total: int,
success: int,
failed: int,
error: int,
duration: Optional[float] = None,
is_at_all: bool = True
) -> bool:
"""发送测试报告"""
# 计算通过率
executed = success + failed + error
pass_rate = f"{(success / executed * 100):.2f}%" if executed > 0 else "N/A"
# 【优化点39】使用Markdown格式化测试报告
title = "接口测试报告"
# 【优化点40】根据测试结果使用不同的emoji
status_emoji = "✅" if failed == 0 and error == 0 else "❌"
text = f"""## {status_emoji} 接口自动化测试报告
### 📊 测试概况
- **总用例数**: {total}
- **执行用例数**: {executed}
- **通过数**: {success} ✅
- **失败数**: {failed} ❌
- **错误数**: {error} ⚠️
### 📈 执行统计
- **通过率**: {pass_rate}
- **执行时间**: {f'{duration:.2f}秒' if duration else 'N/A'}
- **测试时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}
### 📝 详情
> 详细测试结果请查看测试报告附件
"""
return self.send_markdown(title, text, is_at_all=is_at_all)
def __enter__(self):
"""上下文管理器入口
支持上下文管理器
- 支持with语句,自动管理资源
"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口,关闭会话"""
self.session.close()
# 保持向后兼容的函数接口
# - 提供兼容函数,同时建议使用新接口
def send_dd_msg(content_str: str, at_all: bool = True) -> str:
"""向钉钉机器人推送结果(向后兼容接口)
Args:
content_str: 发送的内容
at_all: @全员,默认为True
Returns:
响应文本
"""
logs.warning("send_dd_msg函数已弃用,建议使用DingTalkBot类")
try:
bot = DingTalkBot()
success = bot.send_text(content_str, is_at_all=at_all)
return json.dumps({"success": success})
except Exception as e:
logs.error(f"发送钉钉消息失败: {e}")
return json.dumps({"success": False, "error": str(e)})
sendEmail
python
import smtplib
import os
from pathlib import Path
from typing import List, Optional, Dict, Any
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from contextlib import contextmanager
import re
from conf.operationConfig import OperationConfig
from common.recordlog import logs
"""
sendEmail:往邮箱发送测试结果
Author: tsukiyomi
"""
class EmailConfig:
"""邮件配置管理类"""
def __init__(self, config: Optional[OperationConfig] = None):
self.config = config or OperationConfig()
self._cache: Dict[str, Any] = {}
def get_email_config(self, key: str) -> str:
"""获取邮件配置,带缓存"""
if key not in self._cache:
self._cache[key] = self.config.get_section_for_data('EMAIL', key)
return self._cache[key]
@property
def host(self) -> str:
return self.get_email_config('host')
@property
def user(self) -> str:
return self.get_email_config('user')
@property
def passwd(self) -> str:
return self.get_email_config('passwd')
@property
def addressee(self) -> List[str]:
return self.get_email_config('addressee').split(';')
@property
def subject(self) -> str:
return self.get_email_config('subject')
class EmailBuilder:
"""邮件构建器"""
@staticmethod
def format_email_address(email: str, display_name: Optional[str] = None) -> str:
"""格式化邮件地址
Args:
email: 邮件地址
display_name: 显示名称
Returns:
格式化后的邮件地址
"""
email = email.strip()
if not display_name:
# 尝试从邮件地址提取用户名作为显示名
match = re.match(r'^([^@]+)@', email)
display_name = match.group(1) if match else email
return f'{display_name} <{email}>'
@staticmethod
def build_message(
subject: str,
content: str,
sender: str,
recipients: List[str],
attachments: Optional[List[Dict[str, Any]]] = None
) -> MIMEMultipart:
"""构建邮件消息
Args:
subject: 邮件主题
content: 邮件正文
sender: 发件人
recipients: 收件人列表
attachments: 附件列表,每个附件是包含'path'和'filename'的字典
Returns:
构建好的邮件消息
"""
message = MIMEMultipart()
message['Subject'] = subject
message['From'] = sender
message['To'] = ';'.join([EmailBuilder.format_email_address(r) for r in recipients])
# 添加正文
text = MIMEText(content, _subtype='plain', _charset='utf-8')
message.attach(text)
# 添加附件
if attachments:
for attachment in attachments:
EmailBuilder._attach_file(message, attachment)
return message
@staticmethod
def _attach_file(message: MIMEMultipart, attachment: Dict[str, Any]) -> None:
"""添加附件到邮件
Args:
message: 邮件消息对象
attachment: 附件信息字典
"""
file_path = attachment.get('path')
if not file_path or not os.path.exists(file_path):
logs.warning(f"附件文件不存在: {file_path}")
return
filename = attachment.get('filename', Path(file_path).name)
try:
with open(file_path, 'rb') as f:
atta = MIMEApplication(f.read())
atta['Content-Type'] = 'application/octet-stream'
atta['Content-Disposition'] = f'attachment; filename="{filename}"'
message.attach(atta)
logs.info(f"成功添加附件: {filename}")
except Exception as e:
logs.error(f"添加附件失败 {file_path}: {e}")
class EmailSender:
"""邮件发送器"""
def __init__(self, config: Optional[EmailConfig] = None):
self.config = config or EmailConfig()
@contextmanager
def _smtp_connection(self):
"""SMTP连接上下文管理器"""
service = None
try:
logs.info(f"连接SMTP服务器: {self.config.host}")
service = smtplib.SMTP_SSL(self.config.host)
logs.info(f"登录SMTP账户: {self.config.user}")
service.login(self.config.user, self.config.passwd)
yield service
except smtplib.SMTPConnectError as e:
logs.error(f'邮箱服务器连接失败: {e}')
raise
except smtplib.SMTPAuthenticationError as e:
logs.error(f'邮箱服务器认证错误,请检查POP3/SMTP服务是否开启,密码是否为授权码: {e}')
raise
except smtplib.SMTPException as e:
logs.error(f'SMTP错误: {e}')
raise
finally:
if service:
try:
service.quit()
logs.info('SMTP连接已关闭')
except Exception:
pass
def send(
self,
subject: str,
content: str,
recipients: Optional[List[str]] = None,
attachments: Optional[List[Dict[str, Any]]] = None
) -> bool:
"""发送邮件
Args:
subject: 邮件主题
content: 邮件正文
recipients: 收件人列表
attachments: 附件列表
Returns:
发送是否成功
"""
try:
# 准备发件人和收件人
sender = EmailBuilder.format_email_address(
self.config.user,
'Liaison Officer'
)
recipients = recipients or self.config.addressee
# 构建邮件
message = EmailBuilder.build_message(
subject=subject,
content=content,
sender=sender,
recipients=recipients,
attachments=attachments
)
# 发送邮件
with self._smtp_connection() as service:
logs.info(f"发送邮件到: {recipients}")
service.sendmail(self.config.user, recipients, message.as_string())
logs.info('邮件发送成功!')
return True
except smtplib.SMTPSenderRefused as e:
logs.error(f'发件人地址未经验证: {e}')
except smtplib.SMTPDataError as e:
logs.error(f'邮件内容被拒绝(可能被识别为垃圾邮件): {e}')
except Exception as e:
logs.exception(f'邮件发送失败: {e}')
return False
class TestReportEmailSender:
"""测试报告邮件发送器"""
def __init__(self, config: Optional[EmailConfig] = None):
self.config = config or EmailConfig()
self.sender = EmailSender(config)
@staticmethod
def calculate_statistics(
success: List,
failed: List,
error: List,
not_running: List
) -> Dict[str, Any]:
"""计算测试统计数据
Args:
success: 成功用例列表
failed: 失败用例列表
error: 错误用例列表
not_running: 未执行用例列表
Returns:
统计数据字典
"""
success_num = len(success)
fail_num = len(failed)
error_num = len(error)
notrun_num = len(not_running)
total = success_num + fail_num + error_num + notrun_num
executed = success_num + fail_num + error_num
# 避免除零错误
if executed > 0:
pass_rate = f"{success_num / executed * 100:.2f}%"
fail_rate = f"{fail_num / executed * 100:.2f}%"
error_rate = f"{error_num / executed * 100:.2f}%"
else:
pass_rate = fail_rate = error_rate = "N/A"
return {
'total': total,
'success_num': success_num,
'fail_num': fail_num,
'error_num': error_num,
'notrun_num': notrun_num,
'executed': executed,
'pass_rate': pass_rate,
'fail_rate': fail_rate,
'error_rate': error_rate
}
@staticmethod
def format_report_content(stats: Dict[str, Any], project_name: str = "***项目") -> str:
"""格式化测试报告内容
Args:
stats: 统计数据
project_name: 项目名称
Returns:
格式化的报告内容
"""
if stats['executed'] == 0:
return f"{project_name}接口测试:未执行任何测试用例。"
template = (
f"{project_name}接口测试报告\n\n"
f"测试概况:\n"
f"- 总用例数:{stats['total']}\n"
f"- 执行用例数:{stats['executed']}\n"
f"- 通过:{stats['success_num']}\n"
f"- 失败:{stats['fail_num']}\n"
f"- 错误:{stats['error_num']}\n"
f"- 未执行:{stats['notrun_num']}\n\n"
f"执行率统计:\n"
f"- 通过率:{stats['pass_rate']}\n"
f"- 失败率:{stats['fail_rate']}\n"
f"- 错误率:{stats['error_rate']}\n\n"
f"详细测试结果请参见附件。"
)
return template
def send_test_report(
self,
success: List,
failed: List,
error: List,
not_running: List,
report_file: Optional[str] = None,
project_name: str = "***项目",
subject: Optional[str] = None,
recipients: Optional[List[str]] = None
) -> bool:
"""发送测试报告邮件
Args:
success: 成功用例列表
failed: 失败用例列表
error: 错误用例列表
not_running: 未执行用例列表
report_file: 报告文件路径
project_name: 项目名称
subject: 邮件主题(可选)
recipients: 收件人列表(可选)
Returns:
发送是否成功
"""
# 计算统计数据
stats = self.calculate_statistics(success, failed, error, not_running)
# 准备邮件内容
content = self.format_report_content(stats, project_name)
subject = subject or self.config.subject
# 准备附件
attachments = None
if report_file and os.path.exists(report_file):
attachments = [{
'path': report_file,
'filename': f'test_report_{Path(report_file).name}'
}]
# 发送邮件
return self.sender.send(
subject=subject,
content=content,
recipients=recipients,
attachments=attachments
)
运行结果
dingtalk Robot
个人QQ邮箱
Notice:邮箱和钉钉机器人配置由OperateConfig从config.ini获取