我学会了喜欢测试游戏代码

2022-12-23   出处: chadnauseam's blog  作/译者:chad nauseam/Yilia


  大多数游戏都没有太多的测试方式。 据我所知,唯一公开投入大量精力的大预算游戏是《盗贼之海》。 我最喜欢的开源游戏 Mindustry 确实有一些,但它们也不是非常流行。为什么是这样? 测试在游戏中用处不大吗? 好吧,有点。

优点:

1.游戏可以由 QA 团队手动测试,他们的时间比开发人员的成本低,因此聘请外部帮助来手动测试游戏是有道理的。 但它并不是严格意义上的最好选择——自动化测试的好处是它在代码更改数量方面是 O(1)规模 ,而手动测试是 O(n),因此只有自动化测试可以被包含在 CI 中。 这样做的好处是,当更改代码导致测试失败时,不必浪费更多时间来寻找错误,因为知道它是由刚刚所做的更改引起的。 这种优势不止体现在游戏测试中。
2.测试驱动开发更有趣。“列出红色的错误,然后让它们一次一个地变成绿色”描述了游戏的其中一个好的部分,就像在游戏中闯关一样!”

缺点:

1.游戏改了很多功能。 为下周要更改的这些功能编写测试用例就不是很有用。
2.自动化测试等同于预防错误——一旦你有一个自动化测试来确保一些功能能正常工作,就不太可能无意中发布不起作用的代码版本。 游戏测试人员比自动化测试慢得多,并不总是能够在每次发布时测试所有可能的问题。 因此,在正确性要求高的情况下,自动化测试具有强大的优势。 但在游戏中,正确性几乎不重要。 产品通常会半途而废,但仍能赚很多钱。 (《盗贼之海》也不例外。)GTA V 是有史以来最成功的游戏之一,它不乏技术问题,包括一个愚蠢的 O(n^2) 错误,该错误导致加载屏幕需要 6 分钟,最终由 一个modder 修复。
3.不清楚如何以一种不会让你想拔头发的方式测试游戏代码。 我曾经是一名专业的 Unity 开发人员,但仍然不知道该怎么做。 部分问题在于大多数大型游戏引擎都鼓励意大利面条式代码,这让测试变得非常痛苦。 例如,在 Unity 中,每个游戏对象都有一个名称,并且有一个Find函数接受一个字符串并返回具有该名称的游戏对象。(不要问我如果有多个游戏对象使用该名称会发生什么 - Unity 文档不会费心告诉你。)默认情况下,名称就是在检查器中输入的名称,所以它不像是一些全局的在代码中某处定义的常量。 这意味着如果更改名称,必须查找出使用该函数的每个测试用例并将新字符串传递给它!
  大多数游戏都没有太多的测试方式。 据我所知,唯一公开投入大量精力的大预算游戏是《盗贼之海》。 我最喜欢的开源游戏 Mindustry 确实有一些,但它们也不是非常流行。为什么是这样? 测试在游戏中用处不大吗? 好吧,有点。
  但最近我开始在出色的Rust游戏引擎Bevy中编写游戏。 Bevy 使用 ECS 模式来组织游戏代码,我突然想到 ECS 大大缓解了让 Unity 和 Unreal 中的测试变得如此糟糕的意大利面条代码问题。

ECS 快速入门

使用 ECS,代码通过三个不同的概念与游戏引擎对话:实体、组件和系统。
1.实体:由一个唯一的整数表示。 每个实体对应于游戏中的一个本体单元——可能有一个玩家实体,每个平台都有一个实体,等等。
2.组件:是可以保存任何数据的结构,游戏引擎中有一个数据结构可以保存实体和组件之间的关系。 例如,与玩家对应的实体可能有一个 Player 组件,它存储玩家当前健康状况等属性。
3.系统:是指示游戏引擎在每一帧(或在启动时等)运行的功能。系统可以查询附加了某些组件的实体,并可能改变这些组件(或做其他功能可以做的事情,比如与系统 API 对话)。 例如,可以有一个系统查询所有具有位置分量和速度分量的实体,然后迭代它们以根据速度修改位置。
  在 bevy 中,还有一些内置系统和组件可以处理诸如与显卡交互以在屏幕上显示游戏以及其他常见游戏需求等事情。在 Bevy 中,系统的查询在函数的类型签名中可见。 因此,仅通过查看系统的类型,就可以清楚地知道它可能与哪些组件交互。 这对引擎很有用,它有一个调度程序,如果两个系统都没有请求修改另一个可以修改或读取的组件的能力,则可以并行运行系统。

使用 ECS 进行测试

  ECS 强加的结构使得测试开始显得更有吸引力。 每个测试都可以简单地创建一个世界,在一些实体中生成,附加一些组件,并注册一些系统。 然后它可以模拟几帧游戏,最后断言组件已按照预期的方式进行了修改。
这正是我实施的!下面是我正在编写的游戏中的测试用例:

#[test]
fn character_moves_horizontally() {
    use crate::character;
    Test {
        setup: |app| {
            app.add_plugin(RapierPhysicsPlugin::<NoUserData>::default())
                .add_plugin(character::Plugin);
            // Setup test entities
            let character_id = app
                .world
                .spawn()
                .insert_bundle(SpatialBundle::default())
                .insert_bundle(character::Bundle {
                    input: character::Input {
                        direction: Vec3::X,
                        ..character::Input::default()
                    },
                    ..character::Bundle::default()
                })
                .id();
            spawn_floor_beneath_capsule(app, character_id);
            character_id
        },
        setup_graphics: default_setup_graphics,
        frames: 10,
        check: |app, character_id| {
            let character =
                app.world.get::<Transform>(character_id).unwrap();
            assert_gt!(character.translation.x, 0.0);
        },
    }
    .run()
}

