virusdefender's blog ʕ•ᴥ•ʔ

测试驱动文档在后端 API 开发中的实践

很多人了解过 Python 的 doctest,是从注释中写测试,我们现在反向思维,从测试生成文档。

现状

在开头有必要说明一下现在后端 API 的开发模式,这样才能更好的理解遇到的问题。

DRF 库提供了很多我们并不会用到的功能,比如

所以我们仿照 DRF 的 APIView,继承 Django 的 View,自己写了一个新的 APIView,包含了核心功能,解析 JSON,同时增加了部分常用方法,比如 validate_serializerself.successself.errorself.paginate 等等。

下面是一段伪代码

 1class UserProfileAPI(APIView):
 2    @validate_serializer(ChangeUserProfileSeralizer)
 3    def put(self):
 4        ....
 5        if err:
 6            return self.error("保存失败")
 7        return self.success(UserProfileSerailzier(user_profile).data)
 8
 9class ProblemAPI(APIView):
10    def get(self):
11        return self.success(self.paginate(request, Problem.objects.all(), ProblemSerializer)))

现有的文档存在什么问题

我们的实践

要改进上面的问题,基本原则是尽量少改动已有的代码,所以经过和 @reverland 的一番讨论,确定使用下面的方法:

serializer 的文档

由一个 serializer 生成对应的描述性文档相对是比较简单的,一个典型的 serializer 是这样的

 1class IPOrSubnetField(serializers.CharField):
 2    def __init__(self, **kwargs):
 3        super().__init__(**kwargs)
 4        if not kwargs.get("help_text"):
 5            self.help_text = "IPv4 的 IP 或者子网形式字符串"
 6
 7    def to_internal_value(self, data):
 8        pass
 9
10class CreateRuleSerializer(serializers.Serializer):
11    """
12    一条规则可以封禁也可以限制频率,封禁的时候,不需要传递 e 和 f 字段。
13    """
14    a = serializers.IntegerField(allow_null=True, required=False)
15    b = serializers.CharField(allow_null=True, required=False)
16    d = serializers.CharField(allow_null=True, required=False)
17    d = serializers.ChoiceField(choices=[RuleAction.forbid, RuleAction.limit_rate])
18    e = serializers.IntegerField(required=False, allow_null=True, min_value=1)
19    f = serializers.IntegerField(required=False, allow_null=True, min_value=1)
20    g = serializers.CharField(max_length=128, allow_blank=True, required=False)
21    h = serializers.ListField(child=IPOrSubnetField())

下面是我们生成的表格文档

数据格式

一条规则可以封禁也可以限制频率,封禁的时候,不需要传递 e 和 f 字段。

字段名数据类型是否必填/默认值NULL其他
a整型数字非必填/无默认值True-
b字符串非必填/无默认值True-
c字符串非必填/无默认值True-
d指定选项必填False选项是: [‘forbid’, ’limit_rate’]
e整型数字非必填/无默认值True最小值: 1;
f整型数字非必填/无默认值True最小值: 1;
g字符串非必填/无默认值False最大长度: 128; 最小长度: 0;
h列表必填False详见下方表格

h

字段名数据类型是否必填/默认值NULL其他
子字段字符串必填FalseIPv4 的 IP 或者子网形式字符串;

这个表格包含了字段名、数据类型、数据格式、字段额外说明等几部分信息。

在单元测试的时候,Client 会传递一个特殊的 HTTP 头,这样 @validate_serializer 就知道是否要生成 serializer 的文档了。

API数据的文档

一个 API 仅仅有数据格式的要求是不够的,最好还能够提供一些常见的正确和错误使用的例子,这样也可以帮助用户去更好的理解 API 的用途,单元测试的测试用例就是这些示例最好的来源。

一个典型的单元测试是这样子的

 1class ACLAPITest(APITestCase):
 2    @document
 3    def test_create_acl_rule(self):
 4        """
 5        创建 acl 规则,只有 cidr
 6        """
 7        resp = self.client.post(self.url, data=self.base_rule)
 8        self.assertSuccess(resp)
 9        ...        
10        return resp
11    
12    @document
13    def test_edit_acl_rule(self):
14        """
15        编辑 acl 规则
16        """
17        rule_id = self.test_create_acl_rule_ip().data["data"]["id"]
18        ...
19        resp = self.client.put(self.url, data=new_rule)
20        self.assertSuccess(resp)
21        ...

这里测试创建和编辑 ACL 规则。@document 是标记这个测试用例要生成文档。我们通过修改 Client 的属性来实现。

 1def document(method):
 2    @functools.wraps(method)
 3    def handle(*args, **kwargs):
 4        if args[0]._testMethodName == method.__name__:
 5            args[0].client.test_method_name = args[0]._testMethodName
 6            args[0].client.doc = method.__doc__
 7            args[0].client.running_module = method.__module__.split(".")[0]
 8        ret = method(*args, **kwargs)
 9        return ret
10    return handle

要注意的是,只有修饰在当前正在执行的测试上,才会去更新这些属性,否则运行 test_edit_acl_rule 的时候,test_create_acl_rule 会把 Client 的属性改错。

测试中的 Client 就是一个生成 HTTP 请求,然后模拟发送请求的组件,要想记录下请求和响应的内容,替换掉 DRF 原生 Client 是必须的,当然这个也不难,只要继承原来的 Client,重载相关方法,记录请求数据,然后调用父类的方法,再记录响应数据就可以了。

 1class DocumentAPIClient(APIClient):
 2    test_method_name = ""
 3    doc = ""
 4    running_module = ""
 5    
 6    def _request(self, method, *args, **kwargs):
 7        make_doc = self.test_method_name == inspect.stack()[2].function
 8        if make_doc:
 9            kwargs["serializer_gen_doc"] = True
10        # kwargs 中的额外参数,在 view 中 request.META 中可以取到,类似额外的 HTTP 头
11        resp = getattr(super(), method)(*args, **kwargs)
12
13        if make_doc:
14            # 记录 API 请求和响应
15            pass
16
17class APITestCase(TestCase):
18    client_class = DocumentAPIClient

有几点是要注意的

总结

解决了已有的问题,而且鼓励开发者认真的去写更规范的测试

提交评论 | 微信打赏 | 转载必须注明原文链接

#Django