如何对需要调用外部服务API的Python类进行单元测试?
这个类用于封装一个远程托管的大语言模型,需要调用服务API来获取结果,示例代码如下:
from typing import Iterator, List from anthropic import AnthropicVertex from your_abstract_class import AbstractLLMInterface from your_types import Correspondence import llm_logger from your_config import CONFIGS, get_gcp_project_id class ModelWrapper(AbstractLLMInterface): """The Claude 3 Sonnet model wrapper following the interface.""" def __init__( self, region: str = CONFIGS["GCP"]["REGION"], project: str = get_gcp_project_id(), model: str = CONFIGS["MODELS"]["SONNET_ID"], ) -> None: """Set up the claude client using the region and project.""" self.client: AnthropicVertex = AnthropicVertex(region=region, project_id=project) self.model_name: str = model self.role_key: str = "role" self.content_key: str = "content" self.user_key: str = "user" llm_logger.debug(msg=f"Initialised sonnet client for {region}, {project} and {self.model_name}.") def get_completion(self, user_prompt: str, system_prompt: str, history: List[Correspondence]) -> Iterator[str]: """ Fetch a response from the model.This requires an egress request to GCP and the service for Anthropic model must be enabled in the VertexAI console. """ # This is where the API call to GCP service happens return self.client.messages.stream(user_prompt, system_prompt, history)
我知道Python单元测试里的MagicMocks,可以配置成返回任意内容。但在这个例子里,构造函数的参数都是简单字符串,而且client是在类内部创建的,看起来好像没有地方可以注入模拟客户端,对吗?那是不是意味着这类单元测试必须发起真实的API调用?还是说这个类的设计本身有问题?希望能得到帮助,这个类应该可以在不调用API的情况下被测试。
注意:我知道如果修改类的构造函数,让它接受一个
AnthropicVertex对象的话,模拟就会变得非常简单。但我的问题是要测试当前这个已有的类,而不是修改它。不过,如果当前类的设计违反了任何基础设计原则(比如SOLID),我也很想了解清楚,相关的参考说明也很欢迎。
先说说设计上的问题
首先得明确:这个类的设计确实违反了SOLID中的依赖倒置原则——它作为高层的业务封装模块(ModelWrapper),直接依赖了具体的底层实现(AnthropicVertex客户端),而不是依赖抽象接口。这种紧耦合的设计直接导致了测试困难,因为我们没办法轻松替换掉真实的客户端实例。
不修改现有类的前提下,怎么实现无API调用测试?
如果不能改动现有类的代码,我们可以用**猴子补丁(Monkey-Patching)**的方式,借助Python标准库unittest.mock里的patch工具,替换掉真实的客户端类,从而规避真实的API请求。
给你举个具体的测试代码示例:
from unittest.mock import patch, MagicMock from typing import List from your_module import ModelWrapper, Correspondence def test_model_wrapper_completion(): # 预设模拟的流式返回结果 mock_stream_response = iter(["这是模拟的模型返回内容"]) # 用patch替换掉ModelWrapper里用到的AnthropicVertex类 with patch("your_module.AnthropicVertex") as mock_anthropic_cls: # 创建模拟的客户端实例,配置它的stream方法返回预设内容 mock_client = MagicMock() mock_client.messages.stream.return_value = mock_stream_response # 让模拟的AnthropicVertex类返回这个模拟实例 mock_anthropic_cls.return_value = mock_client # 初始化ModelWrapper,此时它的client属性是我们的模拟对象 wrapper = ModelWrapper(region="test-region", project="test-project", model="test-model") # 调用要测试的get_completion方法 result = list(wrapper.get_completion( user_prompt="你好", system_prompt="你是一个助手", history=[] )) # 断言结果符合预期 assert result == ["这是模拟的模型返回内容"] # 验证API方法是否被正确调用(参数是否符合预期) mock_client.messages.stream.assert_called_once_with( "你好", "你是一个助手", [] )
这个方法的核心逻辑是:patch("your_module.AnthropicVertex")会在测试运行的上下文里,把当前模块中引用的AnthropicVertex替换成一个MagicMock类。当ModelWrapper的构造函数创建AnthropicVertex实例时,实际上得到的是我们预先配置好的模拟客户端,后续调用messages.stream时就会返回我们预设的模拟数据,完全不会触发真实的API请求。
补充:如果后续可以重构代码,怎么优化设计?
如果之后有机会重构这个类,建议遵循依赖倒置原则,让ModelWrapper依赖一个抽象的客户端接口,而不是具体的AnthropicVertex。比如先定义一个抽象基类:
from abc import ABC, abstractmethod from typing import Iterator, List from your_types import Correspondence class AbstractLLMClient(ABC): @abstractmethod def stream_completion(self, user_prompt: str, system_prompt: str, history: List[Correspondence]) -> Iterator[str]: pass
然后让AnthropicVertex的适配类实现这个接口,再把ModelWrapper的构造函数改成接受这个抽象接口的实例:
class ModelWrapper(AbstractLLMInterface): def __init__(self, client: AbstractLLMClient, model_name: str): self.client = client self.model_name = model_name # 其他初始化逻辑... def get_completion(self, user_prompt: str, system_prompt: str, history: List[Correspondence]) -> Iterator[str]: return self.client.stream_completion(user_prompt, system_prompt, history)
这样测试的时候,只需要创建一个实现了AbstractLLMClient的模拟类(或者直接用MagicMock),传入ModelWrapper即可,完全不需要猴子补丁,代码的扩展性也会好很多——后续要替换成其他LLM的客户端,只需要实现同一个抽象接口就行。
备注:内容来源于stack exchange,提问作者dell