那么这是如何工作的呢?

有一个test结构体,像下方例子一样:

pub struct Test<A> {
    pub setup: fn(&mut App) -> A, 
    pub setup_graphics: fn(&mut App, &A),
    pub frames: u64,
    pub check: fn(&App, A),
}

impl<A> Test<A> {
    // The `run()` method does all the work of setting up a world, 
    // passing it to `setup`, simulating for `frames` ticks, 
    // and running `check`.
    // It will only enable rendering if you pass the appropriate 
    // argument to the test binary, so tests run fast by default.
    pub fn run(self) {
        // ...
    }

    // So you can just put your setup code in `setup`, and your assertions in `check`, 
    // and now you have a test for your game!
}

  gamedev 中的一个常见问题是,添加可能仅可见的新功能时,例如 进入游戏 5 分钟后,您可能不想为了测试该功能而玩 5 分钟。 Bevy(和 Unity)中的传统解决方案是创建一个“测试场景”,其中已经包含测试功能所需的一切,并在测试时加载该场景而不是主游戏。 然后,一旦该功能生效,只需将其移植到主游戏场景中,应该不是很困难。
  同样,当测试用例运行失败时,能够真正加载游戏并查看正在测试的世界是件好事。 这就是测试中的 setup_graphics: default_setup_graphics 行和结构中的 setup_graphics: fn(&mut App, &A)字段的原因。 这是因为我意识到测试的一个意想不到的好处,我认为构建微场景对游戏测试是有利的。 只有当向测试运行器表明想要实际进行测试时,它才会运行。 (default_setup_graphics只是设置了一个灯和一个摄像头,这样就可以看到发生了什么。)
  这就是我发现的工作流程:在setup中设置一个小型测试世界,然后通过运行测试来使该功能正常工作。 而且,如果愿意,在让它工作之前或之后,只需在check中添加一些断言,现在就有了一个能用的测试用例,几乎是免费的!
但是,如果做了这些工作……为什么不把它放在Test中并添加check呢? 为了支持这个工作流,我使用了我的 run函数来让它在通过 cargo test进行测试时正常运行,而且还支持加载任何特定的测试世界并像玩普通游戏一样玩它。

ECS的方式如何改变/影响了游戏场景测试

我认为这减轻了游戏测试的三个缺点:
1.编写测试花费的额外时间少得多。 无论如何,都必须制作一个测试场景,并且希望在已经设置好场景后编写几个额外的断言不会太耗时。
2.当不可避免地需要修改正在测试的功能时,有一个现成的测试场景,可以设置该功能所需的一切配置。 不再需要在一个巨大的测试场景文件夹中寻找,反正其中大部分现在已经过时了。 (知道它们不会过时,因为如果过时,希望断言会失败。)当修改不同的功能时,ECS 提供的隔离可防止不得不更改太多测试用例。 理想情况下,每个测试只添加它需要的最低限度的组件,并通过使用“组件包”添加它们,其中某些组件从默认设置更改。 这是让我非常喜欢的 ECS 的反意大利式面条属性。
3.ECS 可以更简洁地设置包含需要的一切的最小场景。

未来的工作

(注意:这部分可能主要是使用 Bevy 的 Rust 开发人员感兴趣。)

测试易碎性

  我想设置一个自定义测试运行器来做一些类似于 nextest 的事情,
  如果每次都失败,则失败。 这样做的好处是它提高了测试剥落到 n 次方的概率。 (“提高”在这里有点用词不当,因为概率实际上降低了。)理想情况下,还会有一些诊断来告诉你哪些测试是不稳定的。另外,我会更加关注确定性。 想要一个 bevy 功能 ,它强制明确地对行为可能取决于订单的任何系统进行排序。相关的是,Bevy-turborand 使确定性随机数生成更简单一些,但这取决于在 bevy 中向访问同一组 RngComponents的任何系统添加显式排序。值得称赞的是,Bevy-rapier(Bevy 最受欢迎的物理引擎)具有确定性模式,可以在任何符合 IEEE 754-2008 标准的平台上提供逐位相同的结果。

记录

目前,由于问题 #4934,无法在测试中使用 bevy 日志。

自定义测试工具

  我想制作一个自定义测试工具,它默认以无头模式并行运行Test,但也允许交互式地播放正在测试的场景。 理想情况下,这将集成到 bevy 编辑器中,如果我们在有生之年能看到它的话。

一些总结

  这实际上实现起来不是完全微不足道。 问题是 Bevy 有两组插件,DefaultPluginsMinimalPluginsDefaultPlugins不能在测试中使用,因为它包含只能在主线程中使用的插件,例如WinitPluginLogPlugin。 其他插件,如 RenderPlugin,将无法在 CI 中工作,因为如果没有 GPU,它们会崩溃。
  一旦弄清楚哪些插件有效,就向 Bevy 添加了一个新的 PluginGroup,TestPlugins,并提交了一个 PR。
  下一个问题:RenderPlugin实际上做了很多不需要 GPU 的有用的东西,包括一些 bevy_rapier需要的东西! 所以下一步是修复 RenderPlugin,这样当没有检测到 GPU 时,它仍然会尝试尽可能多地做,并记录错误而不是恐慌。 当然,这也得到了 PR。(我不确定这些 PR 是否真的会合并。)


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
214° /2141 人阅读/0 条评论 发表评论

登录 后发表评论