简介:一份基于Python Tkinter开发的Monkey测试可视化工具毕业设计资源包,适合软件测试学习者、GUI开发初学者以及相关课题的学生参考。该工具将Monkey随机测试的启动、参数设置、状态监控与结果反馈集成在图形界面中,让用户无需熟悉命令行即可直观掌握测试进度和异常信息,有效提升测试效率与结果可读性。包内含16个文件,大小约21KB,核心为Python主程序脚本,配套有JSON配置文件、Markdown说明文档、6个批处理脚本与6个日志文件;批处理脚本可辅助自动化运行,日志文件记录了随机事件执行轨迹和测试输出,便于定位程序稳定性问题。压缩包内部还划分为结果目录与脚本子目录,层次清楚,方便按需查阅。目前已有91人学习下载,适合用于学习用Tkinter搭建界面、多线程并发控制及Monkey测试的落地实现。
1. 把Monkey测试搬进Tkinter窗口,到底解决了什么问题
Android发版前跑Monkey是常规操作,但命令行里跑Monkey的体验一直不好:一长串参数记不住、日志刷屏看不到进度、出了crash还要回头去logcat里翻堆栈、每次都要现算种子。这个基于Python Tkinter的可视化工具,本质就是把adb shell monkey的命令构造、子进程管理、日志实时展示和结果解析全部收进一个桌面窗口里。QA点几下按钮就能跑一轮测试,崩溃时间点直接标记在进度条上,报告自动落到本地CSV。适合做Android测试的QA、写自动化脚本的工程师,以及想给团队省事的客户端开发。
2. Monkey命令的Python封装:从subprocess到参数校验
2.1 Monkey测试的核心参数:种子、事件间隔与事件百分比
Monkey测试的原理是向系统发送伪随机事件流,事件序列完全由seed决定。同一个seed在同一个包上重跑,事件顺序完全一致,这是复现崩溃的基础。命令行里直接敲adb shell monkey能跑,但放到工具里,命令必须由程序拼出来,参数得先过一遍校验。
以下是Monkey常用参数,做可视化工具时这些必须暴露到界面上,不能写死:
| 参数 | 作用 | 工具界面落点 |
|---|---|---|
-p <package> | 限定测试包名,不写会打全系统 | Text输入框,必填 |
-s <seed> | 伪随机数种子,复现崩溃用 | Spinbox,可填空 |
--throttle <ms> | 事件间隔,太快容易误报ANR | Spinbox,默认300 |
--pct-touch <n> | 触摸事件百分比 | Scale滑块 |
--pct-motion <n> | 滑动事件百分比 | Scale滑块 |
--pct-trackball | 轨迹球事件百分比 | Scale滑块 |
<count> | 总事件数,最大可到几百万 | Entry,默认5000 |
实际工作中,touch和motion的比例加起来建议控制在60%到80%,其余留给系统事件。如果被测应用是视频类,motion应该调高到50%以上;如果是表单类,touch占比高更合理。工具界面把这些百分比做成滑块而不是输入框,就是防止填出总和超过100的组合。
2.2 用subprocess.Popen管理adb进程并实时读取日志
Python跑外部命令有两个选择:os.system和subprocess。工具必须用subprocess.Popen,只有它能拿到子进程的stdout句柄做逐行读取,而且能通过start_new_session把adb进程放进独立进程组,便于彻底终止。
import subprocess import queue def build_monkey_cmd(package: str, seed: int, count: int, throttle: int, pcts: dict) -> list: cmd = ["adb", "shell", "monkey"] cmd += ["-p", package] if seed is not None: cmd += ["-s", str(seed)] cmd += ["--throttle", str(throttle)] cmd += ["--pct-touch", str(pcts.get("touch", 50))] cmd += ["--pct-motion", str(pcts.get("motion", 30))] cmd += [str(count)] return cmd def start_monkey(cmd: list, log_queue: queue.Queue): proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True, ) for line in proc.stdout: log_queue.put(("log", line.rstrip())) ret = proc.wait() log_queue.put(("done", ret))bufsize=1表示按行读取而不是按块缓冲,这样UI上看到的日志接近实时。stderr=subprocess.STDOUT把错误输出合并进主输出,Monkey很多关键信息走的是stderr,不合并会丢内容。text=True把字节流转成字符串,省去手动decode。
终止Monkey时不能只调proc.kill(),因为python杀掉的只是adb shell进程,设备端monkey可能还在跑。正确做法是:
import os, signal def stop_monkey(proc: subprocess.Popen): if proc is None: return try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except ProcessLookupError: pass subprocess.run(["adb", "shell", "pkill", "-f", "monkey"], capture_output=True)os.killpg杀掉整个进程组,避免adb残留。pkill -f monkey是兜底,确保设备端也停掉。
2.3 参数校验:不把脏数据交给adb
界面填了参数直接拼命令下发,这是工具做得粗糙的表现。adb shell monkey对参数的容错有限:count不是整数直接报错,包名带了空格会把整个命令拆碎。校验放在UI层和命令构造层之间,错误就地提示,绝不进命令。
def validate_params(package: str, count: int) -> list[str]: errors = [] if not package.strip(): errors.append("包名不能为空") if not re.match(r"^[a-zA-Z0-9_.]+$", package.strip()): errors.append("包名格式不正确,只能包含字母、数字、点和下划线") if count < 100: errors.append("事件数至少100,否则跑不出有效结果") if count > 200000: errors.append("事件数过大,建议分轮执行") return errors包名校验用正则而不是只判空,是因为Monkey的-p参数会直接拼进shell命令,虽然有shlex处理,但还是尽早把非法输入挡在门外。count的上下限是实践总结:低于100事件,崩溃概率极低;高于20万,单轮执行太久,中途设备容易锁屏。
2.4 设备在线检测:没有设备时的兜底逻辑
工具启动时和执行前都要查设备状态。adb devices输出三列:序列号、状态、型号。状态为device表示在线,offline表示连接异常,unauthorized表示USB调试未授权。
def get_devices() -> list[str]: out = subprocess.run(["adb", "devices"], capture_output=True, text=True).stdout lines = out.strip().splitlines()[1:] return [line.split()[0] for line in lines if line.strip() and "device" in line and "offline" not in line]检测通过后在界面状态栏显示设备序列号。多设备连接时,Monkey命令需要加-s <serial>指定设备,否则adb会报"more than one device"。可视化工具如果面向团队内部用,至少处理单设备场景,多设备场景提示用户先断开多余设备。
3. Tkinter界面:控制面板、实时日志与事件循环的协作
3.1 界面布局:左控制右日志的面板结构
Tkinter做这种工具,布局用ttk.Frame配合pack就够,不需要上grid。常见布局是左侧固定宽度放参数区,右侧用PanedWindow分隔日志区和状态区,这样窗口拉伸时日志区自动扩展。
from tkinter import ttk import tkinter as tk root = tk.Tk() root.title("Monkey Test Visualizer") main_panel = ttk.PanedWindow(root, orient=tk.HORIZONTAL) main_panel.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) left = ttk.Frame(main_panel, width=280) right = ttk.Frame(main_panel) main_panel.add(left, weight=0) main_panel.add(right, weight=1) # 左侧参数区 tk.Label(left, text="包名:").pack(anchor=tk.W, pady=(8, 2)) entry_pkg = tk.Entry(left, width=30) entry_pkg.pack(fill=tk.X, padx=6) tk.Label(left, text="事件数:").pack(anchor=tk.W, pady=(8, 2)) spin_count = tk.Spinbox(left, from_=100, to=200000, increment=100) spin_count.pack(fill=tk.X, padx=6)右侧日志区用tk.Text加滚动条,Text是唯一支持多行彩色文本的组件:
right_frame = ttk.Frame(right) right_frame.pack(fill=tk.BOTH, expand=True) log_text = tk.Text(right_frame, state=tk.NORMAL, wrap=tk.WORD, font=("Consolas", 10)) scroll_bar = ttk.Scrollbar(right_frame, command=log_text.yview) log_text.configure(yscrollcommand=scroll_bar.set) scroll_bar.pack(side=tk.RIGHT, fill=tk.Y) log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)wrap=tk.WORD保证长日志按单词换行,不会从字符中间断开,可读性好很多。字体选等宽字体Consolas,日志里的时间戳和pid能对齐,排查问题时肉眼扫得更快。
3.2 UI线程与Monkey工作线程:queue是唯一的跨线程通道
这是整个工具最容易翻车的地方。Tkinter的UI操作必须在主线程执行,而subprocess的stdout读取是阻塞的,直接放在主线程里界面会卡死。解决方案是开一个工作线程跑start_monkey,把日志逐行put进queue.Queue,主线程用after()周期性poll。
log_queue = queue.Queue() test_thread = None def run_clicked(): global test_thread if test_thread and test_thread.is_alive(): return cmd = build_monkey_cmd(...) log_text.delete("1.0", tk.END) test_thread = threading.Thread( target=start_monkey, args=(cmd, log_queue), daemon=True) test_thread.start() root.after(100, poll_log_queue) def poll_log_queue(): try: while True: kind, payload = log_queue.get_nowait() if kind == "log": append_log(payload) elif kind == "done": btn_run.config(state=tk.NORMAL) except queue.Empty: pass root.after(100, poll_log_queue)这里的线程模型职责划分是:
| 线程 | 职责 | 禁止做的事 |
|---|---|---|
| 主线程 | 界面渲染、参数读取、按钮状态 | 阻塞式读stdout |
| 工作线程 | Popen读取、事件收集 | 调用任何widget方法 |
| 主线程(after回调) | 从queue取数据写界面 | 非阻塞地用完即退 |
after(100, poll_log_queue)的意思是每100毫秒检查一次队列。这个100毫秒是经验值:太短CPU空转,太长日志有明显延迟。工作线程只往queue里put数据,主线程只从queue取数据,这是Tkinter唯一被官方认可的安全跨线程方式。
3.3 按钮状态机:运行中禁止重复启动和误停
工具界面至少有“开始”“停止”两个按钮。运行过程中再点“开始”会同时跑两个monkey,设备端乱套;“停止”在未运行时点,进程对象还是None直接报错。引入简单的状态机解决:
def set_running_state(running: bool): if running: btn_run.config(state=tk.DISABLED) btn_stop.config(state=tk.NORMAL) entry_pkg.config(state=tk.DISABLED) else: btn_run.config(state=tk.NORMAL) btn_stop.config(state=tk.DISABLED) entry_pkg.config(state=tk.NORMAL)运行期间连包名输入框一起禁用,防止用户中途改包名导致输出结果和界面显示不一致。这个细节看似小,实际测试时经常有人跑着跑着改了包名,结果崩溃报告归到错误的包上。
3.4 日志实时滚动与按级别着色
Monkey日志里,CRASH、ANR、WARNING开头的行混在一堆普通输出里,人工盯屏容易漏。用Text的tag_config给不同级别着色,这是Tkinter原生能力,不需要引入第三方组件。
log_text.tag_config("CRASH", foreground="#c0392b", font=("Consolas", 10, "bold")) log_text.tag_config("ANR", foreground="#e67e22", font=("Consolas", 10, "bold")) log_text.tag_config("INFO", foreground="#27ae60") def append_log(line: str): tag = "INFO" if "CRASH" in line: tag = "CRASH" elif "ANR" in line or "NOT RESPONDING" in line: tag = "ANR" log_text.insert(tk.END, line + "\n", tag) log_text.see(tk.END) # 自动滚动到底部see(tk.END)保证新日志永远可见,相当于tail -f的效果。注意不要每行都see一次,如果日志短时间大量涌入,多次调用会拖慢插入速度。更稳的做法是队列里累计了10条以上再滚动一次。
4. 结果解析与可视化:识别CRASH、统计事件注入率、用Canvas画进度
4.1 崩溃信号的识别:CRASH、ANR关键字与堆栈上下文
Monkey跑完不等于测试完成,关键是结果怎么解析。Monkey输出里,崩溃和ANR都有明显标记:
| 输出特征 | 含义 | 处理方式 |
|---|---|---|
// CRASH: com.example.demo (pid 1234) | 应用崩溃 | 记录崩溃包名、pid |
// ANR in com.example.demo | 应用无响应 | 记录ANR时间点 |
// NOT RESPONDING | 同上,旧版本格式 | 兼容识别 |
// Monkey aborted due to error | Monkey自身异常中止 | 终止测试 |
Events injected: 100% | 事件注入完成 | 标记测试完成 |
解析崩溃时不能只看CRASH这一个词,还要抓崩溃前后的20到30行日志作为堆栈上下文。工具里做法是维护一个环形缓冲区,最近50行常驻内存,一旦命中CRASH就把整段缓冲写入报告文件。
import re import collections CRASH_RE = re.compile(r"// CRASH: (\S+) \(pid (\d+)\)") ANR_RE = re.compile(r"// ANR in (\S+)") buf = collections.deque(maxlen=50) crash_count = 0 def parse_line(line: str): global crash_count buf.append(line) m = CRASH_RE.search(line) if m: crash_count += 1 pkg, pid = m.group(1), m.group(2) with open("crash_%s_%s.txt" % (pkg, pid), "w") as f: f.write("\n".join(buf))deque(maxlen=50)在容量满时自动丢弃最老的数据,天然适合做滚动缓冲。崩溃文件按包名加pid命名,同一轮测试里不同进程崩溃不会互相覆盖。
4.2 从Monkey输出中提取事件注入率与执行进度
adb shell monkey会边跑边输出Events injected: xxx这样的计数(旧版本也有,只是密度低),这个数字除以总事件数就是当前进度。按行解析比按字节解析简单,而且解析逻辑可以复用:
EVENT_RE = re.compile(r"Events injected[:\s]+(\d+)") def extract_progress(line: str) -> int | None: m = EVENT_RE.search(line) if m: return int(m.group(1)) if "Monkey aborted" in line or "monkey aborted" in line: return -1 # 异常中止 return None进度信息不只在日志区显示,还在状态栏单独放一个位置,用文字形式展示“已注入 65%”。文字比进度条靠谱,因为进度增量可能一次跳几千,进度条动画会闪。异常中止返回-1,界面识别到后自动弹窗提示并停止按钮闪烁。
4.3 用Canvas绘制事件注入时间线与崩溃标记
Tkinter的Canvas画矩形就能做出不错的时间线图。横轴是事件数,纵轴可以固定画一行,每个崩溃是一个红色竖线标记。
canvas = tk.Canvas(right, height=60, bg="#f8f9fa") progress_bar = None def draw_progress(total_count: int, injected: int): global progress_bar if progress_bar is None: progress_bar = canvas.create_rectangle( 4, 20, 4, 40, fill="#3498db", outline="") w = canvas.winfo_width() - 8 x_end = 4 + max(4, w * injected / total_count) canvas.coords(progress_bar, 4, 20, x_end, 40) def draw_crash_at(injected: int, total_count: int): w = canvas.winfo_width() - 8 x = 4 + w * injected / total_count canvas.create_line(x, 12, x, 48, fill="#c0392b", width=2)canvas.coords更新已有矩形的坐标,比先删后画效率高,而且不会造成画布闪烁。注意winfo_width()在窗口未完成布局时返回1,要等窗口update_idletasks()之后再画,否则横轴比例是错的。崩溃标记用红色竖线画在进度条上方,一眼就能看出崩溃发生在测试的前段还是后段。
4.4 结果导出:CSV报告与可复现种子记录
可视化工具的产出不能停留在界面上,报告要能流转到缺陷系统。CSV格式兼容性最好,字段设计成下面这样:
import csv import time def export_report(crash_events, seed, package, total_count): with open("monkey_report_%d.csv" % int(time.time()), "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["timestamp", "seed", "package", "event_no", "type", "detail"]) for ev in crash_events: writer.writerow([ev["time"], seed, package, ev["event_no"], ev["type"], ev["detail"]])必须记录seed和event_no的组合,这是复现的钥匙。Monkey的崩溃和当时注入到第几个事件强相关,拿同一个seed重跑,事件序列完全一样,崩溃大概率复现。报告文件名带时间戳避免覆盖历史结果,测试结束后界面直接提示CSV生成路径。
5. 打包成exe与三个实战小技巧
5.1 PyInstaller打包:不要把adb路径写死在代码里
工具最终要交给不装Python的QA用,PyInstaller打包是常识。pyinstaller -F -w monkey_tool.py生成单文件无控制台窗口的程序。关键问题是adb的路径:代码里写死C:\platform-tools\adb.exe,换台机器就废。正确做法是在可执行文件同级目录找adb,找不到再从环境变量读:
import os, sys def find_adb() -> str: base = os.path.dirname(sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)) local_adb = os.path.join(base, "adb.exe") if os.path.exists(local_adb): return local_adb return "adb"sys.frozen是PyInstaller运行时注入的属性,用它判断当前是脚本还是打包后的exe,路径基准才正确。打包目录里放一个platform-tools文件夹,把adb.exe放进去,甚至可以把adb命令封装函数里的调用全部改成find_adb()的返回值。
5.2 技巧一:崩溃后自动重跑同一种子
工具在崩溃事件发生后,弹窗询问“是否用相同种子重跑”。直接修改seed参数为刚才的seed,count改成原来的一半,目标不是把崩溃再吓出来,而是确认崩溃是否稳定复现。稳定复现的bug才值得提单,偶现的要附带设备型号和系统版本信息。这个流程自动化的价值是省去手工抄种子和重新敲命令的时间。
5.3 技巧二:用throttle控制测试节奏防备误报
--throttle参数不只是控制速度,更直接影响ANR判定。设备性能差时,事件间隔小于100ms容易把正常卡顿误报成ANR,真实原因其实是注入太快,CPU调度不过来。一般建议压力测试用150ms到300ms,长稳测试用500ms。界面Spinbox的默认值直接设300,并在旁边标注“过小易误报ANR”。
5.4 技巧三:预置三套参数场景化配置
工具里放三个预设按钮:冒烟测试(500事件、throttle 300、touch 60%)、常规压力(5000事件、throttle 200、touch 50% motion 30%)、长稳验证(50000事件、throttle 500、touch 40% motion 40%)。一键填充到参数控件里,免去QA每次手动拖滑块。这三套参数是Monkey测试最常见的使用场景拆解,覆盖了功能验证、快速压测和过夜稳定性三类需求。预设值直接写在配置文件里,团队内部可以根据业务自定义。
本文还有配套的精品资源,点击获取