【译】自动化测试英雄联盟

发表于2016-03-02
评论12 8.3k浏览

【译】自动化测试英雄联盟

 

 

嗨,我是吉姆‘Anodoin’美林,我的工作是为英雄联盟做自动化测试,重点专注于游戏体验。目前我担任构建验证系统开发(BVS-Dev)团队的科研队长。在很大程度上,我们团队构建自动化测试工具并帮助团队编写更好的测试。

在过去的几年中,我们一直在努力让我们的测试系统和基础设施建设起来,以提高开发人员的效率,并减少我们遗漏的bug数量。我们现在每天大约跑10万个测试用例,并且以这个量的自动化测试有助于更快更少bug的获取内容给玩家。我愿意与大家分享一下我们所做的一小部分,并满怀希望地开始一个关于在游戏空间的自动化测试的讨论。

为什么我们在意?

联盟 变化真的,真的很快。平均而言,我们每天看到超过100代码和内容更改签入源代码控制,并且对于所有这些更改提供足够的覆盖率是一个挑战。随着每两周用一个新的补丁,我们要很快就发现缺陷就是至关重要的了。缺陷在发布过程中发现得晚会造成延误,导致重新部署,或者需要临时紧急禁用——对玩家都是糟糕的体验。自动化解放了我们的质量分析师专注于更具创造性的测试和上游缺陷预防,在那里他们可以提供更多的价值。

自动化还提供了更快的周转测试结果。对于人来说在每一段新代码或内容提交上运行一个完整的测试是不可行的,而且,即使是这样,这还需要一群测试人员返回的结果足够地迅速。

我们的测试系统运行在 连续集成 (CI) 并且在大约一小时内的检测中返回报告。这意味着开发人员在一个合理的时间内收到结果,这有助于减少上下文切换;事实上,自动化发现的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 套件测试,其中包括奥术弹幕的伤害测试,如下:

源视频见原文。

当测试完成运行,它提供结果到一个单独的报告服务,其存储运行数据可追溯约六个月。根据给定的测试数据来源,这个服务需要采取不同的操作。本地运行的测试在执行机器上打开一个页面以详细说明通过的与失败的用例。在测试场上运行的,然而,将对任何发现的问题创建新的bug罚单,标签工件根据结果,如果有任何失败的情况就发送电子邮件给提交者。测试数据也要汇总并且通过跟踪报告服务,让我们可以看到什么时候发生测试失败,它们出现的频率,以及合格构建以来有多久。

Wood 5我们没有使用任何守卫,所以在这个关键失败中我没有看到任何问题

 

为了防止片状或不可靠的测试,为了可信,每个都必须通过一个标准的过程。在一个测试已经代码审查并提交后,它会进入一组称为BVSStaging 的测试。还有,测试在晋升之前必须证明稳定至少一星期。如果测试升级失败,为了防止混淆,仅有测试开发人员被通知。

一旦测试已经证明它的可靠性,就会提升到两组之一。第一组,BVSBlocker ,包含测试证明一个构建是否更值得进一步测试。一个在Blocker 中失败的构建不会部署到测试环境中,因为这样的话,游戏要么不会开始,要么有多个严重崩溃错误影响游戏。与之相对应,BVSCore ,是我们功能测试的核心组,包括每一个英雄能力的测试。

框架深潜

BVS 是以三个层次来实现:执行程序,驱动程序和脚本。执行程序实现了功能测试的通用API,而驱动程序实现配置和执行测试的具体步骤。最后,脚本执行测试用例的具体逻辑。目前,我们仅有一个驱动在使用(LOLGame),但执行者与驱动分离意味着未来的项目当LOLGame驱动已写好时,可以通过实现自己的驱动程序及用已写好的共享程序来使用BVS

出于一些原因,我不能得到更多流程图。。。

 

个别组件注册他们必须和可选的参数作为其声明的一部分。当参数在命令行提供,他们被存储为组件占用作为其初始化的一个字典。更早的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)]

