很多人了解过 Python 的 doctest,是从注释中写测试,我们现在反向思维,从测试生成文档。
在开头有必要说明一下现在后端 API 的开发模式,这样才能更好的理解遇到的问题。
APIView,但是并不是 Django REST framework(以下简称 DRF,名字太长了) 中的 APIView,这个后面会说原因DRF 库提供了很多我们并不会用到的功能,比如
Generic Views 一直是一个让我感到疑惑的东西,看似写起来简单,代码量很少,像是填充一些预定义的变量和方法,简单的增删查改会方便一点,但是在实际复杂的业务场景下,可能导致问题复杂化,并没有显式的写出操作过程更清晰。所以我们仿照 DRF 的 APIView,继承 Django 的 View,自己写了一个新的 APIView,包含了核心功能,解析 JSON,同时增加了部分常用方法,比如 validate_serializer、self.success、self.error 和 self.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)))
Generic Views 就放弃了。要改进上面的问题,基本原则是尽量少改动已有的代码,所以经过和 @reverland 的一番讨论,确定使用下面的方法:
doc deploy 后生成和自动部署文本版文档和 Postman 导出格式由一个 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 | 其他 |
|---|---|---|---|---|
| 子字段 | 字符串 | 必填 | False | IPv4 的 IP 或者子网形式字符串; |
这个表格包含了字段名、数据类型、数据格式、字段额外说明等几部分信息。
serializer.fields.items() 中得到,只要遍历一下所有的字段就不难再针对性的处理。is_instance(field, serializers.IntegerField) 等逐个的比较就可以知道。required 和 null 参数,代表是否允许不传递该字段和是否允许该字段的值为 null。对于字符串类型和数据类型的字段等,还支持 max_length / max_value 和 min_length / min_value 参数,代表数据的范围,其他的个别格式限制可以参考下 DRF 的源码。doc string,另一个是 field 的 help_text 属性。在单元测试的时候,Client 会传递一个特殊的 HTTP 头,这样 @validate_serializer 就知道是否要生成 serializer 的文档了。
一个 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 中发出的,那么函数栈是
test_edit_acl_rule 中调用了 test_create_acl_rule 时,self.test_method_name == "test_edit",而函数栈是
不是所有的 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)
生成文档的时候要排序,将响应正确的排在前面,响应错误的排在后面。
解决了已有的问题,而且鼓励开发者认真的去写更规范的测试