游戏测试必备:Python循环语句的自动化实战指南
1. 为什么游戏测试工程师必须学会循环语句1.1 我在游戏测试里遇到的第一个手工地狱先说一段真实经历。多年前我刚转做游戏测试的时候接手了一个卡牌游戏的版本验收任务。当时游戏里有20多个章节每个章节下又有若干小关卡我们需要验证每关的战斗结算、星级评定、掉落奖励是否正确。那时候测试用例表里写的是进入关卡-查看星级-对比奖励-退出关卡看着不复杂问题是这个操作要重复上百次。两个人轮流点鼠标点了一整天中间还要不断拍照、填表格到了晚上眼睛都是花的。更麻烦的是重复操作到了后期注意力下降很容易点了错误的按钮导致测试结果无效又得重跑。后来我逼着自己开始写自动化脚本第一件事就是学透循环语句。原因很简单游戏测试里充满了同样的动作做N遍的场景而循环语句天然就是为这种场景设计的。它让程序按你指定的次数、指定的顺序批量执行同一组检查动作还能在这个过程中记录每一轮的数据。可以说循环语句是游戏测试自动化这座大楼的地基地基不牢后面学什么列表、字典、文件操作、接口测试都用不起来。1.2 循环语句在游戏测试自动化中的整体定位网上关于Python循环语句的教程非常多各种公众号文章、入门视频都在讲for、while、break、continue的语法但绝大部分教程的问题在于例子太空泛了什么打印1到100、计算累加和看着会了回到游戏测试项目里还是不知道怎么写。我这篇博文反过来直接把你放在游戏测试的工位上用真实的测试场景来讲循环语句。我们要解决的核心问题有三类批量执行重复检查同一套校验逻辑在不同关卡、不同角色、不同道具上跑N遍。等待与轮询游戏里的动作是异步的战斗要时间、资源要加载、动画要播放循环语句是等到某个状态成立再继续的最基本实现方式。数据过滤与提前终止一大批测试数据里有些数据无效需要跳过有些数据异常需要立刻停止整轮测试这些控制逻辑就是break和continue干的活。理解这个定位之后你再回头看for循环和while循环的区别就清楚了for循环适合我知道要跑几遍的情况比如把所有关卡列表遍历一遍while循环适合我不知道要等多久只知道等什么条件的情况比如等一局战斗结束。二者没有谁高级谁低级之分场景合适就是好工具。2. for循环关卡遍历与配置表校验的主力军2.1 遍历关卡列表从手工点到全量自动跑先看最常见的场景全量关卡回归。假设测试环境里有一个获取关卡列表的函数get_level_list()返回所有关卡的ID比如[level_1, level_2, ..., level_120]。你要对每个关卡做基础校验进入关卡、检查主角初始血量、退出关卡。网上那些打印列表元素的教程到这里就断了因为他们没告诉你游戏测试里遍历列表的完整套路。我贴一段我当时封装的脚本结构def check_level_basic(level_id): 进入关卡检查基础属性退出关卡 enter_level(level_id) hero_hp get_hero_hp() expect_hp get_expect_hp(level_id) # 从配置表读取期望值 assert hero_hp expect_hp, f关卡 {level_id} 主角血量异常期望 {expect_hp}实际 {hero_hp} exit_level(level_id) print(f关卡 {level_id} 检查通过) level_list get_level_list() for level_id in level_list: check_level_basic(level_id)注意几个容易被忽略的细节。第一assert断言可以带上描述信息这样一旦血量不匹配日志里能直接看到是哪个关卡出了问题不用回头再去猜。第二check_level_basic这个函数把检查逻辑独立出来了for循环只管遍历两者职责分开。这样做的好处是以后新增一种检查项只需要改函数内部循环体一行不用动。如果测试中途遇到某个关卡崩溃for循环不会自动停下来会继续跑后面的关卡。这在部分场景下是好事但在崩溃类bug的排查里反而是坏事这一块我放到后面讲break的时候细说。2.2 整数范围与步长控制升级经验曲线抽查for循环遍历列表是最直觉的用法但游戏测试里还有一大类需求是按数字区间遍历。比如你要验证一个RPG游戏1级到100级的升级经验配置看每个等级的升级所需经验是否和数值策划表一致。手工看100个等级不现实脚本循环就很轻松for level in range(1, 101): config_exp get_required_exp(level) # 从游戏配置接口读取 table_exp exp_table[level] # 从策划配置表读取 assert config_exp table_exp, f等级 {level} 经验配置不一致这里range(1, 101)和range(101)的区别要搞清楚前者从1开始到100结束后者从0开始到100结束。很多新手在这里栽过跟头写出来range(101)然后发现多了一个0级数据如果游戏里根本不存在0级第一次循环就会断言失败。再来说说步长。range(1, 101, 5)表示取1、6、11、16……这样的间隔。我实际项目里会用这个做抽测特别是在版本时间紧张、全量回归跑不完的时候。选取规则一般是抽起始等级、整十等级10、20、30……、最高等级再加上随机抽取几个中间等级。为什么要重点照顾整十等级因为数值策划填Excel表的时候最容易在跨了整十边界那一行漏填或者填错这部分属于经验之谈。2.3 遍历字典与枚举核对掉落表、签到奖励配置列表遍历之外游戏测试里大量数据是以字典形式存在的。比如每日签到奖励配置daily_signin_rewards { 1: 金币*1000, 2: 体力*50, 3: 钻石*30, # ... 一直到第30天 } for day, reward in daily_signin_rewards.items(): actual_reward get_signin_reward(day) assert actual_reward reward, f第 {day} 天签到奖励异常注意这里是遍历字典的items()方法直接同时拿到key和value比先遍历keys再逐个去字典里查值要干净得多性能上也少一次哈希查找。对于游戏测试脚本来说性能差异不算大但代码可读性的提升很明显。还有一种常用的遍历对象是枚举类型。比如游戏里有角色状态枚举IDLE、ATTACK、DEAD、BUFF等你要校验不同状态下角色的表现。用for循环遍历枚举成员检查每个状态对应的事件是否能正常触发from enum import Enum class RoleState(Enum): IDLE 0 ATTACK 1 DEAD 2 BUFF 3 for state in RoleState: set_role_state(state) check_role_animation(state)这段代码的价值在于以后策划新增了一个角色状态你只需要在枚举里加一个成员测试脚本不需要任何改动就能自动覆盖到新状态。这就是循环遍历枚举在测试维护性上的优势。3. while循环状态等待与持续监控的正确打开方式3.1 轮询机制等待战斗结束的正确写法for循环解决次数已知的问题游戏测试里还有一大类次数未知的问题等战斗结束、等资源加载、等人物从A点走到B点。这类场景如果还用for循环你得先知道需要循环多少次可你恰恰不知道所以这时候要用while循环。我第一次写战斗等待逻辑时犯过一个特别幼稚的错误while get_battle_state() ! finished: pass这个写法在理论上没错但实际跑起来CPU直接飙满测试机卡成PPT。原因在于pass空转没有任何延迟while循环会以最快速度反复查询战斗状态把CPU占满了。正确写法是轮询也就是每隔一小段时间查询一次import time def wait_for_battle_finish(timeout30, interval0.5): 等待战斗结束最长等待timeout秒 start_time time.time() while True: state get_battle_state() if state finished: print(战斗结束) return True if time.time() - start_time timeout: print(等待超时战斗未结束) return False time.sleep(interval)注意这个结构while True外面套了一个循环内部用两个if分别处理目标状态达成和超过时限两种情况。time.sleep(interval)是轮询的灵魂它让循环每0.5秒才查询一次既不会漏掉状态变化也不会把CPU吃满。轮询间隔的选择我后面会单独讲。3.2 条件循环的边界条件设计防超时是必修课刚刚的代码里已经用了timeout这几乎是游戏测试w hile循环的标配但很多教程不会强调这一点。他们只教你while condition这种理想写法却没说condition在测试环境里可能永远不为真。网络延迟、服务器卡死、客户端崩溃任何一个环节出问题while都可能无限循环下去你的测试脚本就挂在那一动不动后面的用例全部阻塞。经验法则写while循环之前先问自己如果这个条件永远不成立我的脚本怎么办回答不了的就一律加超时保护。我封装了一个通用等待函数测试用例里调用起来很省心def wait_until(condition_func, timeout15, interval0.5, desc): start_time time.time() while True: if condition_func(): print(f{desc} 条件满足) return True if time.time() - start_time timeout: print(f{desc} 超时未满足) return False time.sleep(interval)这个函数接受一个返回布尔值的函数作为参数调用方的写法就是wait_until(lambda: get_battle_state() finished, timeout20, desc战斗结束)lambda表达式可以很方便地把查询状态并比较封装成一个参数。这样测试用例的主流程干净很多每处等待逻辑都用同一个超时体系不会出现一个脚本里有的地方设了10秒超时、有的地方设了60秒超时的混乱局面。3.3 while循环模拟持续行为玩家巡逻与AI行为测试还有一种场景是让测试角色持续执行某个动作直到外部条件发生变化。比如你要验证一个角色在巡逻状态下是否每隔一段固定时间切换巡逻点这就要用while循环模拟is_patrolling True patrol_count 0 while is_patrolling: hero.walk_to(next_patrol_point) patrol_count 1 time.sleep(3) # 模拟走到下一个点需要的时间 if patrol_count 10: is_patrolling False这段代码的关键是用is_patrolling这个标志位来控制循环退出这比在循环中间用break跳出更利于后续扩展。假设后面要改成在收到服务器消息后停止巡逻你只需要增加一个检查条件把is_patrolling改为False而不需要重写整个循环结构。这也是while循环和bool标志位配合的一个重要设计思路循环的退出条件尽量集中到一处不要散落在循环体内部各个break里否则逻辑复杂以后你根本记不清每个break是从哪里进来的。4. break与continue让测试逻辑按预期跳过和及时止损4.1 break在致命异常场景里的应用停止整轮回归回到我开头说的那个关卡遍历场景。check_level_basic如果断言失败for循环仍然会继续跑下一关这在某些情况下是好事能一次性收集所有关卡的问题但如果遇到的是客户端直接崩溃这种致命错误继续跑就没有意义了。后面的关卡根本进不去只会喷一堆无意义的错误日志白白消耗时间。这时候就需要break手动中断循环for level_id in level_list: try: check_level_basic(level_id) except CrashError as e: print(f关卡 {level_id} 触发崩溃立即停止本轮测试) raise # 或者 break取决于你的用例框架这里有一个决策点是break还是raise整轮测试。我的习惯是如果用例框架允许单条用例失败后继续跑下一条用例那就用raise把异常抛给框架处理如果是自己写的裸脚本就用break跳出循环然后打印一段汇总信息。break的语义其实就是当前这个循环已经没有必要继续了识别出这种场景并用break解决是测试脚本从能跑走向会用的一个分水岭。4.2 continue在无效数据过滤中的应用跳过不合法条目游戏测试里经常遇到遍历大列表但只处理其中一部分数据的需求。比如你从游戏服务器返回的背包数据里想要检查所有杂物类道具的数量是否正常。背包里可能有几百个道具包括金币、钻石、装备、材料、杂物等等你只需要关注杂物类。一种写法是用if加条件判断嵌套在循环里但更pythonic的写法是用continue跳过不需要的数据for item in backpack_items: if item.category ! misc: continue expected_count misc_config[item.item_id] assert item.count expected_count, f杂物 {item.item_id} 数量异常读这段代码的逻辑非常顺不相关的物品直接跳过剩下的全是需要处理的杂物再逐项断言。如果没有continue你就得把后面所有处理逻辑都缩进到一个if块里嵌套层数多了以后代码可读性会明显下降。continue和break经常被放在一起讲但它们的定位完全不同break是整个循环都不继续了continue是这一次循环不处理了但下一次照常。理解了这一点你就不会写出continue之后还要执行电梯代码这种低级bug。4.3 循环控制结构的应用边界什么场景不该用break说一个我在代码评审里反复强调的观点break不是越多越好有些场景用break反而会把逻辑搞乱。举一个具体例子。你要从一个角色列表里找到第一个血量低于20%的角色很多人的第一反应是for循环加breakfor role in role_list: if role.hp_percent 20: target_role role break这段代码能达到目的但可读性并不好因为target_role到底是什么时候赋值的需要看完整段循环才知道。更清晰的做法是使用next加生成器表达式target_role next((role for role in role_list if role.hp_percent 20), None)一行代码就完成了查找而且没有循环控制结构。这就是我想说的循环语句熟练之后你要学会判断哪些地方其实不需要循环。break只在循环到一半发现没必要继续跑的时候使用如果只是想找一个元素优先考虑next、any、all这些内置工具。这个习惯会让你的脚本从会写变成写得好。5. 循环嵌套与配合从单关卡校验到复杂场景组合5.1 嵌套循环遍历二维地图坐标校验实战游戏测试里经常碰到二维结构最典型的就是地图网格。假设地图是一个8x8的格子棋盘你要验证每个格子是否可以放置防御塔。两层for循环一嵌套坐标遍历就出来了map_size 8 for row in range(map_size): for col in range(map_size): if can_place_tower(row, col): print(f格点 ({row}, {col}) 可放置防御塔) else: print(f格点 ({row}, {col}) 不可放置防御塔)这里的执行顺序是外层循环先固定row为0内层循环把col从0到7全部跑完然后外层row变为1内层再跑一遍。这个顺序非常重要很多新手以为两层循环是同时跑实际是外层跑一步、内层跑一整圈。如果只想检查地图上某个特定区域比如左上角4x4的区域可以把range的区间改成range(4)嵌套或者给两层循环分别设置起始和结束值。控制好循环边界比在循环体内部加一堆if判断要清爽得多。5.2 嵌套循环与break的执行顺序坑break只跳最内层接下来是嵌套循环里最大的坑。下面的代码目标是找到第一个血量低于20%的角色找到后立刻停止遍历所有队伍for team in teams: for role in team.roles: if role.hp_percent 20: print(f找到残血角色 {role.name}) break你以为break会跳出所有循环错了。它只会跳出内层的for role循环外层for team循环还会继续执行——于是每个team都会找到一个残血角色逻辑完全偏离预期。这个bug在测试脚本里非常隐蔽因为日志打印出来看确实每个队伍都有一个残血角色你还以为是自己测试数据有问题排查半天才发现是break没跳出两层。解决方案有两种。第一种是用标志位found False for team in teams: for role in team.roles: if role.hp_percent 20: print(f找到残血角色 {role.name}) found True break if found: break第二种更干净把双层循环封装成函数用return替代breakdef find_first_low_hp_role(teams): for team in teams: for role in team.roles: if role.hp_percent 20: return role return None我强烈推荐第二种写法。它不只是解决了break跳层问题还把查找第一个符合条件元素这个逻辑独立出来方便复用。return在这种情况下天然具备立即退出所有循环的语义测试用例读起来也直观得多。5.3 循环搭配异常处理单个用例挂掉不拖垮整轮测试游戏测试脚本跑全量回归的时候最怕的是一个异常把整轮跑崩。假设你遍历100个关卡第37个关卡因为环境问题报了一个没见过的异常如果不加处理脚本直接中断后面63个关卡全部没有测试数据这轮回归的时间就白费了。正确做法是for循环内部搭配try/except把异常捕获并记录下来循环继续pass_count 0 fail_count 0 error_logs [] for level_id in level_list: try: check_level_basic(level_id) pass_count 1 except AssertionError as e: fail_count 1 error_logs.append(f断言失败: {e}) except Exception as e: fail_count 1 error_logs.append(f未知异常: {e}) print(f通过 {pass_count} 个失败 {fail_count} 个) for log in error_logs: print(log)这个模式有几个好处。第一单关崩溃不会影响后续关卡执行第二所有失败信息都汇总到error_logs里跑完统一打印不用盯着屏幕看实时日志第三通过数和失败数的统计可以直接作为测试报告的数据源。这种收集异常、最后统一处理的思路比在循环里遇到错误就立即退出要更适合大批量回归场景。不过要提醒一句这个模式不适合崩溃后遗症严重的场景比如某个关卡崩溃导致整个客户端掉了后续关卡全部进不去那你收集的失败信息全是无意义的。这种情况需要加入上次崩溃后重置客户端的逻辑可以在循环里加一个标志位检测到崩溃就重启客户端再继续这里就不展开了。6. 性能与选型游戏测试脚本里循环语句的实战经验6.1 range和list的差异遍历大列表的隐形性能开销游戏测试有时候要遍历的集合非常大比如检查一个玩家背包里的几千个道具或者扫描地图上几万个NPC的位置。这时候你就要在意range对象和list的差异了。Python 2时代range返回的是list会一次性在内存里生成整个列表Python 3里range返回的是惰性对象每迭代到一个位置才产生一个数。看这段代码# 不推荐额外分配了一个百万元素的列表 for i in list(range(1_000_000)): do_something(i) # 推荐range惰性生成内存开销极小 for i in range(1_000_000): do_something(i)1百万个整数在内存里大约多个几十MB在PC上可能感受不明显但如果你是在测试机、低配云服务器上跑脚本这种额外开销就没有必要。更关键的是list(range(n))会先花时间构建完整列表然后才开始遍历整体速度更慢。同理如果某个接口返回了一个巨大的列表但你只需要遍历一次不要为了可以索引访问而复制一份直接for遍历原列表就好。测试脚本追求的是快速、稳定、够用不是追求花哨的数据结构操作。6.2 列表推导式与生成器表达式过滤数据的简洁之道前面讲continue时写了过滤无效数据的写法其实用列表推导式可以一行搞定# 传统forcontinue写法 valid_drops [] for drop in drops: if drop.id not in invalid_ids: valid_drops.append(drop) # 列表推导式 valid_drops [drop for drop in drops if drop.id not in invalid_ids]列表推导式本质上还是循环只是语法更紧凑。游戏测试里用它来过滤数据、清洗配置、批量构造测试数据都非常顺手。比如你要从几百个道具配置里取出所有品质为5的道具ID列表legendary_ids [item.id for item in item_configs if item.quality 5]一行代码后面想传给接口做批量验证也方便。如果过滤出来的数据量很大用生成器表达式更省内存legendary_ids (item.id for item in item_configs if item.quality 5)区别在于中括号vs圆括号。中括号一次性生成整个列表圆括号生成一个生成器对象每取一个元素才算一个。在数据量几千几万的时候这个差异不大但在几十万上百万的时候生成器表达式的内存优势就体现出来了。我的建议是日常测试脚本用列表推导式就够只有当你明确知道数据量特别大且只需要遍历一次时才换成生成器表达式。代码可读性的优先级很高不要为了省那几MB内存写出让人看不懂的代码。6.3 我在游戏测试里踩过的循环语句真实坑最后把我在真实项目里踩过的几个坑集中整理一遍这些坑都不是什么高深的技术问题但每一个都浪费过我不少排查时间。第一个坑在循环里修改正在遍历的列表。有一次我在遍历一个村庄NPC列表时发现某个NPC行为异常我在循环里直接调用了remove把这个NPC从列表里删了。Python列表在遍历过程中删除元素会导致后面的元素索引整体前移于是漏掉了紧接着的下一个NPC测试结果出现漏检。这个问题的通用解法是遍历列表的副本for npc in npc_list[:]: if npc.behavior abnormal: npc_list.remove(npc)npc_list[:]会生成一个浅拷贝遍历拷贝修改原列表两不相干。第二个坑轮询间隔设得太短。我一开始写等待战斗结束逻辑时想着查得越频繁越好把interval设成0.05秒结果每次测试要查询几千次服务器状态给服务器造成了不小压力还被开发同事投诉了。后来我把间隔调整到0.5秒发现对功能来说没有感知差异但服务器压力降了一个数量级。游戏测试里等待的功能节点比如战斗结束、加载完成通常几百毫秒级别的延迟完全不影响判断结果间隔设0.5秒最合适。如果要求特别快的场景间隔可以压到0.2秒一般都够用。第三个坑while循环的退出条件用or连接时先后顺序没想清楚。写过类似这样的代码while count 10 or not is_finished: ...我本意是在计数小于10且未结束之前循环但用了or之后只要未结束这个条件成立即使count已经超过10也会继续循环。正确写法应该是用and。这个小问题排查了很久最后是逐行读代码才发现的。教训是while的退出条件一定要写在纸上想清楚特别是多个条件组合时别凭感觉写。第四个坑循环体内部打印日志太频繁。我跑过一个遍历3000个道具的脚本每个道具都print一条日志结果日志文件刷了几百MB排查问题的时候翻日志翻到崩溃。后来大型循环里我改用每处理100条打印一次进度的方式for i, item in enumerate(item_list): process(item) if i % 100 99: print(f已处理 {i 1}/{len(item_list)} 条)enumerate可以同时拿到下标和元素条件是取模每隔100次打印一次。这样既能看到进度又不会刷爆日志。7. 把循环语句封装成可复用的测试工具函数写到这里我想分享一个实战中非常实用的做法把循环等待、循环重试、循环统计这三类高频逻辑封装成工具函数沉淀到项目公共库里。这样每个测试用例都不用重复写超时保护更不容易踩死循环的坑。我维护过一个简单的测试工具模块核心就两个函数。一个是前面已经提过的wait_until另一个是retrydef retry(func, retries3, interval1, *args, **kwargs): 执行func直到成功最多重试retries次 for attempt in range(1, retries 1): try: result func(*args, **kwargs) print(f第 {attempt} 次执行成功) return result except Exception as e: print(f第 {attempt} 次执行失败: {e}) if attempt retries: time.sleep(interval) else: raise它做的事情是尝试执行某个操作如果失败等一秒再试第二次直到超过最大重试次数后抛出最后一次异常。游戏测试里很多操作都带偶发性比如网络抖动导致登录失败、资源没刷出来导致点击失效用retry包一层用例稳定性大大提升。这些工具函数的共同特点是把循环这个底层动作从业务逻辑里抽离出来测试用例作者只需要关注我要做什么和在什么条件下算成功不需要关心循环怎么写、超时怎么处理、失败怎么办。好的测试代码和差的测试代码差别往往就在这一层抽象上。从我自己的体会来说掌握循环语句的语法只是第一步真正重要的是多思考你写的每个循环会不会永不停止、会不会漏数据、会不会重复执行。跑测试的时候脚本崩在循环里是最让人头疼的问题但如果你的循环代码从设计上就考虑好了超时、异常和退出条件大部分崩溃都是可以提前避免的。希望这篇围绕游戏测试场景展开的循环语句实战笔记能帮你少走一些弯路。