快速开发一个Monkey自动挂测的Python测试工具(PyQt5)
基于之前对于PyQt5内容的学习 + subprocess的一些小摸索。
打算自己写一个挂测Monkey的Python工具,也是工作需要
目标实现的功能有:获取连接的设备MAC地址,选择设备,实时展示挂测动作+日志,日志保存
准备条件
adb 的 安装
MuMu模拟器(由于我并没有空余的安卓机器,所以这里使用模拟器来实现)
MuMu模拟器的端口是:127.0.0.1:5555 记得打开ADB调试开关
获取对应的设备Mac地址
先connect 一下 看看执行 adb devices之后,返回的是什么?
C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555 device
上面是只开了一个设备的情况,如果多开了设备,则可以使用以下命令,查看目标端口,并对模拟设备进行连接
C:\Users\60399>netstat -ano | findstr LISTENING | findstr 127.0.0.1
TCP 127.0.0.1:5037 0.0.0.0:0 LISTENING 16964
TCP 127.0.0.1:5555 0.0.0.0:0 LISTENING 16416
TCP 127.0.0.1:5557 0.0.0.0:0 LISTENING 18664
TCP 127.0.0.1:7555 0.0.0.0:0 LISTENING 16416
TCP 127.0.0.1:7555 0.0.0.0:0 LISTENING 18664
TCP 127.0.0.1:7890 0.0.0.0:0 LISTENING 14972
TCP 127.0.0.1:10000 0.0.0.0:0 LISTENING 15972
TCP 127.0.0.1:16384 0.0.0.0:0 LISTENING 16416
TCP 127.0.0.1:16416 0.0.0.0:0 LISTENING 18664
TCP 127.0.0.1:20017 0.0.0.0:0 LISTENING 17792
TCP 127.0.0.1:59385 0.0.0.0:0 LISTENING 5680
TCP 127.0.0.1:59386 0.0.0.0:0 LISTENING 14972
C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555 device
C:\Users\60399>adb connect 127.0.0.1:5557
connected to 127.0.0.1:5557
C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555 device
127.0.0.1:5557 device
这里其实可以发现MuMu模拟器使用的IP地址都是相同的,不过多开设备只是端口号不同
可以发现执行了adb devcies的返回结果,第一行是 list of devices attached,第二行才是IP地址+端口 以及设备
- 先获取到设备的ip地址
#获取已连接的设备
def get_devicdes():
response = subprocess.Popen(['adb','devices'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
stdout,stderr = response.communicate()
#假设错误,输出错误结果
if stderr:
print(f"error:{stderr}")
return []
#获取设备IP地址
devices = []
lines = stdout.decode().strip().splitlines()[1:]
for line in lines:
if line.strip() and 'device' in line:
device_id = line.split()[0]
devices.append(device_id)
return devices
-
如何通过已知的IP地址对目标的MAC地址
adb shell cat /sys/class/net/wlan0/address是获取 Wi-Fi MAC 地址的常见方式;如果是一些国产机或者魔改系统,Wi-Fi 设备接口名可能是
wlan1、eth0等,可以先通过adb shell ifconfig或ip link查看。#获得mac地址 def get_macAdd(ipadds): macAdd = [] for ipadd in ipadds: response = subprocess.Popen(['adb','-s',ipadd,'shell','cat','/sys/class/net/wlan0/address'],stderr=subprocess.PIPE,stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True) stdout,stderr = response.communicate() if stderr: print(f'error:{stderr}') macAdd.append(stdout.strip()) return macAdd看一下运行效果吧!
PS D:\Study\to the place\GIT-REPOSITORY-FOR-CURSOR> & D:/Python/python.exe "d:/Study/to the place/GIT-REPOSITORY-FOR-CURSOR/Software-tester-plan/monkey-Learn-day/PyQt5_monkey_tester.py" ['127.0.0.1:5555', '127.0.0.1:5557'] PS D:\Study\to the place\GIT-REPOSITORY-FOR-CURSOR> & D:/Python/python.exe "d:/Study/to the place/GIT-REPOSITORY-FOR-CURSOR/Software-tester-plan/monkey-Learn-day/PyQt5_monkey_tester.py" ['08:35:fd:cc:2a:7e', '08:bd:d2:87:f3:94']成功!
Monkey基本内容
首先最重要的,获取我们要测试的APP的包名。
获取途径很多,推荐直接找开发要一个(APP是要上市的产品的话)。
如果是网上可以直接获取的APP呢,那么也可以直接在网上搜。
我们这个测试的对象呢就用自带的浏览器了,对应的包名:com.android.browser
其他途径:
adb shell pm list packages
adb shell dumpsys window windows | findstr mCurrentFocus
#我不知道是不是模拟器的原因,我使用第二行命令时,始终也获取不到。。如果有人知道可以评论下(感谢)
Monkey挂测命令格式
adb shell monkey [options] <event-count>
adb shell monkey:在设备中调用Monkey工具[options]:各种可选参数<event-count>:指定发送多少个随机事件
基本参数:
| 参数 | 含义 |
|---|---|
-p <package> |
指定要测试的应用包名(可重复写多个) |
--ignore-crashes |
崩溃时继续执行测试,不中断 |
--ignore-timeouts |
忽略 ANR 错误继续测试 |
--ignore-security-exceptions |
忽略权限相关异常继续执行 |
--monitor-native-crashes |
监控 native crash(如C层崩溃) |
--kill-process-after-error |
出错后立即杀死目标 App 进程 |
--bugreport |
生成更详细的 bug 报告日志 |
控制事件类型百分比:
| 参数 | 含义(默认总和为100%,可根据需求调整) |
|---|---|
--pct-touch <n> |
触摸事件所占比例(默认 15%) |
--pct-motion <n> |
滑动/拖动事件比例 |
--pct-trackball <n> |
滚轮事件比例(极少用) |
--pct-nav <n> |
导航事件,如方向键 |
--pct-majornav <n> |
主页、菜单等导航事件 |
--pct-syskeys <n> |
系统按键事件,如 Home/Back |
--pct-appswitch <n> |
模拟应用间切换 |
--pct-anyevent <n> |
其他未分类事件 |
间隔与种子:
| 参数 | 含义 |
|---|---|
--throttle <ms> |
每次事件间的间隔(毫秒)建议设为 300~500 |
--seed <n> |
设置随机种子,便于复现测试 |
-v |
输出日志级别(可写多次,越多越详细) |
bash中的标准挂测命令:
adb shell monkey -p com.example.app \
--throttle 500 \
--ignore-crashes \
--ignore-timeouts \
--monitor-native-crashes \
--ignore-security-exceptions \
--pct-touch 80 \
--pct-motion 10 \
--pct-appswitch 5 \
--pct-syskeys 5 \
--bugreport \
-v -v -v 10000 > monkey_log.txt
Monkey在Python中的部分
传入参数部分:
def __init__(self,ipadd,package_name,throttle=500,event_count=1000):
super().__init__()
#可变参数传入
self.device = ipadd
self.throttle = throttle
self.package_name = package_name
self.event_count = event_count
self.is_running = True
self.process = None
这里,最好不要写死包名,还有间隔、事件数这些东西。可以更加方便复用操作
线程run部分:
#线程开始之后要做的事情
def run(self):
if not self.is_running:
return
# 构建adb monkey命令
command = [
'adb', '-s', self.device, 'shell', 'monkey',
'-p', self.package_name,
'--throttle', str(self.throttle),
'-v', str(self.event_count)
]
try:
self.process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='ignore'
)
self.log_signal.emit(f"Monkey test start!")
#循环读取输入
for line in self.process.stdout:
#检查线程是否被外部请求停止:
if not self.is_running:
break
self.log_signal.emit(line.strip())
#等待进程执行完毕,确保资源被释放
self.process.wait()
except FileNotFoundError:
self.log_signal.emit("ERROR:'adb' command not found")
except Exception as e:
self.log_signal.emit(f'unexcepted error occurred:{e}')
finally:
self.log_signal.emit("Monkey thread finished------------------------------")
self.stop_signal.emit("stopped")
详细阐述Run与Stop中两次不同的线程停止操作,分别起到了什么作用(一开始我是把Monkey与线程混淆了,这里做一个详细的说明)
run() 方法中的检查:if not self.is_running: break
这个检查是在 for 循环内部,也就是在读取子进程输出的这个环节。
-
它的作用是什么? 它的作用是让 Python 线程停止等待和处理 adb monkey 命令返回的日志。当 stop() 方法将 self.is_running 设置为 False 后,这个循环在下一次迭代时会发现这个变化,然后执行 break,跳出循环。
-
它的局限性是什么? break 只是跳出了 for line in self.process.stdout: 这个循环。它并没有终止那个独立的 adb monkey 子进程。那个子进程依然在安卓设备上欢快地运行,继续执行点击、滑动等操作,直到它自己完成指定的事件数。
-
那后面的 self.process.wait() 呢? wait() 方法会阻塞当前线程,直到子进程完全结束。所以,如果仅仅依靠 break 跳出循环,线程会卡在 wait() 这里,一直等到 monkey 测试自己跑完1000次事件后退出。这显然不是我们想要的“立即停止”的效果。
所以,run() 里的检查只是一个“优雅退出循环”的机制,但它本身无法实现“立即停止测试”。
stop() 方法中的 terminate() 和安全检查
stop() 方法是由外部(比如用户点击“停止”按钮)调用的,它的目标就是立刻终止测试。
-
self.process.terminate() 的作用是什么? 这是最关键的一步。terminate() 会向操作系统发送一个信号,要求强制终止由 subprocess.Popen 创建的那个子进程(也就是 adb monkey 进程)。这才是真正让 monkey 测试停下来的命令。
-
那为什么需要 if self.process and self.process.poll() is None: 这个判断?
这是一个非常重要的安全检查,避免程序在不恰当的时候执行 terminate() 而崩溃。我们来分解它:
-
if self.process: 检查 self.process 对象是否存在。有可能 run() 方法在启动子进程之前就出错了(比如 adb 命令没找到),此时 self.process 就是 None。如果没有这个检查,调用 None.poll() 会直接导致程序崩溃。
-
and self.process.poll() is None: self.process.poll() 是一个无阻塞的检查方法。
-
如果子进程仍在运行,poll() 会返回 None。
-
如果子进程已经结束,poll() 会返回它的退出码(一个整数,比如 0 表示正常退出)。
所以,整个 if 语句的含义是:“如果子进程对象存在,并且经过检查发现这个子进程当前确实还在运行,那么就执行终止操作”。
这个检查可以防止两种错误:
-
尝试去终止一个从未成功启动的进程。
-
尝试去终止一个已经自己运行结束的进程。
我挂测时,注意到一个问题:
使用如下代码终止挂测进程后,我所打开的模拟器仍然在对目标程序进行monkey挂测。(重点是下面的stop)
def run(self):
if not self.is_running:
return
# 构建adb monkey命令
command = [
'adb', '-s', self.device, 'shell', 'monkey',
'-p', self.package_name,
'--throttle', str(self.throttle),
'-v', str(self.event_count)
]
try:
self.process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='ignore'
)
self.log_signal.emit(f"Monkey test start!",self.device)
#循环读取输入(报错正常)
for line in self.process.stdout:
#检查线程是否被外部请求停止:
if not self.is_running:
break
self.log_signal.emit(line.strip(),self.device)
#等待进程执行完毕,确保资源被释放
self.process.wait()
except FileNotFoundError:
self.log_signal.emit("ERROR:'adb' command not found",self.device)
except Exception as e:
self.log_signal.emit(f'unexcepted error occurred:{e}',self.device)
finally:
self.log_signal.emit("Monkey thread finished------------------------------",self.device)
self.stop_signal.emit("stopped",self.device)
def stop(self):
self.is_running = False
if self.process and self.process.poll() is None:
try:
self.process.terminate()
self.log_signal.emit("Monkey process terminating...",self.device)
except Exception as e:
self.log_signal.emit(f"Error while terminating process:{e}",self.device)
对应的包仍然在执行monkey程序。
这说明了我并不清楚monkey到底是如何发生的,于是了解后得知:
Monkey的运行是:
- 主机使用adb发出指令,Monkey程序在设备上运行。Monkey作为一个Android系统级命令行工具在目标设备上运行,并开启一个独立的进程
我在stop函数中所做的,只是终止了主机上的ADB Shell连接
所以,我们若想要真正停止设备上的挂测,应当还有以下步骤:
-
通过命令行,找到各个挂测设备对应执行Monkey的进程
-
通过命令行,kill掉设备上的Monkey程序
步骤一:
找到执行Monkey的进程
adb -s 127.0.0.1:5555 shell ps | findstr monkey
注意,我这里使用的是windows系统的电脑,所以使用findstr,一开始找的教程让我用grep,我还傻乎乎地试了。改天再好好复习下linux,忘了老多了。。
返回内容为:
shell 2565 1170 12856376 84280 futex_wait_queue_me 0 S com.android.commands.monkey
其中2565为进程号 1170是父进程号
步骤二:
把这个进程kill掉
修改后的stop函数如下:
def stop(self):
self.is_running = False
if self.process and self.process.poll() is None:
try:
self.process.terminate()
self.log_signal.emit("Monkey process terminating...",self.device)
except Exception as e:
self.log_signal.emit(f"Error while terminating process:{e}",self.device)
#kill设备上的Monkey进程
try:
result = subprocess.run(
f'adb -s {self.device} shell ps | findstr monkey',
shell = True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
#返回命令一般是:shell 2565(对应进程) 1170 12856376 84280 futex_wait_queue_me 0 S com.android.commands.monkey
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) > 1:
pid = parts[1]
kill_cmd = ['adb','-s',self.device,'shell','kill',pid]
subprocess.run(kill_cmd)
self.log_signal.emit('设备上Monkey进程已被kill------------------------')
except Exception as e:
self.log_signal.emit(f'error killing monkey on device{e}',self.device)
关于subprocess与QThread的关系?
这点我觉得比较容易混淆,自己在这里做个特别的说明。防止未来遗忘
-
subprocess.Popen subprocess.run 都会在操作系统层面启动一个新的子进程---执行操作者所给出的命令
-
QThread是Qt框架提供的线程,和UI的线程同属于一个进程空间,只是多了一个执行流--用来做耗时的、阻塞的任务
两者的结构:
在这个工具中,我写了一个UI界面(主进程),然后对每个设备的monkey执行,又要去分管出一个子线程(QThread),就是每个QThread又会用subprocess.Popen,去管理Monkey命令的执行
我的stop方法中呢,有分出了两个子进程subprocess,一个去找设备中的Monkey进程,一个去kill掉它
总结
主线程(UI)→ 多个 QThread(后台任务)→ 每个 QThread 里有自己的 subprocess(子进程)。
关于保存日志
基本思路:UI界面的保存日志按钮-click事件发出信号-引起savelog的槽函数-槽函数中新建线程-线程中获取当前文件路径+时间戳进行保存-返回信号-通过信号弹出结果,并销毁线程
这个部分因为我们是模拟器,所以设备号是带有 :的
但是我们文件名中是不允许有 :的,所以我们保存时要注意替换!
保存日志的进程代码:
获取当前程序的路径需要用到os.path.abspath()
如果打包为exe文件了,注意参数名替换为sys.executable
#保存日志的线程
class Log_thread(QThread):
send_signal = pyqtSignal(str,str)
def __init__(self,device_id,content):
super().__init__()
self.content = content
self.device_id = device_id
def run(self):
try:
#获得当前脚本的路径
if getattr(sys, 'frozen', False): # PyInstaller打包后的exe
base_dir =os.path.dirname(sys.executable)
else:
base_dir =os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(base_dir,'logs')
os.makedirs(log_dir,exist_ok=True)
#防止文件中的:导致无法保存
safe_device_id = self.device_id.replace(':', '_')
#增加时间戳
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
file_path = os.path.join(log_dir,f'log_{safe_device_id}_{timestamp}.txt')
with open(file_path,'a+',encoding='utf-8') as f:
f.write(self.content)
self.send_signal.emit('log has been saved',self.device_id)
except Exception as e:
self.send_signal.emit(f'fail :{e}',self.device_id)
注意,日志保存线程也要放在字典中,不然容易被Python的垃圾回收机制回收了
总结
这篇文章我从上一次发文之后就一直再写,一共写+开发用了4天左右,也算是详细记录了下自己的代码过程吧。
只能说想要写好的文章真的不容易,又要写清思路,又要注意排版。落泪啊,原本想着每天一更,但是发现要维持作品的质量,还是需要不断地打磨!
最后希望大家给出多的建议,我后面也会不断改进此工具,或者尝试开发其他小工具的!

浙公网安备 33010602011771号