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

现状

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

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

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

下面是一段伪代码

class UserProfileAPI(APIView):
    @validate_serializer(ChangeUserProfileSeralizer)
    def put(self):
        ....
        if err:
            return self.error("保存失败")
        return self.success(UserProfileSerailzier(user_profile).data)

class ProblemAPI(APIView):
    def get(self):
        return self.success(self.paginate(request, Problem.objects.all(), ProblemSerializer)))

现有的文档存在什么问题

我们的实践

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

serializer 的文档

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

class IPOrSubnetField(serializers.CharField):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not kwargs.get("help_text"):
            self.help_text = "IPv4 的 IP 或者子网形式字符串"

    def to_internal_value(self, data):
        pass

class CreateRuleSerializer(serializers.Serializer):
    """
    一条规则可以封禁也可以限制频率,封禁的时候,不需要传递 e 和 f 字段。
    """
    a = serializers.IntegerField(allow_null=True, required=False)
    b = serializers.CharField(allow_null=True, required=False)
    d = serializers.CharField(allow_null=True, required=False)
    d = serializers.ChoiceField(choices=[RuleAction.forbid, RuleAction.limit_rate])
    e = serializers.IntegerField(required=False, allow_null=True, min_value=1)
    f = serializers.IntegerField(required=False, allow_null=True, min_value=1)
    g = serializers.CharField(max_length=128, allow_blank=True, required=False)
    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 其他
子字段 字符串 必填 False IPv4 的 IP 或者子网形式字符串;

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

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

API数据的文档

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

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

class ACLAPITest(APITestCase):
    @document
    def test_create_acl_rule(self):
        """
        创建 acl 规则,只有 cidr
        """
        resp = self.client.post(self.url, data=self.base_rule)
        self.assertSuccess(resp)
        ...        
        return resp
    
    @document
    def test_edit_acl_rule(self):
        """
        编辑 acl 规则
        """
        rule_id = self.test_create_acl_rule_ip().data["data"]["id"]
        ...
        resp = self.client.put(self.url, data=new_rule)
        self.assertSuccess(resp)
        ...

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

def document(method):
    @functools.wraps(method)
    def handle(*args, **kwargs):
        if args[0]._testMethodName == method.__name__:
            args[0].client.test_method_name = args[0]._testMethodName
            args[0].client.doc = method.__doc__
            args[0].client.running_module = method.__module__.split(".")[0]
        ret = method(*args, **kwargs)
        return ret
    return handle

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

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

class DocumentAPIClient(APIClient):
    test_method_name = ""
    doc = ""
    running_module = ""
    
    def _request(self, method, *args, **kwargs):
        make_doc = self.test_method_name == inspect.stack()[2].function
        if make_doc:
            kwargs["serializer_gen_doc"] = True
        # kwargs 中的额外参数,在 view 中 request.META 中可以取到,类似额外的 HTTP 头
        resp = getattr(super(), method)(*args, **kwargs)

        if make_doc:
            # 记录 API 请求和响应
            pass

class APITestCase(TestCase):
    client_class = DocumentAPIClient

有几点是要注意的

总结

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