大家好,我 Jim ‘Anodoin’ Merrill,我的工作是致力于英雄联盟的自动化测试,特别关注的是游戏中的体验。我现在担任一个技术团队的负责人,致力于构建验证系统开发(BSV-Dev)团队。主要工作是构建自动化测试工具,帮助其它团队书写更棒的测试。
在过去的几年当中,我们致力于改良我们的测试系统及基础设施,来提高开发人员效率以及减少上线的 bug。现在我们每天要跑 10W 个测试用例,这个数量的测试用例可以让内容以更少的 Bug、更快的的呈现给玩家。我想来分享一点我们所做的工作,希望能开启一段游戏领域的自动化测试的交流。
为什么我们关心?
英雄联盟更新非常非常快。我们平均每天能看到 100 处以上代码或内容变更提交到版本控制系统,想要对所有这些修改提供充分的覆盖是一个挑战。由于每两周更新一个补丁,快速发现漏洞至关重要。如果在发布过程中 bug 发现的晚了可能会导致发布延迟,甚至重新部署或者需要暂时禁止某个英雄等,这些都是非常不好的玩家体验。自动化解放了我们的质量分析师(QA)使他们可以关注更有创造性的测试以及上游缺陷的预防,这里他们可以提供更多的价值。
自动化也能更快的反馈测试结果。每次代码或内容提交都人工扫一遍所有测试是非常不可行的,如果非要这么做,那需要一支测试军队来保证足够快得返回结果。
我们的测试系统在持续集成(CI)上运行而且提交后 1 小时内返回报告。这意味着开发人员可以在一个合理的时间内得到测试结果,这有助于减少上下文的切换;事实上,自动化发现的 bug 解决的速度是平均 bug 的 8 倍。更好的是,如果我们需要增加测试的吞吐量,我们可以简单的向测试场中添加执行器即可。
构建验证系统
这个富有想象力的命名——构建验证系统 (BVS) 是我们针对游戏的客户端及服务端的测试框。他负责载入测试用组件,并部署到测试机,启动并管理测试,执行测试,并报告其结果。这些测试与组件都是 Python 编写的,我们编写了大量的 BVS 代码来使测试的编写者们能从复杂的收集收集依赖的过程中解脱出来。最后,只需要测试类中的几个参数,就可以指定运行那个地图,加载多少个客户端,以及游戏在什么级别的联赛中。
测试使用远程过程调用(RPC)端点暴露在客户端及服务端,以便执行命名以及管理游戏状态。大部分情况,测试包括了一个线性的指令和查询的集合——现有的测试覆盖了从英雄技能到视觉规则到击杀小兵期望获得的奖励。我们更早的一些测试是非线性的,这对一些技术稍差的开发人员难度要大得多。
由于一个测试环境的所有的配置工作都是隔离的,不论是本地测试环境还是测试场效果都是一样的。这样当对游戏做出修改的时候就可以很方便的在本地跑测试。
例如,我们对 Kog’Maw 的新的W技能写了如下的测试:
- """
- Name: BioArcaneBarrage_DamageDealt
- Description: Verifies the damage modifications from Bio-Arcane Barrage
- Verifies:
- - KogMaw deals less damage to non-lane minions
- - KogMaw deals percentile magic damage
- - KogMaw deals normal damage to lane minions
- """
- from KogMawAbilityTest import KogMawAbilityTest
- from Drivers.LOLGame.LOLGameUtils import Enumerations
- import KogMawStats
- class BioArcaneBarrage_DamageDealt (KogMawAbilityTest):
- def __init__(self, championAbilities):
- super(BioArcaneBarrage_DamageDealt, self).__init__(championAbilities)
- self.ability = 'Bio-Arcane Barrage'
- self.slot = KogMawStats.W_SLOT
- self.details = 'Kog\'Maw deals reduced base-damage to non-minions with additional percentile damage'
- self.playerLocation = Enumerations.SRULocations.MID_LANE
- self.enemyAnnieLocation = Enumerations.SRULocations.MID_LANE.angularOffsetDegrees (45, 200)
- self.enemyMinionLocation = Enumerations.SRULocations.MID_LANE.angularOffsetDegrees (45, 400)
- def setup (self):
- super(BioArcaneBarrage_DamageDealt, self) .setup ()
- self.enemyAnnie = self.spawnEnemyAnnie (self.enemyAnnieLocation)
- self.enemyMinion = self.spawnEnemyMinion (self.enemyMinionLocation)
- self.teleport (self.player, self.playerLocation)
- self.issueStopCommand (self.player)
- def execute (self):
- self.takeSnapshot ('preCast')
- self.castSpellOnTarget (self.player, self.slot, self.player)
- self.champAttackOnce (self.player, self.enemyAnnie)
- self.takeRecentDeathRecapSnap (self.enemyAnnie, "annieRecap")
- self.resetCooldowns (self.player)
- self.castSpellOnTarget (self.player, self.slot, self.player)
- self.champAttackOnce (self.player, self.enemyMinion)
- self.takeSnapshot ('minionRecap')
- self.teleport (self.player, Enumerations.SRULocations.ORDER_FOUNTAIN)
- def verify (self):
- # Verify that enemy Annie is taking the correct amount of damage.
- annieAutoDamageEvents = self.getDeathRecapEvents (self.player, "Attack", "annieRecap")
- annieAutoDamage = 0
- for event in annieAutoDamageEvents:
- annieAutoDamage += event.PhysicalDamage
- annieSpellDamageEvents = self.getDeathRecapEvents (self.player, "Spell", "annieRecap", scriptName=KogMawStats.W_MAGIC_DAMAGE_SCRIPT_NAME)
- annieSpellDamage = 0
- for event in annieSpellDamageEvents:
- annieSpellDamage = event.MagicDamage
- AD = self.getStat (self.player, "AttackDamageItem")
- expectedPercentile = (KogMawStats.W_AD_DAMAGE_RATIO * AD)/100
- annieTotalHealth = self.getStat (self.enemyAnnie, "MaxHealth")
- expectedPercentileDamage = self.asPostResistDamage (self.enemyAnnie, expectedPercentile * annieTotalHealth, 'MagicResist', snapshot='preCast')
- self.assertInRange (annieSpellDamage, expectedPercentileDamage, expectedPercentileDamage * .1, "{} magic damage dealt. Expected ~{}".format (annieSpellDamage, expectedPercentileDamage))
- expectedPhysicalDamage = self.asPostResistDamage (self.enemyAnnie, KogMawStats.W_NON_MINION_DAMAGE_RATIO * AD, 'Armor', snapshot='preCast')
- self.assertInRange (annieAutoDamage, expectedPhysicalDamage, expectedPhysicalDamage * .1, "{} physical damage dealt. Expected ~{}".format (annieAutoDamage, expectedPhysicalDamage))
- # Verify that enemy minion is taking the correct amount of damage.
- AD = self.getStat (self.player, "AttackDamageItem")
- minionExpectedPhysicalDamage = self.asPostResistDamage (self.enemyMinion, AD, 'Armor', snapshot='preCast')
- expectedPercentile = (KogMawStats.W_AD_DAMAGE_RATIO * AD)/100
- minionTotalHealth = self.getStat (self.enemyMinion, "MaxHealth")
- minionExpectedMagicDamage = self.asPostResistDamage (self.enemyMinion, expectedPercentile * minionTotalHealth, 'MagicResist', snapshot='preCast')
- expectedDamage = minionExpectedMagicDamage + minionExpectedPhysicalDamage
- actualDamage = self.getDamageTaken (self.enemyMinion, 'preCast', 'minionRecap')
- self.assertInRange (actualDamage, expectedDamage, 1, "{} total physical and magic damage dealt. Expected ~{}".format (annieAutoDamage, expectedDamage))
- def teardown (self):
- self.destroy (self.enemyAnnie)
- self.destroy (self.enemyMinion)
Kog’Maw 整套测试的第一部分,包含了 Arcane Barrage 的伤害测试,具体过程如下:
当一个测试运行完,它将测试的结果提供给一个独立的报告服务,那里存储了过去六个月的运行数据。基于测试数据,这个服务采取不同的行动。一个本地运行的测试会在测试执行的机器上打开一个 web 页面,包括通过的与失败的测试用例的详情。如果在测试场运行,手游买号平台当有任何测试用例失败的时候,系统会自动根据测试结果创建 bug 标签及 issue 等,并给提交者发送邮件。测试数据也会通过报告服务聚合及跟踪,使我们可以知道什么情况下测试不通过,以及失败的频率,还有 bug 存在多久了。
出于防止古怪及不可靠测试的目的,每个测试都必须经过一个标准的流程来保证可信。当一个测试经过了代码审查并提交,它加入一个测试集合叫做 BVSStaging。在那里测试在提交运行前必须稳定运行至少一周。如果在 Staging 中的测试失败了,只会通知测试的开发者,来避免困惑。
当一个测试被证明可靠后,它被提交进两个集合中的一个。第一个集合,BVSBlocker,包括的测试指出该构建是否值得进一步测试。如果一个构建在 Blocker 集合测试不通过是不会部署到测试环境的,因为游戏不能开始,或者有好几个会导致服务端崩溃的 bug 影响游戏。相对的,BVS Core,使我们功能测试的核心集合,包括对每个英雄技能的测试。
框架深度游览
BVS 分三个层面实现:执行器,驱动及脚本。执行器为功能测试实现了一套通用的 API,驱动实现的一个测试的配置与执行的具体步骤。最后,脚本实现测试用例的具体逻辑。当下,我们只有一个驱动在使用(LOL 游戏),但是执行器与驱动分离的设计意味着在将来的项目里可以通过实现各自的驱动来使用 BVS 系统,而且可以共享使用 LOL 的驱动编写时编写的工具。
个别的组件注册了他们必选和可选的参数,作为它们声明的一部分。当命令行提供了参数,参数被作为字典存储下来,然后组件会在初始化时处理这些参数。BVS 早期的版本使用 Python 标准的 argparse 库,但是出于两个原因,我们选择放弃 argparse 库:第一,潜在参数的数量变得非常巨大,通过系统跟踪变得非常困难;第二,驱动需要有一些驱动特有的参数,这意味着在启动时声明一个解析器是不可行的。
- class TestFactory (API.TestFactoryAPI):
- requiredArgs = [ArgsObject ('driver', 'Driver you wish to use'),
- ArgsObject ('name', 'Name of the test to run')]
- optionalArgs = [ArgsObject ('overrideConfig', 'Use a non-standard game.cfg', None),
- ArgsObject ('gameMetadataConfiguration', 'A string identifying which game metadata to use', None),
- ArgsObject ('listener', 'Log listener to use', None),
- ArgsObject ('mutator', 'A string name for mutator to apply to test object', None),
- ArgsObject ('testInfoID', 'Test and metadata this test run is related to', None),
- ArgsObject ('testSubsetNumber', 'The number out of total if test is subsectable', None),
- ArgsObject ('totalSubsetNumber', 'The total numbers of subsets test is split into', None)]
相关的尺度共分为三个等级:测试集合,测试,测试用例。
1、测试集合 是同时运行的一组测试。例如,之前提到的 BVSBlocker 测试集合就是运行在 CI 上的一组冒烟测试。测试集合现在通过 JSON 文件的方式描述给 BVS,可以在 VCS 或 On-The-Fly 中创建。
2、测试 是单独的类实现了一组相似测试用例,使用相同的基础游戏的配置。例如,LoadChampsAndSkins 测试包含了加载各个英雄的资源、皮肤以及确认加载正确的测试用例。
3、测试用例 are 是一个测试中对期望功能测试一个单一单位。例如 LoadChampsAndSkins 中的 loadChampionAndSkin 方法就是一个单独的测试用例,为了覆盖所有英雄和皮肤的组合要执行数百遍。上文提到的 Kog’Maw 整个测试用例被一个更高级别的测试执行,这个测试允许更复杂的测试用例使用比一个函数更复杂的结构。
BVS 并行化通常是在测试集合这一层实现的,但是也可以在测试这一层实现。由于 BVS 通过 JSON 存储和读取测试集合,因此我们可以在 JSON 中创建一个子列表,这样既可以被单个执行器顺序执行,也可以在测试场中并行执行。早期的 BVS 系统中,这个操作是允许我们手工进行平衡的,当测试列表比较小时候,这个比自动化的并行更有效。由于主要测试集合的增长,我们切换到了一个自动化的负载平衡工具来产出这个 JSON 文件,依据每个测试的之前 10 次的平均运行时间来进行调整。
BVS 的大多数用户只跟测试本身打交道,因为我们通过自己的方式来保证测试人员不需要考虑驱动处理的细节。同时,我们用一个非常大的标准类库来封装 RPC 的端点,用于与游戏交互。部分原因是为了防止测试与 RPC 接口过紧的耦合,但主要原因是为了提供一组标准的行为,来防止草率的编写测试,进而保证测试之间的一致性。
特别的 BVS 的标准测试类库不支持纯粹的 sleep。早期的测试编写者,大量使用 sleep,导致一大批脆弱的测试在他们各自执行的硬件上表现完全不同。所有标准类库中的等待操作都是条件等待,都是在等待游戏中一个特定的条件。
- @annotate ("Wait until a unit drops the specified buff.",
- arguments=[argument ("unitNameOrID", "Unit name (or unique integer unit ID).", (str, int)),
- argument ("buff", "Buff you want to drop.", str),
- argument ("timeout", "How long to wait.", float, default=STANDARD_TIMEOUT),
- argument ("interval", "How often to check for a change.", float, default=SERVER_TICK),
- argument ('speedUp', 'Whether to speed the game up.', bool, default=False)],tags=["wait", "buff", "change"])
- def waitForBuffLost (self, unitNameOrID, buff, timeout=STANDARD_TIMEOUT, interval=SERVER_TICK, speedUp=False):
- conditionFunction = lambda: not self.hasBuff (unitNameOrID, buff)
- return self.__waitForCondition (conditionFunction, timeout=timeout, interval=interval, speedUp=speedUp)
另一个我们对 BVS 做的重要的适应是由于早期分离出了除了了运行测试之外的所有逻辑。过去 BVS 决定使用什么设备,标记构建通过还是失败,以及排版测试报告。为了保持一个职责的清晰划分,我们分离出了一个服务来处理与运行测试并不直接相关的所有内容。这个服务是一个 Django 应用,使用 Django REST 框架来提供了一组 API 供 BVS 及其它服务使用。
总体性能
总的来说,BVS 对每个英雄联盟的新构建会在大约 18 分钟的时间里运行大约 5500 个测试用例。一天总共运行大约 10 万个测试用例。从一个缺陷提交到 BVS 的第一份失败报告之间的平均时间大概是一到两个小时。50% 的 critical 及 blocker 级别的 bug 都是 BVS 系统发现的,其余的通过内部 QA 或者 PBE 发现。没有被 BVS 发现的问题通常是由于测试覆盖不足的问题,而不是因为差的测试编写。
虽然我们发现的大多数 Bug 都输入功能缺失或游戏崩溃的领域,偶尔我们也会成为一些真正优秀的 bug 的第一发现者。我个人的得意之作发现一个缺陷:游戏中所有的塔都缓慢移向地图的右上角,结果紫色基地的圣塔附近就出现了交通拥堵。我们的发现也包括一些非自动化测试可能无法发现的东西,比如曾经有个问题是点射技能可能会穿过一个敌人,如果英雄恰好攻击了敌人空白点的范围。
总体来说,自动化测试代替手工测试并不是必要的,但是它帮助我们加快了开发的反馈循环而且解放了更多的手动测试人员使他们可以关注更有害的问题的测试。随着英雄联盟内容的增加,我们持续增加更多的覆盖,这样可以提高我们的对缺陷的命中率,也让我们对构建的健康程度更有信心。
谢谢你花时间来阅读这篇文章,如果有问题可以在评论中直接提问。我们下一篇关于自动化测试的文章中将会讨论测试的吞吐量及返回的速度。