1. 项目背景与核心挑战
在企业级测试体系中,多租户场景下的自动化测试一直是个棘手问题。想象一下,你负责的测试框架需要同时支持A、B、C三个租户的API测试,每个租户都有独立的认证体系和测试数据。更麻烦的是:
- 本地开发时可能需要单独测试某个租户
- CI流水线需要全量执行所有租户用例
- 为了提升效率还要支持pytest-xdist分布式执行
最要命的是——当多个worker同时跑不同租户的测试时,token管理一旦出问题,就会导致:
- 租户A的测试误用了租户B的token
- 并发登录导致token被异常刷新
- 测试结果互相污染
我在金融级SaaS产品的测试中,就曾因为token串用导致错误测试数据写入生产环境(幸亏是预发环境)。这次分享的解决方案,正是从那次事故中沉淀出来的实战经验。
2. 多租户Token管理方案
2.1 线程安全的Token缓存设计
先看核心的TokenManager实现:
python复制from threading import Lock
from common.auth import login
class TokenManager:
_cache = {} # 类变量存储所有租户token
_lock = Lock() # 线程安全锁
@classmethod
def get_token(cls, tenant_key):
with cls._lock: # 保证线程安全
if tenant_key not in cls._cache:
tenant_id, token = login(tenant_key)
cls._cache[tenant_key] = (tenant_id, token)
return cls._cache[tenant_key]
@classmethod
def refresh_token(cls, tenant_key):
with cls._lock:
tenant_id, token = login(tenant_key)
cls._cache[tenant_key] = (tenant_id, token)
return tenant_id, token
关键设计点:
- 双重缓存校验:通过
tenant_key隔离不同租户的token,避免串用 - 线程锁保护:
Lock()确保多线程并发时的数据安全 - 惰性加载:首次请求时才获取token,避免不必要的登录开销
踩坑提醒:曾经尝试用
@property实现缓存,但在多线程下会出现竞态条件。实测证明显式锁是最可靠的方案。
2.2 Token刷新策略优化
当token过期时需要刷新,但直接调用refresh_token可能导致:
- 高频并发的重复刷新
- 刷新期间其他请求拿到过期token
改进方案:
python复制def get_token_with_retry(cls, tenant_key, max_retry=3):
for _ in range(max_retry):
tenant_id, token = cls.get_token(tenant_key)
if validate_token(token): # 需要实现token校验逻辑
return tenant_id, token
cls.refresh_token(tenant_key)
raise Exception(f"Failed to get valid token for {tenant_key}")
3. pytest多租户执行体系
3.1 命令行参数解析
在conftest.py中添加租户参数支持:
python复制def pytest_addoption(parser):
parser.addoption(
"--tenant",
action="store",
default="",
help="指定租户,如 --tenant=A,B"
)
这样可以通过以下方式指定租户:
bash复制pytest --tenant=A # 只测试A租户
pytest --tenant=A,B # 测试A和B租户
pytest # 测试所有租户
3.2 租户列表动态生成
python复制@pytest.fixture(scope="session")
def tenants(request):
tenant_opt = request.config.getoption("--tenant")
if tenant_opt:
tenant_list = tenant_opt.split(",")
else:
from common.config import TENANTS # 从配置加载全量租户
tenant_list = list(TENANTS.keys())
return tenant_list
3.3 动态参数化测试用例
关键实现:
python复制@pytest.fixture
def client(request):
tenant_key = request.param # 从参数化获取当前租户
return UserClient(tenant_key)
def pytest_generate_tests(metafunc):
if "client" in metafunc.fixturenames:
tenants = metafunc.config._tenant_list
metafunc.parametrize("client", tenants, indirect=True)
这个设计使得测试用例完全不需要感知多租户:
python复制def test_user_list(client): # 会自动按租户数量执行多次
result = client.list_users()
assert "data" in result
4. 并发执行深度适配
4.1 pytest-xdist集成
安装插件:
bash复制pip install pytest-xdist
执行命令:
bash复制pytest -n 4 --tenant=A,B # 使用4个worker并发执行A、B租户
4.2 解决并发下的token同步问题
在pytest_configure中初始化租户列表:
python复制def pytest_configure(config):
tenant_opt = config.getoption("--tenant")
if tenant_opt:
config._tenant_list = tenant_opt.split(",")
else:
from common.config import TENANTS
config._tenant_list = list(TENANTS.keys())
这样每个worker都会:
- 独立维护自己的token缓存
- 按分配到的测试用例自动获取对应租户token
- 避免跨worker的token污染
5. 实战问题排查指南
5.1 常见问题与解决方案
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 报错"tenant_key not found" | 1. 拼写错误 2. 未在TENANTS中配置 |
1. 检查--tenant参数 2. 确认common/config.py配置 |
| Token过期导致断言失败 | 默认token有效期过短 | 1. 增加token有效期 2. 添加自动刷新机制 |
| 并发时出现偶发失败 | 资源竞争或速率限制 | 1. 添加请求间隔 2. 检查服务端限流策略 |
5.2 性能优化建议
-
token预加载:在pytest_sessionstart时批量获取所有租户token
python复制@pytest.fixture(scope="session", autouse=True) def preload_tokens(tenants): for tenant in tenants: TokenManager.get_token(tenant) -
分布式缓存:使用redis替代内存缓存,适合跨机器的分布式执行
python复制class RedisTokenManager: def __init__(self, redis_conn): self.redis = redis_conn def get_token(self, tenant_key): if not self.redis.hexists("tokens", tenant_key): self._refresh_token(tenant_key) return self.redis.hget("tokens", tenant_key) -
请求批处理:对list_users这类查询接口,可以改为批量获取后本地过滤
6. 扩展应用场景
这套方案不仅适用于API测试,还可应用于:
-
多环境测试:将tenant理解为env(dev/staging/prod)
bash复制
pytest --tenant=staging -
多用户角色测试:不同角色对应不同tenant
python复制TENANTS = { "admin": {"role": "admin"}, "user": {"role": "member"} } -
数据驱动测试:与pytest的parametrize结合
python复制@pytest.mark.parametrize("tenant,expected", [ ("A", 200), ("B", 403) # B租户应无权限 ]) def test_permission(client, expected): assert client.get_resource().status_code == expected
在电商平台测试中,我们曾用这套架构同时支持20+商户的并行测试,将原本需要4小时的测试套件缩短到30分钟内完成。关键在于确保每个测试用例的完全隔离性——包括网络请求、数据验证和清理逻辑。