如果有这么一款游戏,你操作的角色平均每 20 秒就会死亡一次,正常通关一次,总共需要死掉超过 2000 次。你猜这是一款神作还是垃圾?
《Celeste》(译名:“蔚蓝山”)就是这么一款游戏。在游戏里,你扮演一个名为 Madeline 的女孩,通过跳跃、抓墙、冲刺等动作,去努力登顶一座名为 “Celeste” 的高山。
正如我在开头说的,这款游戏的难度高到令人发指,玩家平均得死上千次才能通关。但奇怪的是,这款游戏获得的成就似乎和它的难度一样高。在 2018 发售那年,它获得了 TGA “年度游戏”提名并成功拿下了“最佳独立游戏”奖项。截止到 2018 年底,它总共卖出了超过 50 万份。
极低的犯错成本
让《蔚蓝山》大获成功的原因有很多。精妙的关卡设计、出色的动作手感、令人惊艳的游戏配乐,以及剧情里流露出的真诚人文关怀,都是非常关键的因素。但除开这些,我在玩游戏时,还注意到了一个有意思的细节:在游戏里,玩家的犯错成本非常低。
假如你操作跳跃的时机不对,角色掉入坑里死掉了。然后,在 不到 3 秒钟 内, Madeline 就会在房间入口处复活。你可以对自己的打法稍作调整,马上进行下一次尝试。
并非所有游戏都给予了玩家这种快速试错能力。比如在 PS4 游戏《血源诅咒》里,一次死亡可能代表你过去一小时获得的资源全都化为乌有。注1
所以,在《蔚蓝山》里,游戏设计者给了玩家一种可以 “低成本犯错” 的能力。有了它,我们可以快速从错误中学习,更好的完成挑战。那么,如果用编程来类比,我们在写代码时的犯错成本又如何呢?
编程时的“犯错成本”
假设我在开发一个新闻稿管理系统,系统里目前只有一种用户:“管理员”。但因为需求变更,我现在得给系统加上两个新角色:“编辑”和“主编”。
每类角色能做的事是有区别的:
- 编辑:可以提交稿件、修改自己的稿件
- 主编:在编辑的权限上,增加刊登稿件的功能
- 管理员:可以做任何事以及管理所有人的权限
为了支持不同的角色,我需要改进现有的用户权限体系。首先,我得把和权限控制相关的所有功能点整理出来,然后开始写权限控制相关的代码。
没人能一次写出不出错的代码,所以写代码,其实就是一个在不断重复 “开发” -> “试错” -> “修改” 的过程:
- 修改后端代码,增加新角色:“主编”
- 在“主编”相关的功能点,增加权限保护代码片段
- 保存代码,等待本地服务器重启加载改动 (5-10 秒)
- 打开浏览器,点击各个功能页面,确认我的改动是否生效 (10 秒以上)
- 如果测出问题,回到步骤 2,重复整个过程
在很长一段时间里,我在工作时的开发流程就是上面这样。我总是在接到需求后就马上对代码修修改改,然后打开浏览器,点点这里、点点那里,用肉眼观察一切是否正常。
使用这种开发方式,假如我某次写的代码有问题,那么从我每次改完代码,到一直走完步骤 3、4、5,整个过程至少得花费超过 30 秒。
如果你不觉得 30 秒很多,请你想想《蔚蓝山》吧。在《蔚蓝山》里,角色每次死亡到下次重试的时间间隔是不到 3 秒钟,二者相差 10 倍。所以,上面这种开发模式的“犯错成本”太高了。
如何降低“犯错成本”
其实,在开发这类 web API 时,我们完全没有必要傻乎乎的手工用浏览器点来点去。作为功能的开发者,我们可以(而且有义务)利用自动化测试来加速整个试错过程。
很多 web 框架都为这类测试提供了帮助。拿 Django 为例,你可以使用 django.test.Client
来轻松编写这类测试:
# 以下代码片段来自 Django 官方文档
import unittest
from django.test import Client
class SimpleTest(unittest.TestCase):
def test_details(self):
client = Client()
response = client.get('/customer/details/')
# 测试某次请求是否返回了 200 状态码
self.assertEqual(response.status_code, 200)
对于前面的需求,我们可以直接编写下面这样的单元测试代码。
# 针对不同的角色定义不同的单元测试类
class RoleEditorTestCases(TestCase):
"""编辑角色的测试类
"""
def test_create_post(self):
# 编辑角色可以正常调用创建帖子接口
response = self.request_post('/posts/', {'title': 'foo'}, current_user=self.user)
assert response.status_code == 201
assert isinstance(response.data, dict)
def test_create_admin(self):
# 编辑应该无权调用创建管理员接口
response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
assert response.status_code == 403
class RoleAdminTestCases(TestCase):
"""管理员角色的测试类
"""
def test_create_admin(self):
# 管理员可以调用创建管理员接口
response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
assert response.status_code == 201
有了这些单元测试后,整个试错流程可以得到极大改进。每当我改完代码后,只要运行 pytest
命令跑一遍相关的单元测试,就能知道改动是否奏效了。
❯ pytest
======== test session starts ========
platform darwin -- Python 3.8.1, pytest-5.3.5
collected 5 items
tests/api/test_permissions.py .....
======== 5 passed in 0.72s ========
不需要等待开发服务器加载变更、不需要打开浏览器点这点那。一切试错任务都可以在几秒钟之内完成。
编写测试其实也是 DRY
我在前面说过,在游戏《蔚蓝山》里,如果角色死掉了,那么她马上会从当前这个 房间入口处 重生。让我们设想一下,假如游戏没有采用这种设计:在新机制下,角色每次死亡后,玩家都得回到本章开始的地方,重新挑战一遍好几十个已经通过的房间。那会怎么样?估计很多人会气的把手柄摔地上。
但是,依赖人工测试的开发流程,其实就非常接近于让人摔手柄的设计。
拿用户权限功能来说,因为这个功能非常关键,所以我每次做出大改动后,都需要重复验证一下每个功能点在各角色下的表现是否正常。假如系统里一共有 20 个功能点需要和权限挂钩,那么 20 * 3 个角色
,就是 60 个需要测试的点。
即便我有三头六臂,每个功能点只花 20 秒测试,整套东西测下来也需要 20 分钟。
但是,如果你已经为这些场景写好了单元测试,那么事情就变得简单多了。每次做了改动之后,你只需要重新执行一遍单元测试,就能把所有场景都验证一次。
Django 框架有一条设计哲学叫 “Don't repeat yourself (DRY)” - “不要重复你自己”。多数情况下,我们说 DRY 是指不要写重复代码。但我认为“不要重复手工测试已经测过的东西”其实也可以算是 DRY 的一种。
所以,每当你手动测试一次功能时,其实就是在重复你自己。既然如此,何不将它写成一个单元测试呢?
“所以,就是在劝我写单元测试?”
是的,我就是在劝你写单元测试。作为对比,让我们看看利用单元测试的开发流程是什么样的:
- 修改后端代码,增加新角色:“主编”
- 在“主编”相关的功能点,增加权限保护代码片段
- 编写与功能代码相关的单元测试代码,与 2 同步进行
- 执行单元测试,如果失败,从 2 开始调整代码,重复整个过程 (几秒钟)
通过把测试行为自动化,我们可以大大减少整个开发过程的试错成本。事实上,自从若干年前养成了写单元测试的习惯,我就一直坚持至今。那么,我到底是因为什么在写单元测试呢?
- 单元测试让我的代码 Bug 更少?
- 单元测试帮助我写出扩展性更强的代码?
- 单元测试让我在重构时更不容易出错?
以上可能都是。但现在,我可以往上面的列表里再加上一点:使用单元测试来开发的过程,有一种流畅感,失败后就马上重试,一切就犹如在操作 Madeline 登顶那座蔚蓝色的山。
注解
😊 如果你喜欢这篇文章,也欢迎了解我的书: 《Python 工匠:案例、技巧与工程实践》 。它专注于编程基础素养与 Python 高级技巧的结合,是一本广受好评、适合许多人的 Python 进阶书。