很多人了解过 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_serializer
、self.success
、self.error
和 self.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)))
现有的文档存在什么问题
- 没有文档,靠”口口相传”和”心有灵犀”。
- 手写 API 文档,不同人写出来的可能格式风格略有差异,也不容易统一管理。更重要的是人都是懒的,没有监督的情况下,文档能不写就不写,而测试还是要必须要写的。
- 一处修改很多 API 可能都会变,比如某一个 Model 修改了字段,很多 API 的返回值都可能受到影响,手动的逐个修改并不科学。
- 已有的文档生成工具自定义程度不高,比如 Django REST Swagger 要求
Generic Views
就放弃了。 - 还有些文档生成工具是在注释中使用特定的格式描述 API 字段和细节的,其实和手写文档没有本质上的差异。
- 上面几条总结一下就是:手写的文档很难自动化验证是否正确,测试生成文档可以保证和测试是一致的。
- 虽然”代码即文档”,但是也只适用于非 API 文档,比如数据库设计、架构设计、算法设计等,因为代码并不一定适合传播给所有人看,尤其是 API 文档一般都是对外的,而且代码中的注释经常是分散的,文档上一个 API 的说明、请求、响应等几部分数据可能分散在几个文件中。
我们的实践
要改进上面的问题,基本原则是尽量少改动已有的代码,所以经过和 @reverland 的一番讨论,确定使用下面的方法:
- 使用 serializer 生成数据格式描述性文档
- 使用部分测试充当 API 样例数据
- CI 的时候,识别 commit 信息中的
doc deploy
后生成和自动部署文本版文档和 Postman 导出格式
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 或者子网形式字符串; |
这个表格包含了字段名、数据类型、数据格式、字段额外说明等几部分信息。
- 一个 serializer 的所有字段可以在
serializer.fields.items()
中得到,只要遍历一下所有的字段就不难再针对性的处理。 - 字段的类型很容易推断,
is_instance(field, serializers.IntegerField)
等逐个的比较就可以知道。 - 几乎所有的字段都支持
required
和null
参数,代表是否允许不传递该字段和是否允许该字段的值为null
。对于字符串类型和数据类型的字段等,还支持max_length / max_value
和min_length / min_value
参数,代表数据的范围,其他的个别格式限制可以参考下 DRF 的源码。 - 有的字段需要额外说明才方便理解,或者有些字段是互斥的,不可以同时传递,所以我们还是要支持在代码中自我描述的功能,这些说明有两处来源,一个是 serializer 的
doc string
,另一个是 field 的help_text
属性。
在单元测试的时候,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
有几点是要注意的
-
测试用例存在嵌套关系的时候,比如
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 来实现。
class MultipartToJsonLikeEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, io.BytesIO) or isinstance(o, io.StringIO): return "<文件上传 💾 >" return json.JSONEncoder.default(self, o)
-
生成文档的时候要排序,将响应正确的排在前面,响应错误的排在后面。
总结
解决了已有的问题,而且鼓励开发者认真的去写更规范的测试
- 为了生成文档,至少会写一个简化版测试,总比没有测试要好
- 一个测试只干一件事情,否则生成的文档会有重复
- 测试中会写注释标明测试的用途