测试驱动文档在后端 API 开发中的实践
文章介绍了一种通过单元测试生成API文档的方法,解决了传统手写文档不规范、维护困难的问题。该方法利用序列化器(serializer)生成数据格式描述,并从测试用例中提取请求和响应示例,最终自动生成结构清晰的API文档。 2017-7-23 12:39:7 Author: strcpy.me(查看原文) 阅读量:0 收藏

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

现状

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

  • 框架: Django
  • 给前端提供的都是 JSON API,没有后端渲染的网页
  • 所有的 API 都继承 APIView,但是并不是 Django REST framework(以下简称 DRF,名字太长了) 中的 APIView,这个后面会说原因
  • 使用 DRF 中的部分 serializer 来做数据格式验证和 QuerySet 转换为 Python 字典列表等类型的工作

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

  • 登录验证,权限管理,API 版本号管理,限流、自动翻页等等,这些我们更侧重独立和手动的处理。
  • Generic Views 一直是一个让我感到疑惑的东西,看似写起来简单,代码量很少,像是填充一些预定义的变量和方法,简单的增删查改会方便一点,但是在实际复杂的业务场景下,可能导致问题复杂化,并没有显式的写出操作过程更清晰。

所以我们仿照 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)))

现有的文档存在什么问题

  • 没有文档,靠"口口相传"和"心有灵犀"。
  • 手写 API 文档,不同人写出来的可能格式风格略有差异,也不容易统一管理。更重要的是人都是懒的,没有监督的情况下,文档能不写就不写,而测试还是要必须要写的。
  • 一处修改很多 API 可能都会变,比如某一个 Model 修改了字段,很多 API 的返回值都可能受到影响,手动的逐个修改并不科学。
  • 已有的文档生成工具自定义程度不高,比如 Django REST Swagger 要求 Generic Views 就放弃了。
  • 还有些文档生成工具是在注释中使用特定的格式描述 API 字段和细节的,其实和手写文档没有本质上的差异。
  • 上面几条总结一下就是:手写的文档很难自动化验证是否正确,测试生成文档可以保证和测试是一致的。
  • 虽然"代码即文档",但是也只适用于非 API 文档,比如数据库设计、架构设计、算法设计等,因为代码并不一定适合传播给所有人看,尤其是 API 文档一般都是对外的,而且代码中的注释经常是分散的,文档上一个 API 的说明、请求、响应等几部分数据可能分散在几个文件中。

我们的实践

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

  • 使用 serializer 生成数据格式描述性文档
  • 使用部分测试充当 API 样例数据
  • CI 的时候,识别 commit 信息中的doc deploy 后生成和自动部署文本版文档和 Postman 导出格式

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 或者子网形式字符串;

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

  • 一个 serializer 的所有字段可以在 serializer.fields.items() 中得到,只要遍历一下所有的字段就不难再针对性的处理。
  • 字段的类型很容易推断,is_instance(field, serializers.IntegerField) 等逐个的比较就可以知道。
  • 几乎所有的字段都支持 requirednull 参数,代表是否允许不传递该字段和是否允许该字段的值为 null。对于字符串类型和数据类型的字段等,还支持 max_length / max_valuemin_length / min_value 参数,代表数据的范围,其他的个别格式限制可以参考下 DRF 的源码。
  • 有的字段需要额外说明才方便理解,或者有些字段是互斥的,不可以同时传递,所以我们还是要支持在代码中自我描述的功能,这些说明有两处来源,一个是 serializer 的 doc string,另一个是 field 的 help_text 属性。

在单元测试的时候,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

有几点是要注意的

  • 测试用例存在嵌套关系的时候,比如 test_edit_acl_rule 中,我们只关心本测试中发送的请求,而不关心调用的 test_create_acl_rule 中发送的请求,所以 Client 需要根据代码调用栈来判断自己的位置。

    请求是在当前正在执行的 test_create_acl_rule 中发出的,那么函数栈是

    • self._request(0)
    • self.post…(1)
    • test_create_acl_rule(2)

    test_edit_acl_rule 中调用了 test_create_acl_rule 时,self.test_method_name == "test_edit",而函数栈是

    • self._request(0)
    • self.post…(1)
    • test_create_acl_rule(2)
    • test_edit_acl_rule(3)
  • 不是所有的 API 都是可 JSON 的,比如上传或者下载文件的请求,生成文档的时候需要特例处理下。可以写一个自定义的 JSON Encoder 来实现。

    1 class MultipartToJsonLikeEncoder(json.JSONEncoder):
    2     def default(self, o):
    3         if isinstance(o, io.BytesIO) or isinstance(o, io.StringIO):
    4             return "<文件上传 💾 >"
    5         return json.JSONEncoder.default(self, o)
    
  • 生成文档的时候要排序,将响应正确的排在前面,响应错误的排在后面。

总结

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

  • 为了生成文档,至少会写一个简化版测试,总比没有测试要好
  • 一个测试只干一件事情,否则生成的文档会有重复
  • 测试中会写注释标明测试的用途

文章来源: https://strcpy.me/index.php/archives/772/
如有侵权请联系:admin#unsafe.sh