中介軟體自動化測試框架 cmdlinker
背景
作為一箇中介軟體的測試工程師,如何對於中介軟體提供的命令進行自動化的迴歸,這一直是一個難題,市面上好像缺乏了對於命令進行自動化迴歸的合理解決方案。
常見方式有下面兩種:
- 直接寫字串的命令,然後使用各種程式語言的 SSH 庫進行連線,然後執行命令字串,獲取執行結果,如果需要對傳入的引數進行校驗,還需要各種提取字串,或者設定各種命令變數,比較繁瑣
- 編寫命令物件,透過操作命令物件 set/get 方式,生成命令字串給 SSH 庫進行命令執行,如果需要對傳入的引數進行校驗,只需要透過命令物件 get 的方式進行獲取,當然缺點就是,每次都需要進行大量的工作去編寫命令物件,重複性勞動
透過對比之後,會發現,使用第二種方式更加直觀、簡潔,提取響應資料也更加方便,但是第二種方式對於大量重複性的去編寫命令物件的方式,也讓人十分噁心。如果有一個工具能夠幫助大家去生成命令物件,然後透過呼叫命令物件執行請求響應的方式就能讓大家從這種困境中走出來。
所以引入了 cmdlinker 這個開源 python 庫,cmdlinker 能夠幫助我們做什麼?
- 透過編寫命令物件 yaml 的方式,生成命令物件,無需手動進行命令物件程式碼的編寫
- 命令物件自帶 runner,可以直接獲取命令執行的請求響應
- 支援本地 shell 執行及遠端 ssh 執行的方式 # 安裝 ~~~shell pip3 install cmdlinker ~~~
文件地址
https://github.com/chineseluo/cmdlinker
案例演示:
使用 docker 命令舉例,查詢容器,獲取容器配置資訊,斷言容器的 ID 是否包含請求的 ID
編寫 yaml 檔案:
entry: "docker"
mapping_entry: "Docker"
module_name: "docker"
class_name: "Docker"
out_path: "./"
mode: "SSH"
parameters:
- mapping_name: "ps"
original_cmd: "ps"
value: False # 是否需要值
mutex: False # 是否互斥
default: None
- mapping_name: "inspect"
original_cmd: "inspect"
value: True # 是否需要值
mutex: False # 是否互斥
default: None
- mapping_name: "f"
original_cmd: "-f"
value: True # 是否需要值
mutex: False # 是否互斥
default: None
執行生成命令:
CmdLinker init -f ./docker.yaml
生成 python 命令物件
from loguru import logger
from typing import Union, Text
from cmdlinker.builtin.exception import CmdLinkerMutexException, CmdLinkerMulMutexException
from cmdlinker.builtin.ssh_utils import SSHClient
from cmdlinker.builtin.shell_utils import ShellClient
class Cmds:
def __init__(self):
self.index = 0
self.CMD_LIST = []
class Runner:
def __init__(self):
self._mutexs = []
self._gcs = []
self._not_mutexs = []
self.pre: Runner = object
self.root: Runner = object
self.next: Union[Runner] = object
self.main_cmd: Text = None
self.cmds: Cmds = None
self.ssh_client: SSHClient = None
self.sudo: bool = True
self.timeout: int = 20
def _exclude(self, cmd_obj):
if not cmd_obj.mark:
logger.debug(f"命令物件:{cmd_obj.__class__.__name__}未被使用")
return False
else:
return True
def _get_execute_cmd(self, cmd_obj):
if cmd_obj.need_value:
return f"{cmd_obj.meta_data['original_cmd']} {cmd_obj.value}"
else:
return f"{cmd_obj.meta_data['original_cmd']}"
def _get_log_desc(self):
if self.pre == self.root:
log_desc = "子"
elif self.pre is None and self.root == self:
log_desc = "根"
else:
log_desc = "父"
return log_desc
def cmd_checker(self):
log_desc = self._get_log_desc()
logger.info("==" * 20 + f"開啟{log_desc}命令{self.__class__.__name__}合法性檢查" + "==" * 20)
for cmd_obj_str in vars(self).keys():
cmd_obj = getattr(self, cmd_obj_str)
if isinstance(cmd_obj, BaseCmd):
if cmd_obj_str == "next":
continue
if not self._exclude(cmd_obj):
continue
if cmd_obj.mutex:
self._mutexs.append(cmd_obj)
logger.debug(f"{self.main_cmd}新增互斥物件:{cmd_obj},最新互斥物件列表:")
else:
self._not_mutexs.append(cmd_obj)
logger.debug(f"{self.main_cmd}新增非互斥物件:{cmd_obj},最新非互斥物件列表:{self._not_mutexs}")
if len(self._mutexs) > 1:
raise CmdLinkerMulMutexException(self.__class__.__name__, self._mutexs)
if len(self._mutexs) != 0 and len(self._not_mutexs) != 0:
raise CmdLinkerMutexException(self.__class__.__name__, self._mutexs, self._not_mutexs)
if self.pre == self.root:
logger.debug(f"啟用命令物件{self.__class__.__name__} 根命令: {self.root.__class__.__name__} 檢查")
self.pre.cmd_checker()
elif self.pre is None and self.root == self:
pass
else:
logger.debug(f"啟用命令物件{self.__class__.__name__}父命令: {self.pre.__class__.__name__} 檢查")
self.pre.cmd_checker()
self._not_mutexs = []
self._mutexs = []
logger.info("==" * 20 + f"{log_desc}命令{self.__class__.__name__}合法性檢查透過" + "==" * 20)
def runner(self):
self.cmd_checker()
cmd = self.exec_cmd()
self.cmds.CMD_LIST = []
return self.ssh_client.run_cmd(cmd, timeout=self.timeout)
def collector(self):
return self.cmds.CMD_LIST.sort(key=lambda cmd_obj: cmd_obj.index)
def exec_cmd(self):
self.cmds.CMD_LIST.sort(key=lambda cmd_obj: cmd_obj.index)
cmd_list = [self.main_cmd] + [self._get_execute_cmd(cmd) for cmd in self.cmds.CMD_LIST]
if self.sudo:
cmd_list.insert(0, "sudo")
logger.info(f"執行命令列表:{cmd_list}")
cmd = " ".join(cmd_list)
return cmd
class BaseCmd(Runner):
def __init__(self):
super().__init__()
self.cmds: Cmds = None
self.meta_data = None
self.mutex = None
self.need_value = None
self.has_child_cmd = None
self.gc = None
self.child_cmd = None
self.default_value = None
self.value = None
self.mark = None
self.index = None
self.level = 0
self._mutexs = []
self._gcs = []
self._not_mutexs = []
self.main_cmd = "docker"
self.pre: object = object
self.root: object = object
self.next: Union[object] = object
self.ssh_client: SSHClient = None
class Ps(BaseCmd):
def __init__(self, root_obj, pre_obj):
super().__init__()
self.pre: Docker = pre_obj
self.root: Docker = root_obj
self.next: Union[Ps] = self
self.meta_data = {'mapping_name': 'ps', 'original_cmd': 'ps', 'value': False, 'mutex': False, 'default': 'None',
'has_child_cmd': False, 'child_cmds': [], 'parent_cmd': 'Docker', 'root_cmd': 'Docker'}
self.level = 2
self.mutex = False
self.need_value = False
self.has_child_cmd = False
self.child_cmds = []
self.gc = False
self.default_value = "None"
self.value = self.default_value
self.mark = False
class Inspect(BaseCmd):
def __init__(self, root_obj, pre_obj):
super().__init__()
self.pre: Docker = pre_obj
self.root: Docker = root_obj
self.next: Union[Inspect] = self
self.meta_data = {'mapping_name': 'inspect', 'original_cmd': 'inspect', 'value': True, 'mutex': False,
'default': 'None', 'has_child_cmd': False, 'child_cmds': [], 'parent_cmd': 'Docker',
'root_cmd': 'Docker'}
self.level = 2
self.mutex = False
self.need_value = True
self.has_child_cmd = False
self.child_cmds = []
self.gc = False
self.default_value = "None"
self.value = self.default_value
self.mark = False
class Format(BaseCmd):
def __init__(self, root_obj, pre_obj):
super().__init__()
self.pre: Docker = pre_obj
self.root: Docker = root_obj
self.next: Union[Format] = self
self.meta_data = {'mapping_name': 'format', 'original_cmd': '--format', 'value': True, 'mutex': False,
'default': 'None', 'has_child_cmd': False, 'child_cmds': [], 'parent_cmd': 'Docker',
'root_cmd': 'Docker'}
self.level = 2
self.mutex = False
self.need_value = True
self.has_child_cmd = False
self.child_cmds = []
self.gc = False
self.default_value = "None"
self.value = self.default_value
self.mark = False
class Docker(Runner):
def __init__(self, host=None, name=None, pwd=None, port="22", timeout="60", sudo=False):
super().__init__()
self.cmds: Cmds = Cmds()
self.pre: object = None
self.root: Docker = self
self.next: Union[Ps, Inspect, Format,] = None
self.main_cmd = "docker"
self._mutexs = []
self._gcs = []
self._not_mutexs = []
self.mode = "SSH"
self.ssh_client = SSHClient(host, name, pwd, port)
self._ps: Ps = Ps(self, self)
self._inspect: Inspect = Inspect(self, self)
self._format: Format = Format(self, self)
def _set_ps(self):
self._ps.mark = True
self._ps.index = self.cmds.index
self.cmds.index += 1
self.next = self._ps
self.cmds.CMD_LIST.append(self._ps)
self._ps.cmds = self.cmds
self._ps.ssh_client = self.ssh_client
def tset_ps(self):
"""
傳遞TRANSMIT模式,可以獲取子命令物件,可透過,root/pre/next,控制命令物件的根/父/子級
"""
self._set_ps()
return self._ps
def hset_ps(self, ):
"""
保持HOLD模式,該方法返回該物件本身,不返回子物件
"""
self._set_ps()
return self
def _set_inspect(self, value=None):
self._inspect.mark = True
self._inspect.index = self.cmds.index
self.cmds.index += 1
self.next = self._inspect
self.cmds.CMD_LIST.append(self._inspect)
self._inspect.cmds = self.cmds
self._inspect.ssh_client = self.ssh_client
if self._inspect.need_value:
self._inspect.value = value
def tset_inspect(self, value=None):
"""
傳遞TRANSMIT模式,可以獲取子命令物件,可透過,root/pre/next,控制命令物件的根/父/子級
"""
self._set_inspect(value=value)
return self._inspect
def hset_inspect(self, value=None):
"""
保持HOLD模式,該方法返回該物件本身,不返回子物件
"""
self._set_inspect(value=value)
return self
def _set_format(self, value=None):
self._format.mark = True
self._format.index = self.cmds.index
self.cmds.index += 1
self.next = self._format
self.cmds.CMD_LIST.append(self._format)
self._format.cmds = self.cmds
self._format.ssh_client = self.ssh_client
if self._format.need_value:
self._format.value = value
def tset_format(self, value=None):
"""
傳遞TRANSMIT模式,可以獲取子命令物件,可透過,root/pre/next,控制命令物件的根/父/子級
"""
self._set_format(value=value)
return self._format
def hset_format(self, value=None):
"""
保持HOLD模式,該方法返回該物件本身,不返回子物件
"""
self._set_format(value=value)
return self
def ps(self) -> Ps:
return self._ps
def inspect(self) -> Inspect:
return self._inspect
def format(self) -> Format:
return self._format
執行測試
進行請求響應斷言(PS:自行替換下面的 host/name/pwd/port 等引數驗證)
if __name__ == '__main__':
docker = Docker(host="192.168.1.2", name="root", pwd="xxxx")
ps_res = docker.hset_ps().hset_format("{{.ID}}").runner()
inspect_res = docker.hset_inspect(ps_res["stdout"]).runner()
assert ps_res["stdout"].strip() in inspect_res["stdout"][0]["Id"]
檢查日誌
C:\Users\Dell\AppData\Local\Programs\Python\Python39\python.exe E:\開源專案\CmdLinker\tests\docker.py
2025-01-03 16:38:03.116 | INFO | __main__:cmd_checker:53 - ========================================開啟根命令Docker合法性檢查========================================
2025-01-03 16:38:03.116 | DEBUG | __main__:cmd_checker:66 - docker新增非互斥物件:<__main__.Ps object at 0x000001ADA171E9A0>,最新非互斥物件列表:[<__main__.Ps object at 0x000001ADA171E9A0>]
2025-01-03 16:38:03.116 | DEBUG | __main__:_exclude:31 - 命令物件:Inspect未被使用
2025-01-03 16:38:03.116 | DEBUG | __main__:cmd_checker:66 - docker新增非互斥物件:<__main__.Format object at 0x000001ADA171EAF0>,最新非互斥物件列表:[<__main__.Ps object at 0x000001ADA171E9A0>, <__main__.Format object at 0x000001ADA171EAF0>]
2025-01-03 16:38:03.116 | INFO | __main__:cmd_checker:81 - ========================================根命令Docker合法性檢查透過========================================
2025-01-03 16:38:03.116 | INFO | __main__:exec_cmd:97 - 執行命令列表:['sudo', 'docker', 'ps', '--format {{.ID}}']
2025-01-03 16:38:03.693 | INFO | cmdlinker.builtin.logger_operation:console_output:18 -
================== cmd request details ==================
execute cmd on host : 192.168.1.2
execute cmd : sudo docker ps --format {{.ID}}
2025-01-03 16:38:03.694 | WARNING | cmdlinker.builtin.ssh_utils:run_cmd:67 - 嘗試對response stdout進行json轉換失敗,原樣輸出
2025-01-03 16:38:03.694 | INFO | cmdlinker.builtin.logger_operation:console_output:18 -
================== cmd response details ==================
status_code : 0
execute_time : 578ms
stdout : 7393aa6334e6
stderr :
2025-01-03 16:38:03.694 | INFO | __main__:cmd_checker:53 - ========================================開啟根命令Docker合法性檢查========================================
2025-01-03 16:38:03.694 | DEBUG | __main__:cmd_checker:66 - docker新增非互斥物件:<__main__.Ps object at 0x000001ADA171E9A0>,最新非互斥物件列表:[<__main__.Ps object at 0x000001ADA171E9A0>]
2025-01-03 16:38:03.694 | DEBUG | __main__:cmd_checker:66 - docker新增非互斥物件:<__main__.Inspect object at 0x000001ADA171EA60>,最新非互斥物件列表:[<__main__.Ps object at 0x000001ADA171E9A0>, <__main__.Inspect object at 0x000001ADA171EA60>]
2025-01-03 16:38:03.694 | DEBUG | __main__:cmd_checker:66 - docker新增非互斥物件:<__main__.Format object at 0x000001ADA171EAF0>,最新非互斥物件列表:[<__main__.Ps object at 0x000001ADA171E9A0>, <__main__.Inspect object at 0x000001ADA171EA60>, <__main__.Format object at 0x000001ADA171EAF0>]
2025-01-03 16:38:03.694 | INFO | __main__:cmd_checker:81 - ========================================根命令Docker合法性檢查透過========================================
2025-01-03 16:38:03.694 | INFO | __main__:exec_cmd:97 - 執行命令列表:['sudo', 'docker', 'inspect 7393aa6334e6\n']
2025-01-03 16:38:03.922 | INFO | cmdlinker.builtin.logger_operation:console_output:18 -
================== cmd request details ==================
execute cmd on host : 47.97.37.176
execute cmd : sudo docker inspect 7393aa6334e6
2025-01-03 16:38:03.922 | INFO | cmdlinker.builtin.logger_operation:console_output:18 -
================== cmd response details ==================
status_code : 0
execute_time : 228ms
stdout : [
{
"Id": "7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd",
"Created": "2024-06-26T07:04:49.182101334Z",
"Path": "docker-entrypoint.sh",
"Args": [
"--requirepass",
"yourpassword"
],
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 27544,
"ExitCode": 0,
"Error": "",
"StartedAt": "2024-06-26T07:04:49.54446658Z",
"FinishedAt": "0001-01-01T00:00:00Z"
},
"Image": "sha256:7614ae9453d1d87e740a2056257a6de7135c84037c367e1fffa92ae922784631",
"ResolvConfPath": "/var/lib/docker/containers/7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd/hostname",
"HostsPath": "/var/lib/docker/containers/7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd/hosts",
"LogPath": "/var/lib/docker/containers/7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd/7393aa6334e6a091aae54790f5a6e3505f6dbab9515c5643978afbdc221975dd-json.log",
"Name": "/some-redis",
"RestartCount": 0,
"Driver": "overlay2",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "",
"ExecIDs": null,
"HostConfig": {
"Binds": null,
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {}
},
"NetworkMode": "default",
"PortBindings": {
"6379/tcp": [
{
"HostIp": "",
"HostPort": "6379"
}
]
},
"RestartPolicy": {
"Name": "no",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "host",
"Dns": [],
"DnsOptions": [],
"DnsSearch": [],
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"ConsoleSize": [
0,
0
],
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": [],
"BlkioDeviceReadBps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteIOps": null,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DeviceRequests": null,
"KernelMemory": 0,
"KernelMemoryTCP": 0,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": false,
"PidsLimit": null,
"Ulimits": null,
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"MaskedPaths": [
"/proc/asound",
"/proc/acpi",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/da926ce47de0e966f295ddf249be649da1f2c1ea162be28fb84a5c5d28db3d96-init/diff:/var/lib/docker/overlay2/cb066f8e71551530105d92d22aee4ac7d10beeff480389676e102be0d701e534/diff:/var/lib/docker/overlay2/b69aadb0a11079daa266a36401ba0ce147450516b500c645eff85176f8e7919b/diff:/var/lib/docker/overlay2/a57e79ac2a99e28c990210b22b831dcd8fad2c98d810de241fc8a3d99b64953a/diff:/var/lib/docker/overlay2/482ff82ce4a7763a75994358255c4e604abab9fcb77fd1f5f2d815a0a98a323b/diff:/var/lib/docker/overlay2/8e951ea76637a9804eb44f89eb8353a5a71da11d304056b22e2d1886e7da9c2d/diff:/var/lib/docker/overlay2/e88bc247f216bd19bd2329b2feeda9cc387df6241598b863b201cafe7ff0db3a/diff",
"MergedDir": "/var/lib/docker/overlay2/da926ce47de0e966f295ddf249be649da1f2c1ea162be28fb84a5c5d28db3d96/merged",
"UpperDir": "/var/lib/docker/overlay2/da926ce47de0e966f295ddf249be649da1f2c1ea162be28fb84a5c5d28db3d96/diff",
"WorkDir": "/var/lib/docker/overlay2/da926ce47de0e966f295ddf249be649da1f2c1ea162be28fb84a5c5d28db3d96/work"
},
"Name": "overlay2"
},
"Mounts": [
{
"Type": "volume",
"Name": "502c8ea157173ae65d24a2e0173c0e990e5ba1398013ad01a048e7a594a62608",
"Source": "/var/lib/docker/volumes/502c8ea157173ae65d24a2e0173c0e990e5ba1398013ad01a048e7a594a62608/_data",
"Destination": "/data",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
],
"Config": {
"Hostname": "7393aa6334e6",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"6379/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"GOSU_VERSION=1.12",
"REDIS_VERSION=6.2.6",
"REDIS_DOWNLOAD_URL=http://download.redis.io/releases/redis-6.2.6.tar.gz",
"REDIS_DOWNLOAD_SHA=5b2b8b7a50111ef395bf1c1d5be11e6e167ac018125055daa8b5c2317ae131ab"
],
"Cmd": [
"--requirepass",
"yourpassword"
],
"Image": "redis",
"Volumes": {
"/data": {}
},
"WorkingDir": "/data",
"Entrypoint": [
"docker-entrypoint.sh"
],
"OnBuild": null,
"Labels": {}
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "b98966d73f26b520343cc69867d8ac56dd3ac65655375c2edb047cabc9eec9c8",
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"Ports": {
"6379/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "6379"
}
]
},
"SandboxKey": "/var/run/docker/netns/b98966d73f26",
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "63a20510e954d1b81181d319b2d004e68918a2926d3db1e71e846197b79c72e1",
"Gateway": "172.17.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "172.17.0.2",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"MacAddress": "02:42:ac:11:00:02",
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"NetworkID": "7fdaf78d05fd8a3d1c3c2ae0151022ae8c326582524327e18403fdab4a26f16d",
"EndpointID": "63a20510e954d1b81181d319b2d004e68918a2926d3db1e71e846197b79c72e1",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "02:42:ac:11:00:02",
"DriverOpts": null
}
}
}
}
]
stderr :
Process finished with exit code 0
我們可以很方便的使用返回的 status_code/stdout/stderr 進行請求響應斷言
OpenSourceTest cmdlinker 社群
歡迎小夥伴加群,討論 cmdlinker 相關問題,或提出最佳化建議!
QQ 群(自動化測試 - 夜行者):816489363
相關文章
- 軟體自動化測試有什麼優勢?自動化測試框架有哪些?框架
- 軟體測試:自動化測試
- 軟體測試為什麼需要自動化測試框架?權威軟體測試公司分享框架
- 自動化測試框架框架
- Eggplant—HMI自動化測試軟體
- 通用自動化測試軟體 — TAE
- Eggplant—HMI 自動化測試軟體
- 軟體測試理論(2)自動化測試
- 自動化測試框架AutoTestFramework及軟硬體環境-Alltesting|澤眾雲測試框架Framework
- 自動化測試框架指南框架
- 常見的自動化測試框架分享,上海軟體測評中心有哪些?框架
- 測試開發之自動化篇-自動化測試框架設計框架
- Python 自動化測試框架unittestPython框架
- 介面自動化測試框架 HttpFPT框架HTTP
- Python自動化測試框架-pytestPython框架
- 利用tox打造自動自動化測試框架框架
- T框架介紹(自動化測試框架)框架
- 自動化測試是什麼?什麼軟體專案適合自動化測試?
- 軟體測試--中介軟體介紹
- 軟體測試自動化的最新趨勢
- 談軟體自動化測試工具的評測方法
- 自動化測試框架的AW模式框架模式
- UI自動化測試框架Cypress初探UI框架
- Python自動化測試框架介紹Python框架
- 軟體自動化測試的四個階段
- 軟體自動化測試工具的那些事兒
- 軟體自動化測試與AI結合 - modernanalystAINaN
- 軟體測試(功能、介面、效能、自動化)詳解
- 軟體自動化測試有哪些測試流程?專業的軟體測評中心推薦
- 軟體測試、自動化測試極容易產生的誤區
- 自動化測試在國際軟體測試中的應用
- 軟體測試筆記——11.自動化測試和手動測試的選擇筆記
- 2023年好用的自動化測試框架有哪些?如何提高自動化測試效果?框架
- Robot Framework自動化測試框架核心指南-如何做好自動化測試平臺框架的設計Framework框架
- 自動化測試系列 —— UI自動化測試UI
- android 5個自動化測試Ui框架AndroidUI框架
- 介面自動化測試框架搭建的思路框架
- HamronyOS 自動化測試框架使用指南框架