柚木

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

很多人了解过 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 字段。

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

  • 一个 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)

  • 生成文档的时候要排序,将响应正确的排在前面,响应错误的排在后面。

总结

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

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