驱动对象的示例参数

 

有三个级别的相关粒度:测试集,测试和测试用例。

·                      测试集是一组一起运行的测试。例如前面提到的BVSBlocker 测试集是一组运行在CI上的冒烟测试。测试集目前对BVS的描述是通过可以在VCS中或运行中产生的JSON文件。

·                      测试是单独的类实现一组使用相同的基本游戏配置的相似的测试用例。 例如,LoadChampsAndSkins 测试贯穿测试用例包括加载每个英雄和皮肤的资产以及确认负载正常地发生。

·                      测试用例是测试中预期的功能的单一的单位。例如,loadChampionAndSkin函数LoadChampsAndSkins测试中是一个单一的测试用例,它执行数百次以覆盖英雄与皮肤的每一个组合。整个Kog’Maw 测试用例以上是由更高级别的测试执行,它允许结构比函数更多、还更复杂的测试用例执行。

并行的BVS通常在测试集水平完成,但也可以发生在测试水平。因为BVSJSON存储和读取测试集,我们用JSON创建子列表,它要么可以由一个执行人连续执行,要么在我们的测试场并行执行。BVS初期,这允许我们用手工平衡,对于小测试列表来说它比自动并行化更有效。随着主要测试集使用的增加,我们已经切换到能产生相同的JSON文件的自动负载平衡器,但现在每个测试组件使用的平均运行时间相当于过去的10次运行。

BVS的大多数用户实际上仅与测试本身相互作用,因为我们以我们的方式出发以确保他们不必去想任何驱动程序的处理细节。按照同样的思路,我们表现出相当大的标准库包装用于跟游戏互动的RPC端点。我们这样做的部分原因仅是确保测试与RPC接口的不紧密耦合,但我们这样做的主要原因是提供一组标准的行为以避免草率的测试编写和确保测试间的一致性。

尤其是,我们曝光了BVS的标准测试库中任何形式的纯粹睡眠。早期的测试作者大量使用了休眠,这导致一定数量的基于不同硬件上运行则表现不同的虚弱测试。标准库中所有的等待都是定期调查游戏节奏的有条件的等待,等待一个被满足的条件。

@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 REST 框架 Django 应用程序,它提供一个APIBVS和其他服务命中为当前BVS状态。

运行和后期运行流量 (点击放大)

 

整体表现

总体而言,对 英雄联盟的每个版本,BVS约在十八分钟内运行5500个测试用例。总的来说,每天大约是10万测试用例。从缺陷提交到第一份BVS的失败报告的平均时间是在一到两小时之间。50%的所有关键或拦截器级别的缺陷会由BVS发现,剩余的会在QA内部或PBE上被发现。没有被BVS捕捉到的问题通常一掠而过,这归功于缺乏测试覆盖率而不是糟糕的测试。

虽然大多数我们发现的缺陷是分布在游戏崩溃或缺失的功能,偶尔我们会是一些优秀bug的第一发现者。我个人最喜欢的缺陷是游戏中所有的塔慢慢滑入右上角的地图,导致史诗级的塔在一个紫色基调的边缘上阻塞。我们的发现也包括非必然出现的非自动化测试,像是如果一个英雄恰好在近距离平射射程内使用指向性技能却穿过了敌人。

总的来说,自动化测试并不一定取代手工测试,但它有助于加快开发的反馈循环及解放了我们更多的手工测试以专注于破坏性测试。随着越来越多的内容加入到了英雄联盟, 我们将继续增加更多的覆盖,这应该提升我们的缺陷命中率并提高我们对健康构建的信心。

感谢你抽出宝贵的时间来阅读。如果你有任何疑问,请随时在下面留言。在我们关于自动化的下一篇文章中,我们将解决测试吞吐量和拉力退回速度的问题。

发帖者吉姆梅里尔

 

 

翻译出处:https://engineering.riotgames.com/news/automated-testing-league-legends

原文作者未做版权声明,视为共享知识产权进入公共领域,自动获得授权。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引