跳转至

Blog

任务管理 —— 进程和线程

介绍

进程和线程

进程和线程是操作系统中最基本的概念:

  • 进程是操作系统进行资源分配和调度的基本单位,它拥有独立的地址空间、内存、数据栈以及其他系统资源。
  • 线程是进程中的一个执行单元,它共享进程的地址空间和资源,但拥有独立的程序计数器、寄存器和栈。

进程和线程都是操作系统进行程序执行的基本单位,但它们的角色和功能有所不同。进程是操作系统进行资源分配和保护的基本单位,它拥有独立的地址空间和系统资源;而线程是进程中的一个执行单元,它是操作系统进行 CPU 调度的基本单位,共享进程的资源并负责执行程序代码。

进程和线程的概念并非一蹴而就,而是随着计算机技术的发展逐步演变而来的:

  1. 早期阶段: 单道程序。早期的计算机系统只能运行一个程序,这个程序独占所有的系统资源。这种模式下,没有进程和线程的概念,程序的执行是顺序的,效率低下。为了提高资源利用率,出现了多道程序设计技术,允许多个程序同时驻留在内存中,并由操作系统进行调度。
  2. 进程的出现:多道程序。为了提高资源利用率,出现了多道程序设计技术,允许多个程序同时驻留在内存中,并由操作系统进行调度。 进程的概念应运而生,每个程序对应一个进程,拥有独立的地址空间和资源。操作系统负责进程的创建、销毁、切换和资源分配,实现了程序的并发执行。
  3. 线程的出现:轻量级进程。随着图形用户界面 (GUI) 和网络应用的普及,传统的进程模型显得过于笨重,创建和切换进程的开销较大。 线程的概念被提出,作为进程中的一个执行单元,共享进程的地址空间和资源,但拥有独立的程序计数器、寄存器和栈。线程的创建和切换开销比进程小得多,可以更高效地实现并发编程。
  4. 多线程的普及:多核处理器的出现使得多线程编程成为提升程序性能的关键技术。现代操作系统都提供了完善的线程支持,例如 POSIX 线程 (pthread) 和 Windows 线程。各种编程语言也提供了对多线程编程的支持,例如 Java、Python、C++ 等。

要是暂时无法理解进程和线程,就可以简单的认为:一个程序就是一个进程,一个进程可以有多个线程,这些线程可以共享这个进程的资源,比如内存、文件等。

在 Android 系统中,可以通过 ps 命令查询进程和线程信息:

# 查询所有的进程和线程
adb shell ps -A

ps -T -p <pid> 可以列出指定进程的所有线程。如果一个 ID 是线程,它会在ps -T的输出中显示为某个进程的子线程。如果 PID 和 TID 相同(如 1234),则该 ID 是进程。如果 PID 和 TID 不同(如 1235 和 1236),则该 ID 是线程。

$ adb shell ps -T -p 1234
USER      PID   PPID  TID   VSZ   RSS   WCHAN          ADDR S NAME
u0_a123   1234  456   1234  1.2G  300M  ffffffff 00000000 S main
u0_a123   1234  456   1235  1.2G  300M  ffffffff 00000000 S Thread-1
u0_a123   1234  456   1236  1.2G  300M  ffffffff 00000000 S Thread-2

# 上述字段的含义如下:
# USER:运行该线程的用户。
# PID:进程 ID(Process ID)。
# PPID:父进程的进程 ID(Parent Process ID)。
# TID:线程 ID(Thread ID)。
# VSZ:虚拟内存大小(Virtual Memory Size),单位是 KB。
# RSS:常驻内存大小(Resident Set Size),单位是 KB 或者 MB。

# WCHAN:线程当前等待的内核事件(Wait Channel)。如果线程处于睡眠状态(例如等待 I/O 或锁),
# 这里会显示线程等待的内核函数或事件。
# 如果线程正在运行,则显示 0 或 -。

# ADDR:线程的内核地址(Kernel Address)。

# S:线程的状态(State)
# - R 表示运行中(Running)
# - S 表示睡眠中(Sleeping)
# - D 表示不可中断睡眠(通常是等待 I/O)
# - Z 表示僵尸状态(Zombie),说明线程已终止但未被父进程回收。
# - T 表示停止状态(Stopped)。

# NAME:线程的名称。

内核线程和用户线程

介绍内核线程和用户线程之前,我们先了解下什么是内核空间和用户空间。Linux 操作系统会划分两个独立的运行空间:内核空间和用户空间。 用户的程序在用户空间运行,系统核心程序在内核空间运行。通过将用户程序和系统程序隔离开的目的是防止用户程序错误或者恶意攻击操作系统。

内核空间是操作系统内核运行的环境,是参与操作系统管理的重要组成部分。内核空间的程序具有最高的权限,能够直接访问硬件资源,比如内存、CPU、I/O 等等。

用户空间是应用程序运行的环境,是用户与操作系统交互的重要桥梁。用户空间的程序运行在受限的权限下,无法直接访问硬件资源,必须通过系统调用(System Call)向内核空间请求服务,例如内存分配、文件读写、网络通信等等。

内核空间和用户空间

在 Linux 操作系统中,线程根据管理和调度的责任方是谁,可以分成内核线程和用户线程。

内核线程在内核空间运行,由操作系统内核直接管理和调度的线程。内核线程的创建、销毁和切换由内核完成,开销较大。 每个内核线程都拥有独立的线程控制块(TCB),内核可以感知和控制所有内核线程。可以利用多核 CPU 实现真正的并行执行。

用户线程在用户空间运行,由用户空间的线程库进行管理和调度。相比内核线程,用户线程创建、销毁和切换由线程库完成,开销较小。用户线程无法利用调用操作系统的调度算法,所以可能导致某些线程长时间得不到 CPU 调度的情况。

一个物理 CPU 在同一时间最多只能运行一个线程。假设用户空间中的进程 1、进程 2 和进程 3 分别拥有可被调度的 4 个线程、3 个线程和 5 个线程,内核空间拥有 3 个进程,其中 1 个进程的优先级非常高。那么,在一台拥有 8 个 CPU 的设备上,操作系统会根据进程管理的调度算法决定哪些进程会被在 CPU 上执行:

内核空间和用户空间

由于设备只有 8 个 CPU 核,同一时间红色的线程表示得到了 CPU 调度的线程,那么剩下没有得到 CPU 调度的线程将继续等待,直到被 CPU 调度。

进程隔离

进程隔离是保护系统免受恶意攻击或错误操作影响的关键机制。它确保每个进程都运行在自己的独立地址空间中,彼此之间互不干扰。即使一个进程崩溃或出现错误,也不会影响其他进程的正常运行。

我们以上述例子中的用户空间的进程 1 和进程 2 举例。在进程创建时,操作系统会给进程 1 和进程 2 单独分配内存空间,原则上进程 1 和进程 2 的代码只能运行在各自的独立沙盒中,彼此互补干扰,各自相互独立。即使进程 1 崩溃或者出现错误 ,也不会影响到进程 2 的正常运行。

进程隔离的实现主要依赖于虚拟内存技术。虚拟内存技术为每个进程分配了一个看似连续且私有的内存空间。操作系统通过内存管理单元(MMU)将虚拟地址转换为物理地址,并检查访问权限。如果一个进程试图访问其他进程的内存空间,MMU 会阻止该操作并触发异常。虚拟内存技术不仅有效地隔离了不同进程数据和代码的影响,还为内存提供了可靠的保护机制,防止进程的错误操作导致操作系统崩溃。

线程的生命周期

线程的生命周期是指线程从创建到终止的整个过程,它描述了线程在其生命周期中可能经历的各种状态以及状态之间的转换关系。线程的生命周期通常包括以下几个阶段:

  1. 新建 (New):线程对象被创建,但尚未启动。
  2. 可运行 (Runnable):线程已经启动,并等待 CPU 时间片。
  3. 运行 (Running):线程正在 CPU 上执行。
  4. 阻塞 (Blocked):线程因为等待某个资源(例如 I/O 操作、锁等)而暂时停止执行。
  5. 等待 (Waiting):线程因为调用了 wait()join() 等方法而进入等待状态。
  6. 计时等待 (Timed Waiting):线程因为调用了 sleep()wait(timeout) 等方法而进入计时等待状态。
  7. 终止 (Terminated):线程已经执行完毕,或者被强制终止。

下图详细的描述了一个线程的完整生命周期:

线程的生命周期

Note

在一些文档中会看到进程还有一个僵尸态(ZOMBIE)。处于僵尸态的线程被称为僵尸线程,僵尸线程表示进程已经死亡,但是尚未被操作系统回收。

Note

Q:为什么我们平时都喜欢说线程的生命周期,而不是说进程的生命周期?

A:这是因为 CPU 实际调度的对象是线程而不是进程,也就是线程是 CPU 的执行对象,所以生命周期指的是 CPU 上运行的线程的生命周期。

优先级

Linux 线程优先级

线程调度

线程调度机制是操作系统的核心功能之一,它负责决定哪个线程可以获得 CPU 资源,以及每个线程可以运行多长时间。线程调度机制的设计直接影响着系统的性能、响应速度和资源利用率。一个好的线程调度机制应该在以下几个方面表现出色:

  1. 公平性 (Fairness):确保每个线程都能获得公平的 CPU 时间份额,避免某些线程长时间得不到 CPU 资源。使用公平的调度算法,例如完全公平调度器 (CFS),并根据线程的优先级和运行时间动态调整调度顺序。
  2. 响应速度 (Responsiveness):提高系统的响应速度,使用户操作得到及时处理。优先调度交互式线程,例如 GUI 线程,并减少线程切换的开销。
  3. 吞吐量 (Throughput):提高系统的吞吐量,使系统能够处理更多的任务。优化调度算法,减少线程切换的开销,并充分利用多核 CPU 的优势。
  4. 资源利用率 (Resource Utilization):提高 CPU、内存等资源的利用率。根据系统的负载情况动态调整线程的优先级和调度策略,并避免资源浪费。
  5. 可扩展性 (Scalability):能够支持大量的线程和 CPU 核心。使用高效的调度算法和数据结构,并避免锁竞争等问题。

为了能够实现上述目标的线程调度策略,常见的线程调度算法有如下几种:

  1. 先来先服务 (FCFS): 按照线程到达的顺序进行调度,简单易实现,但可能导致短任务等待时间过长。
  2. 短作业优先 (SJF): 优先调度预计运行时间较短的线程,可以缩短平均等待时间,但难以准确预测线程的运行时间。
  3. 时间片轮转 (RR): 每个线程轮流运行一个固定的时间片,适合交互式系统,但时间片大小的选择会影响系统性能。
  4. 优先级调度: 根据线程的优先级进行调度,优先级高的线程可以获得更多的 CPU 时间,但可能导致低优先级线程饥饿。
  5. 多级反馈队列: 结合了时间片轮转和优先级调度的优点,将线程分为多个优先级队列,并根据线程的运行情况动态调整其优先级。

通常,在 Linux 和 Android 系统中会采用更加复杂或者精细化的调度算法,甚至会人为通过配置的方式干预线程的运行。

优先级分类

在 Linux 操作系统中,可以按照线程的优先级将线程分成实时线程(Real-Time Thread)和普通线程(Normal Thread)。

  • 实时线程:实时线程是具有高优先级的线程,用于处理需要快速响应和确定性的任务。实时线程可以抢占普通线程,确保关键任务能够及时获得 CPU 资源。
  • 普通线程:普通线程是具有较低优先级的线程,用于处理一般的任务。普通线程不能抢占实时线程,必须等待实时线程主动释放 CPU 资源。通常,在用户空间的线程都是普通线程。

实时线程的调度策略一般为先进先出 FIFO(First In First Out) 或者时间片轮转 RR(Round Robin)。普通线程的调度策略一般为完全公平调度 CFS(Completely Fair Scheduler)。完全公平调度通过动态优先级来确保每个进程都能公平地获得CPU时间。普通进程对执行时效的要求相对较低。

优先级

线程优先级是操作系统调度线程的重要依据,它决定了线程获取 CPU 资源的顺序。优先级高的线程会优先获得 CPU 时间,从而提高其执行效率和响应速度。在 Linux 系统中,用户空间的线程优先级会通过计算映射到内核线程优先级,内核线程优先级的范围在 0 到 139,其中:

  • 实时线程的优先级范围从 0 到 99。
  • 普通线程的优先级范围从 100 到 139。

针对普通线程,Linux 提供了一个 Nice 值的概念。Nice 值的概念最早出现在 Unix 系统中,用于表示进程的“友好程度”(niceness)。 一个进程越“友好”(Nice 值越高),它对 CPU 资源的竞争就越温和,优先级越低;反之,Nice 值越低,进程的优先级越高。

Danger

Nice 值的概念仅用于表示用户空间中的普通进程,实时进程是没有 Nice 值这一说法的。

Nice 值的取值范围在 -20 到 +19。通常情况下,Nice 的默认值为 0。Nice 值越大,进程的优先级越低,意味着获取到 CPU 调度的机会越少。Nice 值越小,进程的优先级则越高,意味着被 CPU 调度的机会越大。一个 Nice 值为 -20 的线程优先级最高,一个 Nice 值为 +19 的进程优先级最低。Nice 值可以通过 ps 命令查询,以查询 Settings 的 Nice 值为例:

$ adb shell ps -A | grep com.android.settings
UID            PID  PPID C STIME TTY          TIME CMD
system        1834   631 0 00:54 ?        00:00:04 com.android.settings

# UID:运行该进程的用户 ID。
# PID:进程 ID。
# PPID:父进程 ID(创建该进程的进程 ID)。
# C:CPU 使用率(百分比)。
# STIME:进程启动时间。
# TTY:与进程关联的终端(如果有)。
# TIME:进程占用 CPU 的总时间。
# CMD:启动进程的命令名称。

$ adb shell ps -l -p 1834
F S   UID   PID  PPID C PRI  NI BIT    SZ WCHAN  TTY          TIME CMD
5 S  1000  1834   631 0  29 -10  64 1509890 do_ep+ ?      00:00:04 ndroid.settings

# PRI:进程的优先级(Priority),值越小优先级越高。
# NI:进程的 nice 值,用于调整优先级。
# BIT:进程的位数(32 位或 64 位)。
# SZ:进程占用的内存大小(以页为单位)。

可以看到 com.android.settings 进程号为 1834,Nice 值(NI)是 -10。在 NI 旁边有一个 PRI 字段,这个字段就是进程的优先级(Priority)。PRI 和 NI 的关系如下:

\[ PRI=NI+20 \]

因为 Nice 值的范围是 [-20,+19],可得 PRI 的值在 [0,39]。由于 Linux 规定了实时线程和普通线程的优先级范围,普通线程优先级在 [100,139],所以还需要对 PRI 做一次计算得到静态优先级(Static Priority):

\[ StaticPrio=100+PRI \]

因为 PRI 的范围是 [0,39],可得静态优先级的取值范围是 [100,139],正好与 Linux 线程优先级的范围一致。因此综上,可得:

\[ f(x) = \begin{cases} \text{实时线程} & : [0, 99] \\ \text{普通线程} & : 100 \leq \text{PRI} + 100 = \text{NICE} + 120 \leq 139 \end{cases} \]

也即:

线程的生命周期

针对用户空间的普通线程可以通过 renice 的方式修改线程的优先级:

# 获取线程的 pid 信息
adb shell "ps -A | grep settings"
system        1829   610 5431532 115052 do_freezer_trap     0 S com.android.setting

# 设置线程的 Nice 值
adb shell "renice -n 19 -p <pid>"

# 查询线程的 Nice 值
adb shell "ps -l -p <pid>"
F S   UID   PID  PPID C PRI  NI BIT    SZ WCHAN  TTY          TIME CMD
5 S  1000  1829   610 0  39 -20  64 1357883 do_fr+ ?      00:00:02 ndroid.settings

Warning

Q:为什么修改 NIPRI 没有立即变化?或者存在对应关系不一致的情况?

A:内核会根据系统负载和调度策略动态调整 PRINI 只是影响因素之一,从而导致 NI 值和 PRI 的值存在是不是一一对应的关系。

Note

在 Linux 系统中,父进程通过fork()系统调用创建的子进程会继承父进程的 Nice 值(即进程的静态优先级)。然而,Nice 值的继承是单向的,父进程后续通过renice命令或setpriority()系统调用修改自身的 Nice 值时,子进程的 Nice 值不会随之改变。这是因为每个进程的 Nice 值是独立的,子进程在创建后与父进程在调度优先级上完全解耦,父进程和子进程的优先级调整互不影响。

调度器

不同的调度器对优先级的处理方式不同,在 Linux 内核提供了多种调度器 (类) 来满足不同场景的需求,以下是几种常用的调度器:

调度器 名称 设计目标 实现原理
SCHED_DEADLINE 截止时间调度器 确保任务在指定的截止时间之前完成 使用 Earliest Deadline First (EDF) 算法,优先调度截止时间最早的任务
SCHED_NORMAL 普通调度器( CFS 调度器的别名,用于调度普通线程 (SCHED_OTHER)。) 公平地分配 CPU 时间给所有可运行的线程 使用虚拟运行时间 (vruntime) 来衡量线程的运行时间,并选择 vruntime 最小的线程运行。
SCHED_FIFO 先进先出调度器(First In First Out) 确保高优先级线程能够及时获得 CPU 资源 按照线程的优先级和到达顺序进行调度,优先级高的线程会一直运行,直到它主动放弃 CPU 或更高优先级的线程出现。
SCHED_RR 轮转调度器(Round Robin) 在保证高优先级线程响应速度的同时,提高公平性 按照线程的优先级和到达顺序进行调度,优先级高的线程会运行一个时间片,然后让给同优先级的其他线程。
SCHED_BATCH 批处理调度器 提高批处理任务的吞吐量 低批处理任务的优先级,并延长其时间片,以减少上下文切换的开销。
SCHED_IDLE 空闲调度器 在系统空闲时运行低优先级任务 只有当系统没有其他可运行的任务时,才会调度 SCHED_IDLE 任务。

通常情况下,实时线程的调度策略通常为 SCHED_FIFO 或 SCHED_RR,普通线程的调度策略通常为 SCHED_OTHER。在拥有 root 权限的 Android 设备上可以通过 chrt 命令查询线程的调度器:

$ adb shell ps -A | grep com.android.settings
UID            PID  PPID C STIME TTY          TIME CMD
system        1834   631 0 00:54 ?        00:00:04 com.android.settings

$ chrt -p 1834
pid 1834's current scheduling policy: SCHED_OTHER
pid 1834's current scheduling priority: 0

Android 线程优先级

线程模型

Android 是一个基于 Linux 内核的操作系统,因此它的线程优先级和调度机制与 Linux 类似。然而,Android 在其应用框架层对线程管理进行了封装和优化,以适应移动设备的资源限制和应用场景。Android 应用通常由多个线程组成,主要包括:

  1. 主线程(UI 线程):负责处理用户界面(UI)更新和事件响应。如果主线程被阻塞,会导致应用卡顿甚至 ANR(Application Not Responding)。
  2. 工作线程(后台线程):用于执行耗时操作(如网络请求、文件读写、数据库操作等)。避免阻塞主线程,提升应用响应速度。

Android 提供了多种线程管理工具,比如带有消息循环的工作线程 HandlerThread、用于管理多个工作线程的 ThreadPoolExecutor、简化后台任务与 UI 更新交互的 AsyncTask 和 Kotlin 轻量级并发框架 Coroutine。

优先级

Android 线程优先级的范围与 Linux 一致,但 Android 提供了更高层次的 API Process.setThreadPriority() 来设置线程优先级:

// 设置当前线程的优先级
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

// 设置指定线程的优先级
Process.setThreadPriority(threadId, Process.THREAD_PRIORITY_BACKGROUND);

在 AOSP 中,定义了一些常用的优先级常量:

常量 说明 nice 值
THREAD_PRIORITY_DEFAULT 默认优先级,通常用于主线程和普通工作线程。 0
THREAD_PRIORITY_LOWEST 最低优先级,适合非常不紧急的后台任务。 19
THREAD_PRIORITY_BACKGROUND 后台任务优先级,适合低优先级的后台任务(如日志记录、数据同步)。 10
THREAD_PRIORITY_FOREGROUND 前台任务优先级,适合与用户交互相关的任务。 -2
THREAD_PRIORITY_DISPLAY 显示相关任务的优先级,适合与 UI 渲染相关的任务。 -4
THREAD_PRIORITY_URGENT_DISPLAY 紧急显示任务的优先级,适合高优先级的 UI 渲染任务。 -8
THREAD_PRIORITY_AUDIO 音频相关任务的优先级,适合音频播放和处理任务。 -16
THREAD_PRIORITY_URGENT_AUDIO 紧急音频任务的优先级,适合高优先级的音频任务。 -19
THREAD_PRIORITY_MORE_FAVORABLE 比当前优先级更优先(nice 值减 1)。 当前 nice - 1
THREAD_PRIORITY_LESS_FAVORABLE 比当前优先级更低(nice 值加 1)。 当前 nice + 1

在实际的性能优化过程中,可以适当地提高对于用户可感知的线程优先级,例如:音频播放线程、动画渲染线程等。同时,也可以降低后台对于用户无感知或者感知较为不明显的线程,例如:数据同步、日志记录等。对于主线程的优先级不建议设置成很低的优先级,因为可能会引起 UI 界面的卡顿。

Android 测试框架 —— JUnit4

JUnit 4 的 官网文档:https://junit.org/junit4/

下载和安装

JUnit 4 提供了以下的方式下载和安装依赖文件:

方式 1;下载 JARs 文件,然后添加到环境中:

方式 2:通过 Maven 的方式集成:

<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13</version>
  <scope>test</scope>
</dependency>

Note

获取 JUnit 最新版本可以访问 Maven 的官网下载。

方式 3:通过 Gradle 的方式集成:

plugins {
    java
}

dependencies {
    testImplementation('junit:junit:4.13')
}

断言(Assertions)

断言(Assertions)则是 JUnit 测试中的核心功能之一。通过断言,测试代码可以验证程序行为是否符合预期。JUnit 提供了一系列的断言方法,用于对代码的执行结果进行验证,从而判断测试是否通过。断言的作用是确保程序在特定的条件下运行正确。如果条件不成立,断言将抛出一个异常(通常是 AssertionError),测试将失败,提示开发人员代码行为不符合预期。

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.sameInstance;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.junit.Assert.assertThat;
import static org.junit.matchers.JUnitMatchers.both;
import static org.junit.matchers.JUnitMatchers.containsString;
import static org.junit.matchers.JUnitMatchers.everyItem;
import static org.junit.matchers.JUnitMatchers.hasItems;

import java.util.Arrays;

import org.hamcrest.core.CombinableMatcher;
import org.junit.Test;

public class AssertTests {
  @Test
  public void testAssertArrayEquals() {
    byte[] expected = "trial".getBytes();
    byte[] actual = "trial".getBytes();
    org.junit.Assert.assertArrayEquals("failure - byte arrays not same", expected, actual);
  }

  @Test
  public void testAssertEquals() {
    org.junit.Assert.assertEquals("failure - strings are not equal", "text", "text");
  }

  @Test
  public void testAssertFalse() {
    org.junit.Assert.assertFalse("failure - should be false", false);
  }

  @Test
  public void testAssertNotNull() {
    org.junit.Assert.assertNotNull("should not be null", new Object());
  }

  @Test
  public void testAssertNotSame() {
    org.junit.Assert.assertNotSame("should not be same Object", new Object(), new Object());
  }

  @Test
  public void testAssertNull() {
    org.junit.Assert.assertNull("should be null", null);
  }

  @Test
  public void testAssertSame() {
    Integer aNumber = Integer.valueOf(768);
    org.junit.Assert.assertSame("should be same", aNumber, aNumber);
  }

  @Test
  public void testAssertTrue() {
    org.junit.Assert.assertTrue("failure - should be true", true);
  }
}

AssertThat

除了上述的断言方法,JUnit 还提供了一个非常强大的断言方法 —— assertThatassertThat 属于 Hamcrest 库的一部分。Hamcrest 是一个用于创建灵活和可读性高的匹配器的库,而 assertThat 就是 Hamcrest 提供的一种断言方式。通过 assertThat,可以构造更具表达性的测试,通常可以帮助提高代码的可读性和维护性。

assertThat 的基本语法如下:

assertThat(actual, matcher);
  • actual 是需要测试的值或对象。
  • matcher 是对 actual 值进行匹配的条件,通常是一个匹配器(matcher)。

匹配器

Hamcrest 匹配器可以分成七种类型:基本匹配器、数字匹配器、集合匹配器、字符串匹配器、对象匹配器、逻辑匹配器、日期和时间匹配器、自定义匹配器。

基本匹配器
匹配器 描述
is() 判断实际值是否与预期值相等,通常与 assertThat 配合使用。
equalTo() 匹配实际值是否等于预期值。
not() 匹配实际值是否与预期值不相等。
sameInstance() 判断实际对象和预期对象是否是同一个实例(即对象引用相同)。
samePropertyValueAs() 判断两个对象的同一属性值是否相等。
数字匹配器
匹配器 描述
greaterThan() 匹配实际值是否大于预期值。
greaterThanOrEqualTo() 匹配实际值是否大于或等于预期值。
lessThan() 匹配实际值是否小于预期值。
lessThanOrEqualTo() 匹配实际值是否小于或等于预期值。
closeTo() 匹配实际值是否在一个给定的误差范围内接近预期值。
集合匹配器
匹配器 描述
hasSize() 匹配集合或数组的大小。
contains() 匹配集合或数组中的元素是否按给定顺序出现。
containsInAnyOrder() 匹配集合或数组中的元素是否包含指定元素,顺序不重要。
hasItem() 匹配集合是否包含指定元素。
hasItems() 匹配集合是否包含多个指定元素。
字符串匹配器
匹配器 描述
containsString() 匹配字符串是否包含指定的子字符串。
startsWith() 匹配字符串是否以指定的子字符串开始。
endsWith() 匹配字符串是否以指定的子字符串结束。
matchesPattern() 匹配字符串是否符合指定的正则表达式。
对象匹配器
匹配器 描述
instanceOf() 匹配实际值是否是某个类的实例。
typeCompatibleWith() 匹配实际值是否与指定类型兼容(即可向该类型强制转换)。
notNullValue() 匹配值是否不为 null
nullValue() 匹配值是否为 null
sameInstance() 匹配实际值是否与预期值为同一实例。
refEq() 判断对象的指定属性是否相等,适用于对象的属性比较。
逻辑匹配器
匹配器 描述
both() 用于组合两个条件,表示两个条件都必须满足。
either() 用于组合两个条件,表示满足其中一个条件即可。
not() 用于对某个条件的结果取反。
and() 用于将多个条件连接在一起,表示所有条件都必须满足。
or() 用于将多个条件连接在一起,表示至少有一个条件满足即可。
日期和时间匹配器
匹配器 描述
date() 匹配实际日期是否与预期日期相等。
before() 匹配实际日期是否早于预期日期。
after() 匹配实际日期是否晚于预期日期。

如何使用匹配器

在使用 Hamcrest 需要添加相关的库依赖:

<!-- https://mvnrepository.com/artifact/org.hamcrest/hamcrest -->
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest</artifactId>
    <version>3.0</version>
    <scope>test</scope>
</dependency>

添加完成后,就可以使用 Hamcrest 的匹配器进行断言测试:

import org.hamcrest.core.CombinableMatcher;
import org.junit.Test;

import java.util.Arrays;
import java.util.List;

import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;

public class AssertThatDemoTest {
    @Test
    public void test() {
        assertThat(1 + 2, is(3));
        assertThat(1 + 2, is(not(4)));
        assertThat("Hello World!", containsString("Hello World!"));

        List<String> list = Arrays.asList("Hello", "World");
        assertThat(list, hasSize(2));

        assertThat(Arrays.asList("one", "two", "three"), hasItems("one", "three"));
        assertThat(Arrays.asList("fun", "ban", "net"), everyItem(containsString("n")));

        assertThat("snowman", both(containsString("s"))
                .and(containsString("n")));

        assertThat(new Object(), not(sameInstance(new Object())));

        assertThat("good", allOf(equalTo("good"), startsWith("good")));
        assertThat("good", not(allOf(equalTo("bad"), equalTo("good"))));
        assertThat("good", anyOf(equalTo("bad"), equalTo("good")));
        assertThat(7, not(CombinableMatcher.either(equalTo(3)).or(equalTo(4))));
    }
}

运行器(Test Runners)

JUnit 提供了多种 Test Runners 来执行测试,允许用户以不同的方式组织、执行和报告测试结果。Test Runners 是测试框架的核心组成部分,负责启动测试、管理测试生命周期以及报告测试结果。JUnit 主要有两类 Test Runners:默认的测试运行器和自定义测试运行器。

默认的运行器

默认情况下,JUnit 4 会使用 BlockJUnit4ClassRunner 作为测试运行器来运行 @Test 注解标记的方法。该测试运行器支持所有 JUnit 4 功能(如生命周期方法 @Before,@After,@BeforeClass, @AfterClass 等)。运行器负责在执行每个测试方法之前、之后分别调用相应的生命周期方法。

RunWith

JUnit 提供了 @RunWith 注解,允许指定自定义的 Test Runner。通过 @RunWith,可以使用不同的 Runner 来控制测试执行的方式。比如使用参数化测试器 Parameterized.class,允许通过多组不同的数据来执行同一测试:

@RunWith(Parameterized.class)
public class CalculatorTest {
    private int a;
    private int b;
    private int expectedResult;

    public CalculatorTest(int a, int b, int expectedResult) {
        this.a = a;
        this.b = b;
        this.expectedResult = expectedResult;
    }

    @Parameterized.Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][] {
            { 1, 2, 3 }, { 2, 3, 5 }, { 4, 5, 9 }
        });
    }

    @Test
    public void testAddition() {
        assertEquals(expectedResult, a + b);
    }
}

再比如想要把多个测试类组合成一个大的测试集(Test Suite),统一执行测试用例,可以使用 @RunWith(Suite.class)

@RunWith(Suite.class)
@Suite.SuiteClasses({TestClass1.class, TestClass2.class})
public class AllTests {
    // 这是一个测试套件,它将执行 TestClass1 和 TestClass2 中的所有测试
}

再比如 Mockito 提供的运行器用于简化使用 Mockito 进行单元测试时的设置。MockitoJUnitRunner 会自动初始化带有 @Mock 注解的字段,避免你手动调用 MockitoAnnotations.initMocks()

@RunWith(MockitoJUnitRunner.class)
public class MyMockitoTest {

    @Mock
    private MyService myService;

    @Test
    public void testMock() {
        when(myService.doSomething()).thenReturn("Mocked!");
        assertEquals("Mocked!", myService.doSomething());
    }
}

除此之外,在一些特殊场景下,可以自定义一个 Test Runner 控制器来控制测试执行的流程:

public class MyCustomRunner extends Runner {

    private Class<?> testClass;

    public MyCustomRunner(Class<?> testClass) {
        this.testClass = testClass;
    }

    @Override
    public Description getDescription() {
        return Description.createSuiteDescription(testClass);
    }

    @Override
    public void run(RunNotifier notifier) {
        // 自定义测试运行逻辑
    }
}

Note

JUnit 5 引入了更强大的扩展机制,其中 @RunWith 注解被 @ExtendWith 注解替代,用于支持更多的扩展功能。JUnit 5 的测试运行器概念更加灵活,可以通过扩展接口来实现对测试执行的控制。

测试执行顺序

JUnit 设计时并没有规定测试方法的执行顺序。最初,测试方法是按反射 API 返回的顺序依次执行的。然而,依赖 JVM 的执行顺序并不是一个好选择,因为 Java 平台并没有规定特定的顺序,实际上 JDK 7 返回的是一种随机的顺序。 编写良好的测试代码不应该依赖方法执行的顺序,但有些代码可能会假设顺序。 对于某些平台来说,能预见的失败比随机失败更容易排查问题。

从 JUnit 4.11 版本开始,JUnit 默认使用一种确定性的执行顺序(即每次运行时顺序是固定的),但这个顺序仍然是不可预测的。因此 JUnit 4.13 提供了修改测试执行顺序的注解 @OrderWith@OrderWith注解的参数是 Ordering.Factory 的一个实例。 JUnit 在 org.junit.tests.manipulation 包中提供了 Ordering.Factory 的实现。用户也可以创建自己的 Ordering.Factory 实例, 以实现自定义的测试方法排序。Ordering.Factory 的实现应当有一个公共构造函数,该构造函数接受一个 Ordering.Context 参数 (可以参考 Alphanumeric 源代码中的示例)。

import org.junit.Test;
import org.junit.runner.OrderWith;
import org.junit.runner.manipulation.Alphanumeric;

@OrderWith(Alphanumeric.class)
public class TestMethodOrder {

    @Test
    public void testA() {
        System.out.println("first");
    }
    @Test
    public void testB() {
        System.out.println("second");
    }
    @Test
    public void testC() {
        System.out.println("third");
    }
}

上面的代码执行顺序会按照字母的升序进行执行,Alphanumeric.class 的源码看到,Alphanumeric 继承了 Sorter 实现了 Ordering.Factory

public final class Alphanumeric extends Sorter implements Ordering.Factory {

    public Alphanumeric() {
        super(COMPARATOR);
    }

    public Ordering create(Context context) {
        return this;
    }

    private static final Comparator<Description> COMPARATOR = new Comparator<Description>() {
        public int compare(Description o1, Description o2) {
            return o1.getDisplayName().compareTo(o2.getDisplayName());
        }
    };
}

从 JUnit 4.11 版本开始,可以通过简单地在测试类上使用 @FixMethodOrder 注解,并指定可用的 MethodSorters 之一, 来改变测试方法的执行顺序:

  • @FixMethodOrder(MethodSorters.JVM):按照 JVM 返回的顺序执行测试方法。这个顺序可能在不同的运行中有所不同。
  • @FixMethodOrder(MethodSorters.NAME_ASCENDING):按方法名的字典顺序对测试方法进行排序。

如果没有指定 @FixMethodOrder@OrderWith 注解,默认的执行顺序相当于 @FixMethodOrder(MethodSorters.DEFAULT)

import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestMethodOrder {

    @Test
    public void testA() {
        System.out.println("first");
    }
    @Test
    public void testB() {
        System.out.println("second");
    }
    @Test
    public void testC() {
        System.out.println("third");
    }
}

异常测试

JUnit 的异常测试(Exception Testing)是指在使用 JUnit 进行单元测试时,验证代码在遇到异常或错误条件时是否按预期抛出异常。 JUnit 支持的异常测试的有如下几种:

  • 使用 assertThrows 方法。
  • 使用 Try/Catch 语句。
  • @Test 注解上使用 expected 参数。
  • 使用 ExpectedException 规则。

assertThrows

assertThrows 方法是在 4.13 版本的 Assert 类中新增的。通过这个方法,可以验证某个函数调用(比如通过 lambda 表达式或方法引用指定)是否抛出了特定类型的异常。同时,assertThrows 会返回抛出的异常对象, 可以进一步对异常进行验证(比如检查异常的消息和原因是否符合预期)。

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class ExceptionTest {

    @Test
    public void testThrowException() {
        // 使用 assertThrows 验证代码块是否抛出了指定异常
        IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Invalid argument!");
        });
        // 可以进一步验证异常的消息
        assertEquals("Invalid argument!", thrown.getMessage());
    }

    @Test
    public void testExceptionAndState() {
      List<Object> list = new ArrayList<>();

      IndexOutOfBoundsException thrown = assertThrows(
          IndexOutOfBoundsException.class,
          () -> list.add(1, new Object()));

      // assertions on the thrown exception
      assertEquals("Index: 1, Size: 0", thrown.getMessage());
      // assertions on the state of a domain object after the exception has been thrown
      assertTrue(list.isEmpty());
}

Try/Catch

如果使用的 4.13 以前的版本,可以结合 Try/Catch 语句进行异常测试:

@Test
public void testExceptionMessage() {
  List<Object> list = new ArrayList<>();

  try {
    list.get(0);
    fail("Expected an IndexOutOfBoundsException to be thrown");
  } catch (IndexOutOfBoundsException anIndexOutOfBoundsException) {
    assertThat(anIndexOutOfBoundsException.getMessage(), is("Index: 0, Size: 0"));
  }
}

expected

@Test 注解有一个可选的参数叫做 expected,它接受 Throwable 的子类作为值。如果我们想验证 ArrayList 是否抛出了正确的异常, 可以这样写:

@Test(expected = IndexOutOfBoundsException.class) 
public void empty() { 
  new ArrayList<Object>().get(0); 
}

expected 参数应谨慎使用。上述测试方法只要方法中的任何代码抛出 IndexOutOfBoundsException,测试就会通过。 而且,使用这种方式时,无法验证异常中的消息内容,也无法检查异常抛出后领域对象的状态。因此,推荐使用前面提到的其他方法。

ExpectedException

另一种测试异常的方法是使用 ExpectedException 规则,但这种方法在 JUnit 4.13 中已经被弃用了。 这个规则不仅可以让你指定期望的异常类型,还可以指定期望的异常消息:

@Rule
public ExpectedException thrown = ExpectedException.none();

@Test
public void shouldTestExceptionMessage() throws IndexOutOfBoundsException {
  List<Object> list = new ArrayList<Object>();

  thrown.expect(IndexOutOfBoundsException.class);
  thrown.expectMessage("Index: 0, Size: 0");
  list.get(0); // execution will never get past this line
}

expectMessage 还允许使用 Matchers,这为测试提供了更多的灵活性。例如:

thrown.expectMessage(CoreMatchers.containsString("Size: 0"));

这段代码会验证抛出的异常消息中是否包含 "Size: 0" 字符串。

此外,还可以使用 Matchers 来检查异常,这在异常中包含嵌入状态(例如,异常的字段或其他信息)时特别有用。例如:

thrown.expect(MyCustomException.class);
thrown.expect(Matchers.hasProperty("errorCode", Matchers.is(404)));

在这个例子中,Matchers.hasProperty 用来检查抛出的 MyCustomException 是否包含 errorCode 属性, 并且验证其值是否为 404。这种方式使你能够对异常中的具体内容进行更精确的检查。

Danger

当测试调用被测方法并且该方法抛出异常时,测试中在该方法之后的代码将不会执行(因为被测方法抛出了异常)。 这种行为可能会导致混淆,这也是 ExpectedException.none() 被弃用的原因之一。

忽略测试

如果由于某种原因,不希望一个测试失败,而是希望它被忽略,可以暂时禁用该测试。 在 JUnit 中,忽略一个测试的方法有两种方式:你可以注释掉方法,或者删除 @Test 注解; 不过,测试运行器将不会报告这样的测试。另一种方式是,在 @Test 注解前或后添加 @Ignore 注解。 测试运行器会报告被忽略的测试数量,以及已执行的测试数量和失败的测试数量。

需要注意的是,@Ignore 注解可以接受一个可选的参数(一个字符串),用来记录忽略测试的原因。

@Ignore("Test is ignored as a demonstration")
@Test
public void testSame() {
    assertThat(1, is(1));
}

设置测试超时

对于那些“失控”或耗时过长的测试,可以自动标记为失败。实现这种行为有两种方式:

  • @Test 注解中添加 timeout 超时。
  • 使用 Timeout 规则。

@Test 添加超时

@Test(timeout=1000)
public void testWithTimeout() {
  ...
}

这是通过在单独的线程中运行测试方法来实现的。如果测试运行超过了分配的超时时间,测试将失败,JUnit 会中断运行测试的线程。 如果测试在执行一个可中断的操作时超时,运行测试的线程将退出(如果测试处于无限循环中,运行测试的线程将一直运行,而其他测试则会继续执行)。

Timeout 规则

Timeout 规则会将相同的超时时间应用于类中的所有测试方法,并且目前会与每个测试方法上 @Test 注解中指定的超时参数一起执行。

import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.Timeout;

public class HasGlobalTimeout {
    public static String log;
    private final CountDownLatch latch = new CountDownLatch(1);

    @Rule
    public Timeout globalTimeout = Timeout.seconds(10); // 10 seconds max per method tested

    @Test
    public void testSleepForTooLong() throws Exception {
        log += "ran1";
        TimeUnit.SECONDS.sleep(100); // sleep for 100 seconds
    }

    @Test
    public void testBlockForever() throws Exception {
        log += "ran2";
        latch.await(); // will block 
    }
}

Timeout 规则中指定的超时适用于整个测试环境,包括任何 @Before@After 方法。 如果测试方法处于无限循环中(或其他原因无法响应中断),则 @After 方法将不会被调用。

参数化测试

参数化测试(Parameterized Test)是 JUnit 提供的一种测试机制,允许在同一个测试方法中执行多次测试,每次使用不同的参数。通过这种方式,可以减少重复的测试代码,提供代码的维护性和可读性。 例如,需要给计算斐波那契数列的方法 Fibonacci 编写一个参数化测试:

public class Fibonacci {
    public static int compute(int n) {
        int result = 0;

        if (n <= 1) { 
            result = n; 
        } else { 
            result = compute(n - 1) + compute(n - 2); 
        }

        return result;
    }
}

那么,使用参数化测试可以这样写:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

import java.util.Arrays;
import java.util.Collection;

import static org.junit.Assert.assertEquals;

@RunWith(Parameterized.class)
public class FibonacciTest {
    // data() 方法返回一个 Collection<Object[]>。
    // 这里我们使用创建一个数据集,每个 Object[] 包含两个元素:
    // 第一个是 Fibonacci 数列的输入值,第二个是预期的输出值。
    @Parameterized.Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{{0, 0}, {1, 1}, {2, 1}, {3, 2}, {4, 3}});
    }

    private final int fInput;
    private final int fExpected;

    public FibonacciTest(int fInput, int fExpected) {
        this.fInput = fInput;
        this.fExpected = fExpected;
        System.out.printf("Input: %s, Expected: %s%n", fInput, fExpected);
    }

    @Test
    public void test() {
        assertEquals(fExpected, Fibonacci.compute(fInput));
    }
}

首先,在代码中指定 JUnit 运行器(Parameterized.class)来运行测试。这使得每次运行测试时,JUnit 会使用不同的参数调用测试方法,并执行多次测试。 然后,使用 @Parameterized.Parameters 定义提供测试数据的方法,该方法返回的数据将作为测试的输入,利用的是反射构造的方式传递参数。 最后,编写测试方法 test() 验证测试的结果。

假设(Assumptions)

在编写自动化测试时,有时某些测试只有在满足特定条件时才有意义。比如,某些功能可能依赖于操作系统的类型、机器的配置或特定的系统属性。 如果这些条件不满足,测试可能并不适用,直接失败并不能提供有用的反馈。这时,我们可以使用假设(Assumptions)来跳过测试,避免无意义的失败。

假设的主要目的是在测试执行时进行条件检查,如果某些条件不满足,测试将会跳过,而不是失败。它通过 Assume 类提供的一些方法来实现。这与 断言(Assert)方法不同,断言会在条件不满足时导致测试失败,而假设则会在条件不满足时使测试跳过。

Assume 类提供了一些静态方法,可以用来定义假设条件。这些方法包括:

  • Assume.assumeTrue(boolean condition):如果条件为 true,测试继续执行;如果条件为 false,测试将被跳过;
  • Assume.assumeFalse(boolean condition):如果条件为 false,测试继续执行;如果条件为 true,测试将被跳过;
  • Assume.assumeThat(T actual, Matcher<? super T> matcher):用于更复杂的条件检查,如果条件不满足,测试将被跳过;
  • Assume.assumeNoException(Throwable exception):检查是否没有抛出异常,如果有异常发生,测试将被跳过。

例如,判断设备是不是 Windows 系统,若是的话才执行测试用例,那么可以这样写:

@Test
public void testForWindows() {
    Assume.assumeTrue(System.getProperty("os.name").contains("Windows"));
    // 只有在 Windows 上,下面的测试才会执行
    // 执行测试代码
}

Warning

断言(Assert)用于验证条件是否为真,条件不满足时测试失败;而 假设(Assume)用于检查条件是否满足,条件不满足时跳过测试,不会导致失败。

规则(Rules)

规则(Rules)提供了一种机制,可以在测试执行前或后对测试进行额外的操作或配置。JUnit 规则是基于 Java 的 TestRule 接口实现的, 它允许在每个测试方法的执行前后插入自定义的行为。这些规则可以帮助我们进行日志记录、执行时间监控、资源管理、异常捕获等。

JUnit 中的规则通常实现 TestRule 接口,该接口包含一个方法 apply(Statement base, Description description), 通过这个方法你可以在测试方法执行前或后插入自定义逻辑。Statement 表示一个测试方法或测试类的执行语句。 通过 apply 方法,可以对测试的执行进行包装或修改。Description 提供关于当前测试的元数据,如测试方法名、类名等。

例如,自定义一个 TimerRule 规则:

public class TimerRule implements TestRule {
    @Override
    public Statement apply(Statement base, Description description) {
        return new Statement() {

            @Override
            public void evaluate() throws Throwable {
                long startTime = System.currentTimeMillis();
                try {
                    // 执行测试方法
                    base.evaluate();
                } finally {
                    long endTime = System.currentTimeMillis();
                    System.out.println("Time taken: " + (endTime - startTime) + "ms");
                }
            }
        };
    }
}

使用这个规则:

public class TimerRuleTest {

    @Rule
    public TimerRule timerRule = new TimerRule();

    @Test
    public void testMethod() throws InterruptedException {
        // 模拟测试方法执行
        Thread.sleep(500);
    }
}

JUnit 提供了一些内置的规则,可以直接使用:

规则名称 功能描述
TemporaryFolder 创建临时文件和目录,测试结束后会自动清理
ExternalResource 用于设置在整个测试类生命周期内共享的外部资源
ErrorCollector 允许收集多个错误,而不是在第一个错误发生时终止测试
Verifier 用于在测试方法执行结束后,进行一些结果校验
TestWatcher 用于观察每个测试方法的执行结果,并根据测试的成功与否执行相关操作
TestName 提供当前正在执行的测试方法的名称
Timeout 为测试方法设置执行超时,若测试超时,则自动失败
ExpectedException 用于检查测试方法是否抛出了预期的异常

TemporaryFolder

TemporaryFolder 创建临时文件和目录,测试结束后会自动清理:

public static class HasTempFolder {
  @Rule
  public final TemporaryFolder folder = new TemporaryFolder();

  @Test
  public void testUsingTempFolder() throws IOException {
    File createdFile = folder.newFile("myfile.txt");
    File createdFolder = folder.newFolder("subfolder");
    // ...
  }
} 

ExternalResource

ExternalResource 用于设置在整个测试类生命周期内共享的外部资源:

public static class UsesExternalResource {
  Server myServer = new Server();

  @Rule
  public final ExternalResource resource = new ExternalResource() {
    @Override
    protected void before() throws Throwable {
      myServer.connect();
    };

    @Override
    protected void after() {
      myServer.disconnect();
    };
  };

  @Test
  public void testFoo() {
    new Client().run(myServer);
  }
}

ErrorCollector

ErrorCollector 允许收集多个错误,而不是在第一个错误发生时终止测试:

public static class UsesErrorCollectorTwice {
  @Rule
  public final ErrorCollector collector = new ErrorCollector();

  @Test
  public void example() {
    collector.addError(new Throwable("first thing went wrong"));
    collector.addError(new Throwable("second thing went wrong"));
  }
}

Verifier

Verifier 用于在测试方法执行结束后,进行一些结果校验:

public class UsesVerifierTest {
    private static String result;

    @Rule
    public final Verifier verifier = new Verifier() {
        @Override
        protected void verify() throws Throwable {
            if ("failed".equals(result)) {
                throw new AssertionError("failed");
            }
        }
    };

    @Test
    public void example() {
        result = "failed";
    }
}

TestWatcher

TestWatcher 用于观察每个测试方法的执行结果,并根据测试的成功与否执行相关操作:

import static org.junit.Assert.fail; 
import org.junit.AssumptionViolatedException; 
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

public class WatchmanTest {
  private static String watchedLog;

  @Rule
  public final TestRule watchman = new TestWatcher() {
    @Override
    public Statement apply(Statement base, Description description) {
      return super.apply(base, description);
    }

    @Override
    protected void succeeded(Description description) {
      watchedLog += description.getDisplayName() + " " + "success!\n";
    }

    @Override
    protected void failed(Throwable e, Description description) {
      watchedLog += description.getDisplayName() + " " + e.getClass().getSimpleName() + "\n";
    }

    @Override
    protected void skipped(AssumptionViolatedException e, Description description) {
      watchedLog += description.getDisplayName() + " " + e.getClass().getSimpleName() + "\n";
    }

    @Override
    protected void starting(Description description) {
      super.starting(description);
    }

    @Override
    protected void finished(Description description) {
      super.finished(description);
    }
  };

  @Test
  public void fails() {
    fail();
  }

  @Test
  public void succeeds() {
  }
}

Danger

TestWatchman 是在 JUnit 4.7 中引入的,它使用了已经弃用的 MethodRule 接口。详细信息可以参考:TestWatchman

TestWatcher 从 JUnit 4.9 版本开始取代了 TestWatchman。它实现了 TestRule 接口,而不是 MethodRule 接口。详细信息可以参考:TestWatcher

Timeout

Timeout 为测试方法设置执行超时,若测试超时,则自动失败:

public static class HasGlobalTimeout {
  public static String log;

  @Rule
  public final TestRule globalTimeout = Timeout.millis(20);
}

ExpectedException

ExpectedException 用于检查测试方法是否抛出了预期的异常:

public static class HasExpectedException {
  @Rule
  public final ExpectedException thrown = ExpectedException.none();

  @Test
  public void throwsNothing() {

  }

  @Test
  public void throwsNullPointerException() {
    thrown.expect(NullPointerException.class);
    throw new NullPointerException();
  }

  @Test
  public void throwsNullPointerExceptionWithMessage() {
    thrown.expect(NullPointerException.class);
    thrown.expectMessage("happened?");
    thrown.expectMessage(startsWith("What"));
    throw new NullPointerException("What happened?");
  }
}

ClassRule

@ClassRule 是一个非常重要的注解,用于定义应用于整个测试类的规则。不同于 @Rule 注解,它通常用于设置那些只需要在整个测试类开始时执行一次的规则,而不是每个测试方法执行时都执行的规则。

public class MyClassRuleTest {
    @ClassRule
    public static TestRule classRule = new TestRule() {
        @Override
        public Statement apply(Statement base, Description description) {
            System.out.println("Class-level rule is being applied.");
            return base;
        }
    };

    @Test
    public void test1() {
        System.out.println("Test1");
    }

    @Test
    public void test2() {
        System.out.println("Test2");
    }
}

Warning

@Rule 适用于需要在每个测试方法前后执行的规则。规则会在每个测试方法前后应用,适用于需要做一些测试前置或清理工作(比如设置和清理环境)的场景。

@ClassRule 适用于只需要在测试类级别执行一次的规则,通常用于全局性的资源管理(如数据库连接、文件系统、外部服务等)。 它是静态的,并且只会在测试类开始时执行一次。

RuleChain

RuleChain 规则允许对 TestRules 进行排序:

public static class UseRuleChain {
    @Rule
    public final TestRule chain = RuleChain
                           .outerRule(new LoggingRule("outer rule"))
                           .around(new LoggingRule("middle rule"))
                           .around(new LoggingRule("inner rule"));

    @Test
    public void example() {
        assertTrue(true);
    }
}

输出:

starting outer rule
starting middle rule
starting inner rule
finished inner rule
finished middle rule
finished outer rule

理论(Theories)

理论(Theories)是 JUnit 提供的一种用于执行参数化测试(Parameterized Tests)的方法,允许基于一组数据(可能是不同的输入值或测试条件)来运行不同的测试用例。 @Theory 注解使得能够创建根据不同的输入数据集自动运行的测试方法。每个理论测试方法都会根据输入数据进行多次执行,这样可以验证不同的输入数据是否会导致不同的行为。

使用 @Theory 给方法进行标注,结合 @DataPoints@Theory 注解来提供数据:

import org.junit.Test;
import org.junit.experimental.theories.*;
import static org.junit.Assert.*;

public class MyTheoryTest {
    // 定义一些数据点,用于提供给测试方法
    @DataPoints
    public static int[] numbers = {1, 2, 3, 4, 5};

    // 定义理论测试方法,理论上可以使用不同的数字数据进行测试
    @Theory
    public void testEvenNumbers(int number) {
        // 验证每个测试数字是否为偶数
        assertTrue("Number is not even", number % 2 == 0);
    }

    // 另外一个测试方法,检查数字是否是正数
    @Theory
    public void testPositiveNumbers(int number) {
        assertTrue("Number is not positive", number > 0);
    }
}

测试夹具(Test Fixture)

测试夹具(Test Fixture)是软件测试中一个非常重要的概念。测试夹具指的是能够提供软件测试所依赖的系统、环境、状态等前置条件的方式方法。例如:

  • 在测试开始前,完成特定的文本、数据输入,模拟或创建对象;
  • 在测试完成后,及时清理文件、取消数据库的连接等。

JUnit 提供了一些注解,使得测试类可以在每个测试之前或之后运行夹具,或者只为所有测试方法在类级别运行一次的 Test Fixture。JUnit 有四个 Test Fixture 注解(两个类级别和两个方法级别):

  • @BeforeClass@AfterClass 类级别的注解,主要用于在整个测试类范围内执行一次初始化和清理操作, 适用于共享资源的管理。它们必须是静态方法;
  • @Before@After 方法级别的注解,用于每个测试方法执行前后的初始化和清理。它们可以访问实例对象,并在每个测试方法执行时被调用。
package test;

public class TestFixturesExample {
  @BeforeClass
  public static void setUpClass() {
    System.out.println("@BeforeClass setUpClass");
  }

  @AfterClass
  public static void tearDownClass() throws IOException {
    System.out.println("@AfterClass tearDownClass");
  }

  @Before
  public void setUp() {
    System.out.println("@Before setUp");
  }

  @After
  public void tearDown() throws IOException {
    System.out.println("@After tearDown");
  }

  @Test
  public void test1() {
    System.out.println("@Test test1()");
  }

  @Test
  public void test2() {
    System.out.println("@Test test2()");
  }
}

Quote

软件测试中的 Test Fixtures 到底是什么?可以参考 Daxiong 的 这篇文章

类别(Categories)

类别(Categories)是一种用于对测试进行分组和组织的机制。使用 JUnit Categories,可以根据测试的类型、执行时间、优先级等对测试进行分类, 从而实现有选择性地运行特定的测试。特别适用于在大型项目中对不同类型的测试进行分组(如快速测试、慢速测试、集成测试等)。

在 JUnit 中使用类别(Categories)的步骤大致可以分成三步:

Step 1:定义类别的接口

类别是通过接口来定义的,通常这些接口不包含任何方法,只作为标记接口使用:

public interface FastTests { }
public interface SlowTests { }
public interface IntegrationTests { }

Step 2:为测试方法或测试类指定类别

使用 @Category 注解来指定测试方法或测试类属于哪个类别。可以为同一个测试方法或类指定多个类别:

import org.junit.Test;
import org.junit.experimental.categories.Category;

public class MyTests {

    @Test
    @Category(FastTests.class)
    public void testFast() {
        // 快速测试的逻辑
    }

    @Test
    @Category(SlowTests.class)
    public void testSlow() {
        // 慢速测试的逻辑
    }

    @Test
    @Category(IntegrationTests.class)
    public void testIntegration() {
        // 集成测试的逻辑
    }

    @Test
    @Category({FastTests.class, IntegrationTests.class})
    public void testFastAndIntegration() {
        // 同时属于快速测试和集成测试的测试逻辑
    }
}

Step 3:使用 @RunWith(Categories.class) 来运行指定类别的测试

通过在测试类上使用 @RunWith(Categories.class) 来指定使用 Categories 运行器,并通过 @Categories.IncludeCategory@Categories.ExcludeCategory 来选择运行某些类别的测试:

@RunWith(Categories.class)
@Categories.IncludeCategory(FastTests.class)  // 只运行 FastTests 类别的测试
@Suite.SuiteClasses(MyTests.class)
public class RunFastTests {
}

@RunWith(Categories.class)
@Categories.ExcludeCategory(SlowTests.class)  // 排除 SlowTests 类别的测试
@Suite.SuiteClasses(MyTests.class)
public class ExcludeSlowTests {
}

// 还可以组合多个类别进行选择性执行。例如:
@RunWith(Categories.class)
@Categories.IncludeCategory({FastTests.class, IntegrationTests.class})
@Suite.SuiteClasses(MyTests.class)
public class RunFastAndIntegrationTests {
// 既属于 FastTests 又属于 IntegrationTests 类别的测试。
}

Danger

JUnit 5 不再直接支持 @Category 注解,而是引入了 Tag 的概念,提供了类似的功能。在 JUnit 5 上使用使用 @Tag 注解来标记测试并根据标签过滤执行:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;

public class MyTests {

    @Test
    @Tag("fast")
    public void testFast() {
        // 快速测试逻辑
    }

    @Test
    @Tag("slow")
    public void testSlow() {
        // 慢速测试逻辑
    }
}

然后在运行时,选择仅运行标记了特定标签的测试,例如:

mvn test -Dgroups="fast"

Android 测试框架 —— JUnit5

Junit 5 的官网文档:https://junit.org/junit5/ 或者下载 PDF 文档。

介绍

与之前的 JUnit 4 不同,JUnit 5 由三个不同的子项目的多个模块组成。

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

JUnit Platform 作为在 JVM 上启动测试框架的基础,定义了 TestEngine API,用于开发可在该平台上运行的测试框架。 此外,平台还提供了一个控制台启动器,可以通过命令行启动平台,并提供了 JUnit 平台套件引擎, 用于在平台上使用一个或多个测试引擎运行自定义测试套件。 JUnit Platform 在流行的集成开发环境(如 IntelliJ IDEA、Eclipse、NetBeans 和 Visual Studio Code)以及构建工具(如 Gradle、Maven 和 Ant)中也得到了支持。

JUnit Jupiter 是编写 JUnit 5 测试和扩展的编程模型与扩展模型的结合。Jupiter 用于在平台上运行基于 Jupiter 的测试。 JUnit Vintage 用于在平台上运行基于 JUnit 3 和 JUnit 4 的测试,但是要求引用依赖上必须包含 JUnit 4.12 或更高版本。

  • JUnit 平台 (JUnit Platform):这是 JUnit 5 的基础,负责启动测试并提供支持其他测试引擎(如 Jupiter 和 Vintage)的框架。JUnit 平台还提供了一个控制台启动器,可以通过命令行启动平台,并提供了 JUnit 平台套件引擎,用于在平台上使用一个或多个测试引擎运行自定义测试套件;
  • JUnit Jupiter:这是 JUnit 5 的编程模型和扩展模型,是编写和执行 JUnit 5 测试的核心;
  • JUnit Vintage:这个模块允许 JUnit 4 和 JUnit 3 的测试在 JUnit 5 平台上运行,因此 JUnit 4 的测试可以无缝迁移到 JUnit 5 环境中。

三者的关系可以总结如下:

JUnit 5 的模块关系
JUnit 5 的模块关系

Note

开发者可以按自己的节奏逐步迁移,JUnit 团队仍然会继续为 JUnit 4 提供维护和修复。

相比与 JUnit 4 的变化

很多 JUnit 4 的特性在 JUnit 5 上已经不再支持,但是可以通过 JUnit Vintage 框架提供支持。由于所有特定于 JUnit Jupiter 的类和注解都位于 org.junit.jupiter 基础包中,因此将 JUnit 4 和 JUnit Jupiter 同时放在类路径中不会导致任何冲突。因此,可以安全地将现有的 JUnit 4 测试与 JUnit Jupiter 测试共存。在迁移 JUnit 4 到 JUnit 5 过程中以下这些应该注意:

  1. 注解位于 org.junit.jupiter.api 包中。
  2. 断言方法位于 org.junit.jupiter.api.Assertions。 注意,你仍然可以继续使用 org.junit.Assert 中的断言方法,或者使用其他断言库,如 AssertJ、Hamcrest、Truth 等。
  3. 假设方法位于 org.junit.jupiter.api.Assumptions。 注意,JUnit Jupiter 5.4 及后续版本支持 JUnit 4 中 org.junit.Assume 类的方法进行假设操作。具体来说,JUnit Jupiter 支持 JUnit 4 中的 AssumptionViolatedException,用来表示测试应被中止,而不是标记为失败。
  4. @Before@After 不再存在;请使用 @BeforeEach@AfterEach
  5. @BeforeClass@AfterClass 不再存在;请使用 @BeforeAll@AfterAll
  6. @Ignore 不再存在;请使用 @Disabled 或其他内置的执行条件。
  7. @Category 不再存在;请使用 @Tag
  8. @RunWith不再存在;已被 @ExtendWith 取代。
  9. @Rule@ClassRule 不再存在;已被 @ExtendWith@RegisterExtension 取代。
  10. @Test(expected = …)ExpectedException 规则不再存在;请改用 Assertions.assertThrows(…)
  11. JUnit Jupiter 中的断言和假设方法将失败消息作为最后一个参数,而不是第一个参数。

下载和安装

可以从 JUnit 5 的 MVN Repository 仓库获取到所有版本的 Maven、Gradle 依赖。

Note

JUnit 5 需要 Java 8 或者更高的版本。

Maven

<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>

Gradle

Gradle
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.11.4'
Gradle(Short)
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.4'
Gradle(Kotlin)
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4")

Hello World

在项目中添加 JUnit 5 的相关依赖后,开发一个 Hello World 体验一下 JUnit 5 的魅力吧:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("Hello World Test")
public class HelloWorldTest {
    @Test
    public void test() {
        System.out.println("Hello World");
    }
}

通过 @DisplayName 修改用例的显示名字,点击运行按钮:

Hello World

基本概念

概念 描述
测试类 任何包含至少一个测试方法的顶级类、静态成员类或 @Nested 类,即容器。测试类不能是抽象类,并且必须有一个构造函数。Java 记录类也被支持。
测试方法 任何实例方法,直接使用 @Test@RepeatedTest@ParameterizedTest@TestFactory@TestTemplate 注解或元注解。除了 @Test 外,这些注解会在测试树中创建一个容器,用于组织测试,或者(对于 @TestFactory)可能创建其他容器。
容器 测试树中的一个节点,包含其他容器或测试作为其子节点(例如,测试类)。
测试 测试树中的一个节点,当执行时验证预期行为(例如,一个 @Test 方法)。
生命周期方法 任何直接使用 @BeforeAll@AfterAll@BeforeEach@AfterEach 注解或元注解的方法。

Warning

测试方法和生命周期方法可以在当前测试类中局部声明,也可以从超类或接口继承(参见测试接口和默认方法)。 此外,测试方法和生命周期方法不得是抽象的,并且不得返回值(除了 @TestFactory 方法需要返回值)。

Warning

测试类、测试方法和生命周期方法不要求是 public,但不能是 private。 一般情况下,除非出于技术原因,建议省略测试类、测试方法和生命周期方法的 public 修饰符。 例如,当测试类被另一个包中的测试类继承时,可能需要使用 public。另一个将类和方法设为 public 的技术原因是,在使用 Java 模块系统时,简化模块路径上的测试。

对于一个标准的 JUnit 5 的测试类来说,可以参考如下的写法:

import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class StandardTests {

    @BeforeAll
    static void initAll() {
    }

    @BeforeEach
    void init() {
    }

    @Test
    void succeedingTest() {
    }

    @Test
    void failingTest() {
        fail("a failing test");
    }

    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        // not executed
    }

    @Test
    void abortedTest() {
        assumeTrue("abc".contains("Z"));
        fail("test should have been aborted");
    }

    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
    }

}

注解

JUnit Jupiter 支持以下注解,用于配置测试和扩展框架功能。除非另有说明,所有核心注解都位于 junit-jupiter-api 模块的 org.junit.jupiter.api 包中。

注解 描述
@Test 表明该方法是一个测试方法。与 JUnit 4 中的 @Test 注解不同,该注解不声明任何属性,因为 JUnit Jupiter 中的测试扩展基于各自的专用注解运行。此类方法会被继承,除非被重写。
@ParameterizedTest 表明该方法是一个参数化测试。此类方法会被继承,除非被重写。
@RepeatedTest 表明该方法是一个重复测试的测试模板。此类方法会被继承,除非被重写。
@TestFactory 表明该方法是一个动态测试的测试工厂。此类方法会被继承,除非被重写。
@TestTemplate 表明该方法是一个测试用例模板,设计为根据注册的提供者返回的调用上下文数量多次调用。此类方法会被继承,除非被重写。
@TestClassOrder 用于配置注解的测试类中 @Nested 测试类的执行顺序。此类注解会被继承。
@TestMethodOrder 用于配置注解的测试类的测试方法执行顺序,类似于 JUnit 4 中的 @FixMethodOrder。此类注解会被继承。
@TestInstance 用于配置注解测试类的测试实例生命周期。此类注解会被继承。
@DisplayName 声明一个自定义的显示名称用于测试类或测试方法。此类注解不会被继承。
@DisplayNameGeneration 声明一个自定义的显示名称生成器用于测试类。此类注解会被继承。
@BeforeEach 表示注解的方法应该在当前类中的每个 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之前执行;类似于 JUnit 4 的 @Before。这些方法是可以继承的,除非被重写。
@AfterEach 表示注解的方法应该在当前类中的每个 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之后执行;类似于 JUnit 4 的 @After。这些方法是可以继承的,除非被重写。
@BeforeAll 表示注解的方法应该在当前类中的所有 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之前执行;类似于 JUnit 4 的 @BeforeClass。这些方法是可以继承的,除非被重写,并且除非使用 "每类" 测试实例生命周期,否则必须是 static
@AfterAll 表示注解的方法应该在当前类中的所有 @Test@RepeatedTest@ParameterizedTest@TestFactory 方法之后执行;类似于 JUnit 4 的 @AfterClass。这些方法是可以继承的,除非被重写,并且除非使用 "每类" 测试实例生命周期,否则必须是 static
@Nested 表示注解的类是一个非静态的嵌套测试类。在 Java 8 至 Java 15 中,除非使用 "每类" 测试实例生命周期,否则不能直接在 @Nested 测试类中使用 @BeforeAll@AfterAll 方法。从 Java 16 开始,可以在 @Nested 测试类中将 @BeforeAll@AfterAll 方法声明为 static,无论使用何种测试实例生命周期模式。这些注解不会被继承。
@Tag 用于声明标签以便于过滤测试,可以在类或方法级别使用;类似于 TestNG 中的测试组或 JUnit 4 中的类别。这些注解在类级别是可以继承的,但在方法级别不会继承。
@Disabled 用于禁用测试类或测试方法;类似于 JUnit 4 的 @Ignore。这些注解不会被继承。
@AutoClose 表示注解的字段代表一个资源,该资源将在测试执行后自动关闭。
@Timeout 用于在测试、测试工厂、测试模板或生命周期方法的执行超过给定持续时间时使其失败。这些注解是可以继承的。
@TempDir 用于通过字段注入或参数注入在生命周期方法或测试方法中提供临时目录;位于 org.junit.jupiter.api.io 包中。这些字段是可以继承的。
@ExtendWith 用于声明性地注册扩展。
@RegisterExtension 用于通过字段编程式地注册扩展。这些字段是可以继承的。

元注解和组合注解

JUnit Jupiter 注解可以作为元注解使用。通过自定义组合注解,将元注解重新构造成一个新的注解。自定义后的注解将自动继承其元注解的语义。

例如,代替在代码中复制粘贴 @Tag("fast"),你可以创建一个名为 @Fast 的自定义组合注解。然后,@Fast 可以作为 @Tag("fast") 的替代品直接使用:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Tag;

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface Fast {
}

以下的 @Test 方法展示了如何使用 @Fast 注解:

@Fast
@Test
void myFastTest() {
    // ...
}

甚至可以进一步扩展,通过引入一个自定义的 @FastTest 注解,它可以作为 @Tag("fast")@Test 的替代品使用。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
@Test
public @interface FastTest {
}

JUnit 会自动识别以下内容作为一个标记为“fast”的 @Test 方法:

@FastTest
void myFastTest() {
    // ...
}

@DisplayName

测试类和测试方法可以通过 @DisplayName 注解声明自定义显示名称——包括空格、特殊字符,甚至表情符号——这些名称将在测试报告、测试运行器和 IDE 中显示。

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("A special test case")
class DisplayNameDemo {

    @Test
    @DisplayName("Custom test name containing spaces")
    void testWithDisplayNameContainingSpaces() {
    }

    @Test
    @DisplayName("╯°□°)╯")
    void testWithDisplayNameContainingSpecialCharacters() {
    }

    @Test
    @DisplayName("😱")
    void testWithDisplayNameContainingEmoji() {
    }

}

Note

JUnit Jupiter 支持通过 @DisplayNameGeneration 注解配置自定义显示名称生成器。 通过 @DisplayName 注解提供的值始终优先于 DisplayNameGenerator 生成的显示名称。 详见 JUnit 5 官网《2.4.1. Display Name Generators》

@Disabled

可以通过 @Disabled 注解、条件测试执行中讨论的注解之一,或者自定义的 ExecutionCondition 来禁用整个测试类或单个测试方法。 当 @Disabled 注解应用于类级别时,该类中的所有测试方法都会被自动禁用。

如果一个测试方法通过 @Disabled 注解被禁用,这将阻止该测试方法的执行,以及方法级别的生命周期回调(如 @BeforeEach 方法、@AfterEach 方法和相应的扩展 API)。然而,这并不会阻止测试类的实例化,也不会阻止类级别的生命周期回调(如 @BeforeAll 方法、@AfterAll 方法和相应的扩展 API)的执行。

@Disabled("Disabled until bug #99 has been fixed")
class DisabledClassDemo {
    @Test
    void testWillBeSkipped() {
    }
}

或者:

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class DisabledTestsDemo {

    @Disabled("Disabled until bug #42 has been resolved")
    @Test
    void testWillBeSkipped() {
    }

    @Test
    void testWillBeExecuted() {
    }

}

@(En|Dis)abledOnOs

可以通过 @EnabledOnOs@DisabledOnOs 注解,在特定的操作系统、架构或二者的组合下启用或禁用容器或测试。

Conditional execution based on operating system
@Test
@EnabledOnOs(MAC)
void onlyOnMacOs() {
    // ...
}

@TestOnMac
void testOnMac() {
    // ...
}

@Test
@EnabledOnOs({ LINUX, MAC })
void onLinuxOrMac() {
    // ...
}

@Test
@DisabledOnOs(WINDOWS)
void notOnWindows() {
    // ...
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@EnabledOnOs(MAC)
@interface TestOnMac {
}
Conditional execution based on architecture
@Test
@EnabledOnOs(architectures = "aarch64")
void onAarch64() {
    // ...
}

@Test
@DisabledOnOs(architectures = "x86_64")
void notOnX86_64() {
    // ...
}

@Test
@EnabledOnOs(value = MAC, architectures = "aarch64")
void onNewMacs() {
    // ...
}

@Test
@DisabledOnOs(value = MAC, architectures = "aarch64")
void notOnNewMacs() {
    // ...
}

@En(Dis)abledOnJre 和 @En(Dis)abledForJreRange

可以通过 @EnabledOnJre@DisabledOnJre 注解,在特定版本的 Java 运行时环境(JRE)上启用或禁用容器或测试,或者通过 @EnabledForJreRange@DisabledForJreRange 注解,在特定版本范围的 JRE 上启用或禁用。该范围默认以 JRE.JAVA_8 作为下限(最小值),JRE.OTHER 作为上限(最大值),允许使用半开区间。

@Test
@EnabledOnJre(JAVA_8)
void onlyOnJava8() {
    // ...
}

@Test
@EnabledOnJre({ JAVA_9, JAVA_10 })
void onJava9Or10() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9, max = JAVA_11)
void fromJava9to11() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9)
void fromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@EnabledForJreRange(max = JAVA_11)
void fromJava8To11() {
    // ...
}

@Test
@DisabledOnJre(JAVA_9)
void notOnJava9() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9, max = JAVA_11)
void notFromJava9to11() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9)
void notFromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@DisabledForJreRange(max = JAVA_11)
void notFromJava8to11() {
    // ...
}

@En(Dis)abledIfSystemProperty

可以通过 @EnabledIfSystemProperty@DisabledIfSystemProperty 注解,根据指定的 JVM 系统属性的值来启用或禁用容器或测试。通过 matches 属性提供的值将被解释为正则表达式。

@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void onlyOn64BitArchitectures() {
    // ...
}

@Test
@DisabledIfSystemProperty(named = "ci-server", matches = "true")
void notOnCiServer() {
    // ...
}

@En(Dis)abledIfEnvironmentVariable

可以通过 @EnabledIfEnvironmentVariable@DisabledIfEnvironmentVariable 注解,根据底层操作系统中指定的环境变量的值来启用或禁用容器或测试。通过 matches 属性提供的值将被解释为正则表达式。

@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "staging-server")
void onlyOnStagingServer() {
    // ...
}

@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = ".*development.*")
void notOnDeveloperWorkstation() {
    // ...
}

@EnabledIf

作为实现 ExecutionCondition 的替代方案,可以通过 @EnabledIf@DisabledIf 注解,根据条件方法来启用或禁用容器或测试。条件方法必须返回布尔值,并且可以不接受任何参数或只接受一个 ExtensionContext 参数。

以下测试类演示了如何通过 @EnabledIf@DisabledIf 配置一个名为 customCondition 的本地方法:

@Test
@EnabledIf("customCondition")
void enabled() {
    // ...
}

@Test
@DisabledIf("customCondition")
void disabled() {
    // ...
}

boolean customCondition() {
    return true;
}

另外,条件方法也可以位于测试类之外。在这种情况下,必须通过其全限定名进行引用,正如以下示例所示:

package example;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;

class ExternalCustomConditionDemo {

    @Test
    @EnabledIf("example.ExternalCondition#customCondition")
    void enabled() {
        // ...
    }

}

class ExternalCondition {

    static boolean customCondition() {
        return true;
    }

}

Warning

在以下几种情况下,条件方法需要是静态的:

  • 当 @EnabledIf 或 @DisabledIf 被用在类级别时
  • 当 @EnabledIf 或 @DisabledIf 被用在 @ParameterizedTest 或 @TestTemplate 方法上时
  • 当条件方法位于外部类中时

在其他情况下,可以使用静态方法或实例方法作为条件方法。

@Tag

测试类和方法可以通过 @Tag 注解进行标记。标记了 @Tag 注解的类和方法,可以在执行测试时选择特定的用例进行执行。

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("fast")
@Tag("model")
class TaggingDemo {

    @Test
    @Tag("taxes")
    void testingTaxCalculation() {
    }

}

@Nested

@Nested 测试为测试编写者提供了更多的能力,以表达多个测试组之间的关系。这样的嵌套测试利用了 Java 的嵌套类,并有助于以层次化的方式思考测试结构。以下是一个详细的示例,包括源代码和在 IDE 中执行的截图。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.EmptyStackException;
import java.util.Stack;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("A stack")
class TestingAStackDemo {

    Stack<Object> stack;

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("throws EmptyStackException when popped")
        void throwsExceptionWhenPopped() {
            assertThrows(EmptyStackException.class, stack::pop);
        }

        @Test
        @DisplayName("throws EmptyStackException when peeked")
        void throwsExceptionWhenPeeked() {
            assertThrows(EmptyStackException.class, stack::peek);
        }

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when popped and is empty")
            void returnElementWhenPopped() {
                assertEquals(anElement, stack.pop());
                assertTrue(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when peeked but remains not empty")
            void returnElementWhenPeeked() {
                assertEquals(anElement, stack.peek());
                assertFalse(stack.isEmpty());
            }
        }
    }
}

执行此示例时,在 IDE 中的测试执行树将在图形用户界面中呈现类似以下的结构:

内部类执行顺序
内部类执行顺序

@RepeatedTest

JUnit Jupiter 提供了通过在方法上添加 @RepeatedTest 注解并指定希望的重复次数来重复执行测试的功能。每次重复执行的测试都像常规的 @Test 方法一样执行,并完全支持相同的生命周期回调和扩展。

以下示例演示了如何声明一个名为 repeatedTest() 的测试方法,该方法将被自动重复执行 10 次:

@RepeatedTest(10)
void repeatedTest() {
    // ...
}

@ParameterizedTest

@ParameterizedTest 标记方法时表明该方法是一个参数化测试。参数化测试使得能够使用不同的参数多次运行同一个测试。它们的声明方式与常规的 @Test 方法相同。@ParameterizedTest 的方法必须声明至少一个数据源,用于提供每次调用的参数,然后在测试方法中使用这些参数。

以下示例演示了一个参数化测试,它使用 @ValueSource 注解指定一个字符串数组作为参数源:

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@TestTemplate

TestInfo

在所有之前的 JUnit 版本中,测试构造函数或方法不允许带有参数(至少在标准的 Runner 实现中是如此)。作为 JUnit Jupiter 的主要变化之一,现在测试构造函数和方法都允许带有参数。这为测试提供了更大的灵活性,并支持构造函数和方法的依赖注入。

ParameterResolver 定义了一个 API,用于那些希望在运行时动态解析参数的测试扩展。如果测试类的构造函数、 测试方法或生命周期方法接受参数,则该参数必须通过注册的 ParameterResolver 在运行时进行解析。

目前有三个内置的解析器,它们会自动注册:TestInfoParameterResolverRepetitionExtensionTestReporterParameterResolver

TestInfoParameterResolver

TestInfoParameterResolver:如果构造函数或方法的参数类型是 TestInfoTestInfoParameterResolver 将提供一个与当前容器或测试对应的 TestInfo 实例,作为该参数的值。然后,可以使用 TestInfo 来检索有关当前容器或测试的信息,例如显示名称、测试类、测试方法和关联的标签。显示名称可以是技术名称,如测试类或测试方法的名称,或者是通过 @DisplayName 配置的自定义名称。

TestInfo 作为 JUnit 4 中 TestName 规则的替代方案。以下示例演示了如何将 TestInfo 注入到测试构造函数、@BeforeEach 方法和 @Test 方法中。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;

@DisplayName("TestInfo Demo")
class TestInfoDemo {

    TestInfoDemo(TestInfo testInfo) {
        assertEquals("TestInfo Demo", testInfo.getDisplayName());
    }

    @BeforeEach
    void init(TestInfo testInfo) {
        String displayName = testInfo.getDisplayName();
        assertTrue(displayName.equals("TEST 1") || displayName.equals("test2()"));
    }

    @Test
    @DisplayName("TEST 1")
    @Tag("my-tag")
    void test1(TestInfo testInfo) {
        assertEquals("TEST 1", testInfo.getDisplayName());
        assertTrue(testInfo.getTags().contains("my-tag"));
    }

    @Test
    void test2() {
    }

}

RepetitionExtension

如果 @RepeatedTest@BeforeEach@AfterEach 方法的参数类型是 RepetitionInfoRepetitionExtension 会提供一个 RepetitionInfo 实例。然后,可以使用 RepetitionInfo 来检索当前重复次数的信息、总重复次数、失败的重复次数以及对应 @RepeatedTest 的失败阈值。不过需要注意的是,RepetitionExtension 只在 @RepeatedTest 上下文中注册。

TestReporterParameterResolver

如果构造函数或方法的参数类型是 TestReporterTestReporterParameterResolver 会提供一个 TestReporter 实例。 TestReporter 可用于发布当前测试运行的附加数据。这些数据可以通过 TestExecutionListener 中的 reportingEntryPublished()方法进行消费,从而可以在 IDE 中查看或包含在报告中。

class TestReporterDemo {

    @Test
    void reportSingleValue(TestReporter testReporter) {
        testReporter.publishEntry("a status message");
    }

    @Test
    void reportKeyValuePair(TestReporter testReporter) {
        testReporter.publishEntry("a key", "a value");
    }

    @Test
    void reportMultipleKeyValuePairs(TestReporter testReporter) {
        Map<String, String> values = new HashMap<>();
        values.put("user name", "dk38");
        values.put("award year", "1974");

        testReporter.publishEntry(values);
    }

}

断言(Assertion)

JUnit Jupiter 提供了许多与 JUnit 4 相同的断言方法,并新增了一些特别适合与 Java 8 lambda 表达式配合使用的断言方法。所有

JUnit Jupiter 的断言方法都是 org.junit.jupiter.api.Assertions 类中的静态方法。断言方法可以选择性地接受第三个参数作为断言消息, 该参数可以是 String 类型或 Supplier<String> 类型。

当使用 Supplier<String>(例如 lambda 表达式)时,消息会延迟求值。这可以带来性能优势,特别是在消息构建复杂或耗时的情况下, 因为消息仅在断言失败时才会被评估。

import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.concurrent.CountDownLatch;

import example.domain.Person;
import example.util.Calculator;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

class AssertionsDemo {

    private final Calculator calculator = new Calculator();

    private final Person person = new Person("Jane", "Doe");

    @Test
    void standardAssertions() {
        assertEquals(2, calculator.add(1, 1));
        assertEquals(4, calculator.multiply(2, 2),
                "The optional failure message is now the last parameter");

        // Lazily evaluates generateFailureMessage('a','b').
        assertTrue('a' < 'b', () -> generateFailureMessage('a','b'));
    }

    @Test
    void groupedAssertions() {
        // In a grouped assertion all assertions are executed, and all
        // failures will be reported together.
        assertAll("person",
            () -> assertEquals("Jane", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName())
        );
    }

    @Test
    void dependentAssertions() {
        // Within a code block, if an assertion fails the
        // subsequent code in the same block will be skipped.
        assertAll("properties",
            () -> {
                String firstName = person.getFirstName();
                assertNotNull(firstName);

                // Executed only if the previous assertion is valid.
                assertAll("first name",
                    () -> assertTrue(firstName.startsWith("J")),
                    () -> assertTrue(firstName.endsWith("e"))
                );
            },
            () -> {
                // Grouped assertion, so processed independently
                // of results of first name assertions.
                String lastName = person.getLastName();
                assertNotNull(lastName);

                // Executed only if the previous assertion is valid.
                assertAll("last name",
                    () -> assertTrue(lastName.startsWith("D")),
                    () -> assertTrue(lastName.endsWith("e"))
                );
            }
        );
    }

    @Test
    void exceptionTesting() {
        Exception exception = assertThrows(ArithmeticException.class, () ->
            calculator.divide(1, 0));
        assertEquals("/ by zero", exception.getMessage());
    }

    @Test
    void timeoutNotExceeded() {
        // The following assertion succeeds.
        assertTimeout(ofMinutes(2), () -> {
            // Perform task that takes less than 2 minutes.
        });
    }

    @Test
    void timeoutNotExceededWithResult() {
        // The following assertion succeeds, and returns the supplied object.
        String actualResult = assertTimeout(ofMinutes(2), () -> {
            return "a result";
        });
        assertEquals("a result", actualResult);
    }

    @Test
    void timeoutNotExceededWithMethod() {
        // The following assertion invokes a method reference and returns an object.
        String actualGreeting = assertTimeout(ofMinutes(2), AssertionsDemo::greeting);
        assertEquals("Hello, World!", actualGreeting);
    }

    @Test
    void timeoutExceeded() {
        // The following assertion fails with an error message similar to:
        // execution exceeded timeout of 10 ms by 91 ms
        assertTimeout(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            Thread.sleep(100);
        });
    }

    @Test
    void timeoutExceededWithPreemptiveTermination() {
        // The following assertion fails with an error message similar to:
        // execution timed out after 10 ms
        assertTimeoutPreemptively(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            new CountDownLatch(1).await();
        });
    }

    private static String greeting() {
        return "Hello, World!";
    }

    private static String generateFailureMessage(char a, char b) {
        return "Assertion messages can be lazily evaluated -- "
                + "to avoid constructing complex messages unnecessarily." + (a < b);
    }
}

尽管 JUnit Jupiter 提供的断言功能足以应对许多测试场景,但有时可能需要更强大的功能和额外的功能,如匹配器。在这种情况下,JUnit 团队推荐使用第三方断言库,如 AssertJ、Hamcrest、Truth 等。因此,开发人员可以自由选择自己喜欢的断言库。

例如,匹配器与流式 API 的组合可以使断言更加描述性和可读。尽管如此,JUnit Jupiter 的 org.junit.jupiter.api.Assertions 类并没有像 JUnit 4 中 org.junit.Assert 类那样提供接受 Hamcrest Matcher 的 assertThat() 方法。 相反,开发人员被鼓励使用第三方断言库提供的匹配器内建支持。

以下示例演示了如何在 JUnit Jupiter 测试中使用 Hamcrest 的 assertThat() 支持。只要将 Hamcrest 库添加到类路径中,就可以静态导入 assertThat()is()equalTo() 等方法,并像下面的 assertWithHamcrestMatcher() 方法那样在测试中使用它们。

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;

import example.util.Calculator;

import org.junit.jupiter.api.Test;

class HamcrestAssertionsDemo {

    private final Calculator calculator = new Calculator();

    @Test
    void assertWithHamcrestMatcher() {
        assertThat(calculator.subtract(4, 1), is(equalTo(3)));
    }

}

假设(Assumptions)

假设(Assumptions)通常用于在某些测试的条件不满足时,抛出 org.opentest4j.TestAbortedException 类型的异常,从而中止测试的执行,而不是将其标记为失败。 比如,当测试依赖于系统的某个配置属性时,当这个属性当前又不存在于系统中,此时继续进行测试就会变得毫无意义。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;

import example.util.Calculator;

import org.junit.jupiter.api.Test;

class AssumptionsDemo {

    private final Calculator calculator = new Calculator();

    @Test
    void testOnlyOnCiServer() {
        assumeTrue("CI".equals(System.getenv("ENV")));
        // remainder of test
    }

    @Test
    void testOnlyOnDeveloperWorkstation() {
        assumeTrue("DEV".equals(System.getenv("ENV")),
            () -> "Aborting test: not on developer workstation");
        // remainder of test
    }

    @Test
    void testInAllEnvironments() {
        assumingThat("CI".equals(System.getenv("ENV")),
            () -> {
                // perform these assertions only on the CI server
                assertEquals(2, calculator.divide(4, 2));
            });

        // perform these assertions in all environments
        assertEquals(42, calculator.multiply(6, 7));
    }

}

Note

也可以使用 JUnit 4 中 org.junit.Assume 类的方法来进行假设。具体来说,JUnit Jupiter 支持 JUnit 4 的 AssumptionViolatedException 异常,用于表示测试应该中止,而不是被标记为失败。

异常处理

JUnit Jupiter 提供了强大的支持来处理测试中出现的异常。在 JUnit Jupiter 中,如果测试方法、生命周期方法或扩展中抛出异常且该异常没有在方法内部被捕获,框架将把该测试或测试类标记为失败。 在以下示例中,failsDueToUncaughtException() 方法抛出一个 ArithmeticException 异常。由于该异常没有在测试方法内部被捕获,JUnit Jupiter 将把该测试标记为失败。

private final Calculator calculator = new Calculator();

@Test
void failsDueToUncaughtException() {
    // The following throws an ArithmeticException due to division by
    // zero, which causes a test failure.
    calculator.divide(1, 0);
}

JUnit Jupiter 提供了一些异常的断言方法,用于测试在预期条件下是否抛出了特定的异常。assertThrows()assertThrowsExactly() 断言是验证代码是否通过抛出适当的异常来正确响应错误条件的关键方法。

  • assertThrows() 方法用于验证在执行提供的可执行代码块时是否抛出了特定类型的异常。 它不仅检查抛出异常的类型,还检查其子类,因此适用于更为广泛的异常处理测试。 assertThrows() 断言方法返回抛出的异常对象,以便对其进行进一步的断言操作;
  • assertThrowsExactly() 方法用于当需要断言抛出的异常恰好是特定类型,而不允许其是预期异常类型的子类时使用。 这在需要验证精确的异常处理行为时非常有用。与 assertThrows() 类似,assertThrowsExactly() 断言方法也会返回抛出的异常对象, 以便对其进行进一步的断言操作。

以下是这两个断言方法的示例;

@Test
void testExpectedExceptionIsThrown() {
    // The following assertion succeeds because the code under assertion
    // throws the expected IllegalArgumentException.
    // The assertion also returns the thrown exception which can be used for
    // further assertions like asserting the exception message.
    IllegalArgumentException exception =
        assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("expected message");
        });
    assertEquals("expected message", exception.getMessage());

    // The following assertion also succeeds because the code under assertion
    // throws IllegalArgumentException which is a subclass of RuntimeException.
    assertThrows(RuntimeException.class, () -> {
        throw new IllegalArgumentException("expected message");
    });
}

@Test
void testExpectedExceptionIsThrown() {
    // The following assertion succeeds because the code under assertion throws
    // IllegalArgumentException which is exactly equal to the expected type.
    // The assertion also returns the thrown exception which can be used for
    // further assertions like asserting the exception message.
    IllegalArgumentException exception =
        assertThrowsExactly(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("expected message");
        });
    assertEquals("expected message", exception.getMessage());

    // The following assertion fails because the assertion expects exactly
    // RuntimeException to be thrown, not subclasses of RuntimeException.
    assertThrowsExactly(RuntimeException.class, () -> {
        throw new IllegalArgumentException("expected message");
    });
}

尽管任何从测试方法中抛出的异常都会导致测试失败,但在某些情况下,明确断言某个代码块在测试方法中不抛出异常是有益的。 assertDoesNotThrow() 断言可以在你想验证特定代码段没有抛出任何异常时使用。这种断言确保了在执行给定代码块时没有出现意外的异常。

@Test
void testExceptionIsNotThrown() {
    assertDoesNotThrow(() -> {
        shouldNotThrowException();
    });
}

void shouldNotThrowException() {
}

测试顺序

方法的执行顺序

尽管真正的单元测试通常不应该依赖于执行顺序,但在某些情况下,确实需要强制指定测试方法的执行顺序——例如, 在编写集成测试或功能测试时,测试的顺序很重要,尤其是与 @TestInstance(Lifecycle.PER_CLASS) 配合使用时。

要控制测试方法的执行顺序,可以在测试类或测试接口上使用 @TestMethodOrder 注解,并指定所需的 MethodOrderer 实现。

  • MethodOrderer.DisplayName:根据测试方法的显示名称按字母数字顺序对测试方法进行排序。
  • MethodOrderer.MethodName:根据测试方法的名称和形式参数列表按字母数字顺序对测试方法进行排序。
  • MethodOrderer.OrderAnnotation:根据 @Order 注解中指定的值按数字顺序对测试方法进行排序。
  • MethodOrderer.Random:伪随机地对测试方法进行排序,并支持自定义种子的配置。
  • MethodOrderer.Alphanumeric:根据测试方法的名称和形式参数列表按字母数字顺序对测试方法进行排序;已弃用,建议使用 MethodOrderer.MethodName,并将在 6.0 版本中移除。

以下示例演示了如何确保测试方法按照 @Order 注解中指定的顺序执行:

import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

@TestMethodOrder(OrderAnnotation.class)
class OrderedTestsDemo {

    @Test
    @Order(1)
    void nullValues() {
        // perform assertions against null values
    }

    @Test
    @Order(2)
    void emptyValues() {
        // perform assertions against empty values
    }

    @Test
    @Order(3)
    void validValues() {
        // perform assertions against valid values
    }

}

类的执行顺序

尽管测试类通常不应依赖于其执行顺序,但在某些情况下,强制执行特定的测试类执行顺序是有必要的。 比如希望以随机顺序执行测试类,以确保测试类之间没有意外的依赖关系。再比如,可能希望按特定顺序排列测试类,以优化构建时间,如以下场景所示:

  • 首先运行先前失败的测试和较快的测试:即“快速失败”模式。
  • 在启用并行执行的情况下,优先安排较长的测试:即“最短测试计划执行时间”模式。
  • 其他各种使用场景。

要全局配置整个测试套件的测试类执行顺序,可以使用 junit.jupiter.testclass.order.default 配置参数,指定希望使用的 ClassOrderer 的完全限定类名。ClassOrderer 需要应用在所有顶层的测试类(包括静态嵌套测试类)和 @Nested 测试类。 提供的类必须实现 ClassOrderer 接口。或使用以下内置的 ClassOrderer 实现之一。

  • ClassOrderer.ClassName:根据测试类的完全限定类名按字母数字顺序对测试类进行排序。
  • ClassOrderer.DisplayName:根据测试类的显示名称按字母数字顺序对测试类进行排序。
  • ClassOrderer.OrderAnnotation:根据 @Order 注解中指定的值按数字顺序对测试类进行排序。
  • ClassOrderer.Random:伪随机地对测试类进行排序,并支持自定义种子的配置。

要为 @Nested 测试类在本地配置测试类执行顺序,可以在包含 @Nested 测试类的外部类上声明 @TestClassOrder 注解,并在 @TestClassOrder 注解中直接提供要使用的 ClassOrderer 实现的类引用。配置的 ClassOrderer 将递归应用于 @Nested 测试类及其内部的 @Nested 测试类。需要注意的是,本地的 @TestClassOrder 声明始终会覆盖通过 junit.jupiter.testclass.order.default 配置参数全局配置的 @TestClassOrder 声明或继承的 @TestClassOrder 声明。

以下示例演示了如何保证 @Nested 测试类按照通过 @Order 注解指定的顺序执行:

@TestClassOrder(ClassOrderer.OrderAnnotation.class)
class OrderedNestedTestClassesDemo {

    @Nested
    @Order(1)
    class PrimaryTests {
        @Test
        void test1() {
        }
    }

    @Nested
    @Order(2)
    class SecondaryTests {
        @Test
        void test2() {
        }
    }
}

测试接口和默认方法

JUnit Jupiter 允许在接口的默认方法上声明 @Test@RepeatedTest@ParameterizedTest@TestFactory@TestTemplate@BeforeEach@AfterEach 注解。@BeforeAll@AfterAll 可以声明在测试接口的静态方法上,或者在测试接口或测试类上使用 @TestInstance(Lifecycle.PER_CLASS) 注解时,声明在接口的默认方法上(参见测试实例生命周期)。

@TestInstance(Lifecycle.PER_CLASS)
interface TestLifecycleLogger {

    static final Logger logger = Logger.getLogger(TestLifecycleLogger.class.getName());

    @BeforeAll
    default void beforeAllTests() {
        logger.info("Before all tests");
    }

    @AfterAll
    default void afterAllTests() {
        logger.info("After all tests");
    }

    @BeforeEach
    default void beforeEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("About to execute [%s]",
            testInfo.getDisplayName()));
    }

    @AfterEach
    default void afterEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("Finished executing [%s]",
            testInfo.getDisplayName()));
    }

}
interface TestInterfaceDynamicTestsDemo {

    @TestFactory
    default Stream<DynamicTest> dynamicTestsForPalindromes() {
        return Stream.of("racecar", "radar", "mom", "dad")
            .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }

}

@ExtendWith 和 @Tag 可以声明在测试接口上,这样实现该接口的类就会自动继承它的标签和扩展。

@Tag("timed")
@ExtendWith(TimingExtension.class)
interface TimeExecutionLogger {
}

class TestInterfaceDemo implements TestLifecycleLogger,
        TimeExecutionLogger, TestInterfaceDynamicTestsDemo {

    @Test
    void isEqualValue() {
        assertEquals(1, "a".length(), "is always equal");
    }

}

Android 测试框架 —— UI Automator

介绍

UI Automator 是 Google 在 Android 4.1 的时候推出的 Android UI 自动化测试框架。 它可以模拟用户操作(比如:点击、滑动、输入文本等)和获取应用程序的界面信息,帮助开发者构建可靠且高效的自动化测试脚本。

特点和优势

与其他的测试框架相比,UI Automator 具有以下这些特点和优势:

  1. 跨应用程序测试:UI Automator 可以跨应用程序进行自动化测试,可以获取和操作整个设备上的界面元素,而不仅仅局限于单个应用程序。
  2. 强大的界面元素查找能力:UI Automator 提供了丰富的 API 和机制来查找应用程序界面上的元素,可以根据属性、文本、ID 等条件进行准确的查找。
  3. 多种用户操作模拟:UI Automator 支持模拟用户的各种操作,如点击、滑动、长按、输入文本等,可以实现复杂的用户交互行为。
  4. 多设备支持:UI Automator 可以同时连接多个设备,并在这些设备上执行自动化测试。
  5. 异步任务处理:UI Automator 支持处理异步任务,可以等待应用程序加载完成或响应操作完成后再进行下一步操作。
  6. 丰富的日志和报告:UI Automator 提供了详细的日志和报告功能,可以帮助开发者定位问题、分析测试结果。

应用场景

下面是 UI Automator 的一些常见应用场景:

  1. 自动化测试:UI Automator 可以用于开发和执行自动化测试脚本,帮助开发者检测应用程序的功能、性能和稳定性。它可以自动模拟用户操作,执行各种测试用例,并生成详细的测试报告。
  2. UI 自动化验证:UI Automator 可以用于验证应用程序的用户界面是否正确显示和响应。它可以通过查找元素、获取属性和模拟用户操作来验证应用程序的界面元素、布局和交互逻辑。
  3. 多应用程序测试:UI Automator 可以跨应用程序进行测试,对多个应用程序的功能和集成进行验证。它可以在不同的应用程序之间切换,模拟用户在多个应用程序之间的操作和交互。
  4. 兼容性测试:UI Automator 可以在不同的设备和 Android 版本上执行自动化测试,帮助开发者确保应用程序在各种环境下的兼容性。它可以在多个真机和模拟器上运行测试用例,验证应用程序在不同设备上的表现。
  5. 性能测试:UI Automator 可以测量应用程序的性能指标,如启动时间、响应时间和内存占用等。它可以自动执行一系列操作和操作,并记录关键性能数据,以评估应用程序的性能表现。
  6. 用户体验测试:UI Automator 可以模拟用户的真实操作和使用场景,评估应用程序的用户体验。它可以模拟用户的滑动、点击、输入等操作,测试应用程序在不同用户交互情况下的响应和流畅度。

Warning

UI Automator 对于 WebView 构建的应用适配不是很好。

1.0 vs 2.0

UI Automator 主要版本有 1.0 和 2.0,现在大多使用的是 2.0 版本。可通过 UI Automator 官网 查看版本的变更历史。

类型 1.0 2.0
实现语言 Java Java
框架实现 基于 Instrumentation 框架实现 基于 Android 的 AccessibilityService 实现
支持范围 单应用 跨应用
API 功能 提供了一组用于模拟用户操作和获取界面元素信息的 API,如点击、滑动、查找元素等 在 1.0 的基础功能上进行了扩展,提供更多强大的 API 和功能,如通过 UISelector 搜索元素、设置忽略的控件、处理异步任务等。
兼容性 Android 4.1 及以上版本的设备上运行 Android 5.0 及以上的版本运行,相比1.0 版本,2.0 的兼容性更好

快速入门

安装 Android Studio

下载并安装 Android Studio

创建一个项目

在 Android Studio 中,androidTesttest 是两个常见的目录,它们分别用于不同类型的测试:

  1. androidTest:这个目录用于编写 Android UI 测试,通常用于运行 Instrumentation Tests(仪器化测试)。这些测试是针对 Android 应用的 UI 和功能进行的,依赖于 Android 设备或模拟器;
  2. test:这个目录用于编写 本地单元测试,这些测试通常是在本地 JVM 上运行的,与 Android 框架无关。它们用于测试 Java 或 Kotlin 代码的逻辑部分,而不涉及 UI 或设备相关的功能。

找到 build.gradle(Module :app) 添加依赖:

dependencies {
    ...
    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0-alpha03'
}

编写测试用例

我们以 com.example.uiautomatordemo 作为项目包名,在 androidTest 目录下创建一个 Demo.java 文件用于开发 UI 测试:

app
├── java
│   ├── com.example.uiautomatordemo
│   ├── com.example.uiautomatordemo(androidTest)
│   │   └── Demo.java
│   └── com.example.uiautomatordemo(test)

这个例子中,我们以找到桌面的 Gmail 应用,让 UI Automator 打开这个测试应用:

@RunWith(AndroidJUnit4.class)
public class Demo {
    @Test
    public void openGmail() {
        // 创建一个 Device 对象,然后先返回桌面
        UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
        device.pressHome();

        // 从桌面上找到 Gmail 应用图标,然后点击
        UiObject2 gmail = device.findObject(By.text("Gmail"));
        Boolean opened = gmail.clickAndWait(Until.newWindow(), 3000);
        assertTrue(opened);
    }
}

编写完成后,点击 Android Studio 的运行按钮,可以看到 Gmail 应用启动了。

Warning

测试设备使用的是谷歌的原生机器,使用其他的机器,需要根据机器的应用和布局挑选出合适的应用。

核心类

UI Automator 提供了相关 API 文档 ,可以查询到常用的接口和核心类。

UiDevice

UiDevice 是 UI Automator 中的非常重要的类,用于与设备进行交互,模拟用户行为,以及访问设备的各种 UI 元素。UI Automator 允许开发人员编写测试脚本,通过模拟点击、滑动、输入文本等操作来验证应用程序的行为。

在使用 UiDevice 前,需要通过 UiDevice.getInstance() 获取先初始化 UiDevice

UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

当获取到 UiDevice 后,就可以获取到设备的信息和操作设备了:

UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

// 获取屏幕尺寸
DisplayMetrics metrics = new DisplayMetrics();
device.getDisplayMetrics(metrics);

// 获取系统版本
String osVersion = device.getSystemVersion();

// 点击屏幕某个位置坐标
device.click(x, y);

// 滑动和滚动:其中 startX, startY 是滑动的起点坐标,endX, endY 是终点坐标,steps 是滑动的步数
device.swipe(startX, startY, endX, endY, steps);

// 滚动操作(在一个滚动视图中)
UiObject scrollable = device.findObject(new UiSelector().scrollable(true));
scrollable.scrollForward();  // 向前滚动

// 输入文本
UiObject editText = device.findObject(new UiSelector().className("android.widget.EditText"));
editText.setText("Hello, world!");

// 按键操作
// 按 Home 键:
device.pressHome();

// 按返回键:
device.pressBack();

// 按菜单键:
device.pressMenu();

// 按电源键:
device.pressPower();

// 获取设备屏幕截图:
File screenshot = new File("/path/to/save/screenshot.png");
device.takeScreenshot(screenshot);

// UiDevice 提供了等待方法,以确保 UI 元素在交互之前是可见的:
UiObject myButton = device.findObject(new UiSelector().text("Click me"));
myButton.waitForExists(5000);  // 等待最多 5 秒钟,直到按钮存在

UI 自动化测试依赖于页面加载的速度和UI元素的可见性。在查找元素时,使用 waitForExists() 或者 waitForSelector() 等方法来确保元素已经加载。同时,在自动化测试中,设备可能会锁屏,导致 UiDevice 无法操作。可以在测试开始时确保设备解锁:

if (!device.isScreenOn()) {
    device.wakeUp();
}

在一些设备上,可能需要授予应用程序特定的权限(如读取联系人、存储权限等)。在自动化测试中,确保权限已经授权,或者在测试中模拟用户授权。

查找元素

在 UI Automator 中,查找界面的元素的方式有以下三种:

  • 使用 UiSelector 查找元素。UiSelector 是 UI Automator 原生方法。
  • 使用 BySelector 查找元素。BySelectorUiSelector 的方法进行一次封装,使得查找元素更加方便。
  • 使用 By 查找元素。By 是对 BySelector 的再次封装和简化,两者的 API 基本相同。

Quota

谷歌对 ByBySelector 的解释:

By is a utility class which enables the creation of BySelectors in a concise manner. Its primary function is to provide static factory methods for constructing BySelectors using a shortened syntax. For example, you would use findObject(By.text("foo")) rather than findObject(new BySelector().text("foo")) to select UI elements with the text value "foo".

UiSelector

UiSelector 是 UI Automator 中的一个类,用于查找 Android 设备上的 UI 元素(如按钮、文本框、列表项等)。通过使用 UiSelector 搜索出符合要求的 UI 元素,然后与之进行交互(如点击、输入文本、检查文本等)。

// 通过文本查找
UiSelector selector = new UiSelector().text("Hello");

// 通过资源 ID 查找
UiSelector selector = new UiSelector().resourceId("com.example.app:id/button1");

// 通过类名查找
UiSelector selector = new UiSelector().className("android.widget.Button");

// 通过描述查找
UiSelector selector = new UiSelector().description("Submit");

// 通过父元素查找
UiSelector selector = new UiSelector().childSelector(new UiSelector().text("Next"));

// 查找启用状态为 true 的控件
UiSelector selector = new UiSelector().enabled(true);

// 查找可点击的控件
UiSelector selector = new UiSelector().clickable(true);

// 根据控件在父容器中的位置查找
UiSelector selector = new UiSelector().index(1);  // 查找父容器中第2个元素

// 查找包含特定文本的控件
UiSelector selector = new UiSelector().textContains("Submit");

// 使用正则表达式匹配文本内容
UiSelector selector = new UiSelector().textMatches(".*Submit.*");

// 查找具有指定坐标范围的控件。
UiSelector selector = new UiSelector().bounds("[0,0][100,100]");

通常,UiSelector 查找到的控件会与 UiDevicefindObject 方法结合使用,完成点击等动作:

UiSelector selector = new UiSelector().text("Next").clickable(true);
UiObject nextButton = device.findObject(selector);
nextButton.click();

Note

如果找不到符合条件的 UI 元素,UiObject 的相关方法可能会抛出 UiObjectNotFoundException。可以使用 exists() 等方法检查元素是否存在,或者通过 waitForExists() 等方法添加等待机制。

BySelector

UiSelector 的基础上,BySelector 对其所有的方法进行了一次封装,使得查找元素更加方便。

BySelector selector = new BySelector().text("foo");
UiObject foo = device.findObject(selector);
foo.click();

Warning

在最新版本的 UI Automator 上,BySelector 的构造方法标记了:

Clients should not instanciate this class directly. Use the By factory class instead.

谷歌推荐使用 By 方法代替 BySelector

By

UiSelector 的基础上,ByBySelector 再进行了一次封装。经过封装后的 By 缩短了 BySelector 的语法,提供了静态工厂方法来构造 BySelector 对象。

BySelector selector = By.text("foo");
UiObject foo = device.findObject(selector);
foo.click();

通过查看 By.text() 方法的实现,可以看到 By.text() 是对 BySelector 的一层封装。

public static @NonNull BySelector text(@NonNull String text) {
    return new BySelector().text(text);
}
UiObject

使用 UiSelector 或者 By 等方式,筛选出界面上的元素,再通过 UiDevice 执行查找操作后,返回的是一个 UiObject 对象。 UiObject 是 UI Automator 中用于表示和操作单个 UI 元素的类。通过 UiObject,可以与 UI 元素进行交互,如点击、输入文本、检查属性等。

// 获取 UiDevice 实例
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

// 使用 UiSelector 查找一个文本为 "Submit" 的按钮,点击按钮
UiObject button = device.findObject(new UiSelector().text("Submit"));
button.click(); 

// 输入文本
UiObject editText = device.findObject(new UiSelector().className("android.widget.EditText"));
editText.setText("Hello, UI Automator!");  // 输入文本

// 获取文本内容
String text = button.getText();

// 获取元素的类名
String className = button.getClassName();  

// 获取元素的类名
if (button.exists()) {
    // 元素存在,可以继续操作
} else {
    // 元素不存在
}

// 长按按钮
button.longClick();  

// 滑动操作
UiObject scrollableList = device.findObject(new UiSelector().className("android.widget.ListView"));
scrollableList.scrollForward();  // 向前滚动
UiObject2

UiObject2 是 UI Automator 2.0 引入的新类,它是 UiObject 的升级版本,具有更多功能和更好的性能支持。UiObject2 对比 UiObject 提供了更高效的 UI 元素查找和操作,尤其是在涉及复杂的布局和动态更新的界面时,UiObject2 显得更加高效和稳定。同时, UiObject2 引入了基于 By 的查找方式,支持更灵活的定位方式,能够更快速地找到 UI 元素。

特性 UiObject UiObject2
引入版本 UI Automator 1.x UI Automator 2.0
查找方式 基于 UiSelector,支持简单查找方式 基于 By,支持多种查找方式,如 By.text()By.desc()
性能 相对较低,处理复杂布局时性能较差 性能较高,特别适用于复杂和动态更新的界面
支持视图 适用于简单 UI 元素操作 支持复杂视图,如 RecyclerViewListView
交互方式 基本的交互操作,如点击、输入文本等 更丰富的交互方式,支持更多UI组件操作
高级功能 功能较为基础,操作简单 支持更复杂的操作,如手势操作、元素滚动等
等待机制 提供基本的等待方法,如 waitForExists() 提供更强大的等待机制,支持更复杂的等待操作
容错性 对动态界面的容错能力较差 对动态界面和大量元素的操作更为稳定

UiObject2 的使用方式和 UiObject 基本相同,不同的是在使用完 findObject() 方法后,返回的是一个 UiObject2 对象:

// 获取 UiDevice 实例
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

// 使用 By 查找一个按钮
UiObject2 button = device.findObject(By.text("Submit"));
UiCollection

UiCollection 是 UI Automator 2.0 提供的一种用于处理多个 UI 元素的集合的类。与 UiObjectUiObject2 聚焦于单一 UI 元素的操作不同,UiCollection 主要用于操作一个元素集合,它允许访问和操作一组 UI 元素,如 RecyclerView, ListView, GridView 等。UiCollectionUiObject2 配合使用时,可以更方便地操作复杂的界面组件, 例如在一个可滚动列表中查找多个元素, 或是对多个 RecyclerView 项进行操作。

// 获取 UiDevice 实例
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

// 查找一个包含多个项的集合
UiCollection recyclerView = device.findObject(By.clazz("androidx.recyclerview.widget.RecyclerView"));

// 遍历集合中的所有元素
UiCollection recyclerView = device.findObject(By.clazz("androidx.recyclerview.widget.RecyclerView"));

// 获取子元素的数量
int count = recyclerView.getChildCount();
if (count == 0) {
    Log.d("RecyclerView", "No items found");
}

// 遍历 RecyclerView 中的所有项
int itemCount = recyclerView.getChildCount();
for (int i = 0; i < itemCount; i++) {
    UiObject2 item = recyclerView.getChild(i);  // 获取第 i 项
    String text = item.getText();  // 获取每一项的文本
    Log.d("Item Text", text);
}

// 向下滚动 RecyclerView
recyclerView.scroll(Direction.DOWN, 1.0f);  // 向下滚动

// 获取 RecyclerView 中的第一个子项
UiObject2 firstItem = recyclerView.getChild(0);

// 点击第一个子项
firstItem.click();
UiScrollable

UiScrollable 是 UI Automator 中用于处理滚动操作的类。它通常与 UiCollection 一起使用,尤其是在需要滚动并交互的长列表或其他可滚动容器中。 UiScrollable 提供了方法来滚动到特定的位置、元素或方向,帮助模拟用户在长列表或分页界面中的滚动操作。

UiScrollable 支持向上、向下、向左、向右滚动,可以滚动到包含特定文本或特定属性的元素。在滚动的过程中,支持调整滚动的速度和步数。

UiScrollable scrollable = new UiScrollable(By.className("androidx.recyclerview.widget.RecyclerView"));
// 向下滚动内容直到找到某个元素:
scrollable.scrollTextIntoView("Target Item");

// 滚动指定的距离或到达指定的元素:
scrollable.scrollForward();  // 向下滚动一页
scrollable.scrollBackward(); // 向上滚动一页
scrollable.scroll(Direction.DOWN, 1.0f);  // 滚动一定比例,1.0f 表示滚动满屏

// 滚动并点击某个特定的元素:
UiObject2 targetItem = scrollable.getChild(By.text("Target Item"));
targetItem.click();

条件

Condition 是 UI Automator 中用于等待某些条件满足的机制。在动态加载或界面变化的场景中,Condition 可以帮助实现更为可靠的同步,避免因界面更新未完成而引发的错误。相比传统的自动化测试方法通常使用 Thread.sleep() 来等待一段固定时间, ,使用 Condition,只有在满足特定条件时才会继续执行,不会浪费不必要的时间。Condition 可以理解为 “等待条件”,它在某个条件达成以前不会执行后续的操作。例如,等待某个按钮可点击,或等待一个加载动画消失;等待某个按钮可点击或者等待一个加载动画消失。

Condition 是一个接口类,具体的实现类有 UiObject2ConditionSearchCondition

  • UiObject2Condition 用于检查 UiObject2 的状态。通常,两者结合使用。
  • SearchCondition 代表满足一定条件的,需要查找的 UI 元素,主要用于判断是否存在某个组件。

另外一个条件是 EventConditionEventCondition 是指依赖于某个事件或一系列事件已经发生的条件。EventCondition 也是一个抽象类, 它实现的不是 Condition 接口,而是 UiAutomation.AccessibilityEventFilter 接口。

UiObject2Condition and SearchCondition EventCondition

UiObject2Condition

UiObject2Condition 是一个条件类,用于定义某个 UI 对象(如按钮、文本框、列表项等)在特定情况下的状态。该类的作用是等待某个 UI 元素满足某些特定条件,以便进行下一步的操作。例如,它可以用来等待某个 UI 元素可见、启用或者可点击等。通常这种条件检查在自动化测试中非常有用,特别是在 UI 测试场景中,确保元素准备好再进行交互。

UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
BySelector selector = By.text("foo");
UiObject2 foo = device.findObject(selector);

// 等待控件的字体发生变化,变成成 `Hello World`
UiObject2Condition<Boolean> condition = new UiObject2Condition<>() {
    @Override
    public Boolean apply(UiObject2 args) {
        return Objects.equals(args.getText(), "Hello World");
    }
};

// UiObject2Condition 和 UiObject2 一同使用
foo.wait(condition, 1000);
SearchCondition

SearchCondition 主要用于描述查找某个 UI 元素时需要满足的条件。它可以包含一组条件判断,只有当这些条件都被满足时,才会认为查找的 UI 元素是符合要求的。通常,SearchCondition 不可以单独使用,因为 SearchCondition 的接口方法 Searchable 是一个保护的方法,外部无法直接初始化。

SearchCondition<Boolean> searchCondition = new SearchCondition<Boolean>() {
    @Override
    public Boolean apply(Searchable args) { // Searchable 会报无法找到的错误
        return null;
    }
};

SearchCondition 的初始化一般会通过 Until 类进行。在 Until 类中,定义了很多返回值为 SearchCondition 的静态方法:

Until.java
public static SearchCondition<Boolean> gone(@NonNull BySelector selector) {}
public static SearchCondition<Boolean> hasObject(@NonNull BySelector selector) {}
public static SearchCondition<UiObject2> findObject(@NonNull BySelector selector) {}
public static SearchCondition<List<UiObject2>> findObjects(@NonNull BySelector selector) {}
// 创建一个 Device 对象,然后先返回桌面
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

BySelector selector = By.text("foo");
UiObject2 foo = device.findObject(selector);
SearchCondition<UiObject2> searchCondition = Until.findObject(selector);

// 通过 UiDevice 进行等待
device.wait(searchCondition, 1000);
// 通过 UiObject2 进行等待
foo.wait(searchCondition, 1000);

不管是通过 UiDevice 进行等待还是通过 UiObject2 进行等待,最终走到的都是同一个 wait 方法:

public <U> U wait(Condition<? super T, U> condition, long timeout, long interval) {
    long startTime = SystemClock.uptimeMillis();

    U result = condition.apply(mObject);
    for (long elapsedTime = 0; result == null || result.equals(false);
            elapsedTime = SystemClock.uptimeMillis() - startTime) {

        if (elapsedTime >= timeout) {
            break;
        }

        SystemClock.sleep(interval);
        result = condition.apply(mObject);
    }
    return result;
}
EventCondition

EventCondition 是 Android UI 自动化测试框架中用于表示事件条件的类。它的主要作用是在测试中判断某些特定事件是否发生, 通常用于等待某些事件完成,或者某些特定的 UI 元素状态发生变化。 EventCondition 需要重写 getResultaccept 两个接口方法。

EventCondition<Boolean> eventCondition = new EventCondition<Boolean>() {
    @Override
    public Boolean getResult() {
        return null;
    }

    @Override
    public boolean accept(AccessibilityEvent accessibilityEvent) {
        return false;
    }
};
Until

Until 是一个条件工厂类,定义了很多方法的返回值为 UiObject2ConditionSearchConditionEventCondition 的方法:

Public Method 描述
static @NonNull UiObject2Condition<Boolean> checkable(boolean isCheckable) 返回一个条件,取决于 UiObject2 是否可选中。
static @NonNull UiObject2Condition<Boolean> checked(boolean isChecked) 返回一个条件,取决于 UiObject2 是否已选中。
static @NonNull UiObject2Condition<Boolean> clickable(boolean isClickable) 返回一个条件,取决于 UiObject2 是否可点击。
static @NonNull UiObject2Condition<Boolean> descContains(@NonNull String substring) 返回一个条件,当对象的描述包含给定的字符串时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> descEndsWith(@NonNull String substring) 返回一个条件,当对象的描述以给定的字符串结尾时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> descEquals(@NonNull String contentDescription) 返回一个条件,当对象的描述与给定的字符串完全匹配时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> descMatches(@NonNull Pattern regex) 返回一个条件,当对象的描述与给定的正则表达式匹配时。
static @NonNull UiObject2Condition<Boolean> descMatches(@NonNull String regex) 返回一个条件,当对象的描述与给定的正则表达式匹配时。
static @NonNull UiObject2Condition<Boolean> descStartsWith(@NonNull String substring) 返回一个条件,当对象的描述以给定的字符串开始时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> enabled(boolean isEnabled) 返回一个条件,取决于 UiObject2 是否启用。
static @NonNull SearchCondition<UiObject2> findObject(@NonNull BySelector selector) 返回一个搜索条件,当至少找到一个符合选择器的元素时满足该条件。
static @NonNull SearchCondition<List<UiObject2>> findObjects(@NonNull BySelector selector) 返回一个搜索条件,当至少找到一个符合选择器的元素时满足该条件。
static @NonNull UiObject2Condition<Boolean> focusable(boolean isFocusable) 返回一个条件,取决于 UiObject2 是否可聚焦。
static @NonNull UiObject2Condition<Boolean> focused(boolean isFocused) 返回一个条件,取决于 UiObject2 是否获得焦点。
static @NonNull SearchCondition<Boolean> gone(@NonNull BySelector selector) 返回一个搜索条件,当未找到任何符合选择器的元素时满足该条件。
static @NonNull SearchCondition<Boolean> hasObject(@NonNull BySelector selector) 返回一个搜索条件,当至少找到一个符合选择器的元素时满足该条件。
static @NonNull UiObject2Condition<Boolean> longClickable(boolean isLongClickable) 返回一个条件,取决于 UiObject2 是否可长按点击。
static @NonNull EventCondition<Boolean> newWindow() 返回一个条件,取决于是否出现了新窗口。
static @NonNull EventCondition<Boolean> scrollFinished(@NonNull Direction direction) 返回一个条件,取决于滚动操作是否在给定方向上完成。
static @NonNull UiObject2Condition<Boolean> scrollable(boolean isScrollable) 返回一个条件,取决于 UiObject2 是否可滚动。
static @NonNull UiObject2Condition<Boolean> selected(boolean isSelected) 返回一个条件,取决于 UiObject2 是否已被选中。
static @NonNull UiObject2Condition<Boolean> textContains(@NonNull String substring) 返回一个条件,当对象的文本包含给定的字符串时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> textEndsWith(@NonNull String substring) 返回一个条件,当对象的文本以给定的字符串结尾时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> textEquals(@NonNull String text) 返回一个条件,当对象的文本与给定的字符串完全匹配时(区分大小写)。
static @NonNull UiObject2Condition<Boolean> textMatches(@NonNull Pattern regex) 返回一个条件,当对象的文本与给定的正则表达式匹配时。
static @NonNull UiObject2Condition<Boolean> textMatches(@NonNull String regex) 返回一个条件,当对象的文本与给定的正则表达式匹配时。
static @NonNull UiObject2Condition<Boolean> textNotEquals(@NonNull String text) 返回一个条件,当对象的文本与给定的字符串不匹配时。

Configurator

Configurator 用于设置 UI Automator 测试的关键参数。设置后会立即生效,并且可以在测试运行期间随时更改。 修改参数前需要先通过调用 getInstance 获取一个实例。在使用修改后的参数运行测试后,务必恢复原始的参数值,否则这将影响其他测试用例。

Public Method 描述
long getActionAcknowledgmentTimeout() 获取等待 UiObject 点击确认的当前超时时间。
static @NonNull Configurator getInstance() 获取 Configurator 的单例实例。
long getKeyInjectionDelay() 此方法已废弃。此参数不再使用(文本直接设置,而非通过按键)。
long getScrollAcknowledgmentTimeout() 获取等待 UiScrollable 滚动操作确认的当前超时时间。
int getToolType() 获取用于运动事件的当前工具类型。
int getUiAutomationFlags() 获取用于获取 android.app.UiAutomation 实例的当前标志。
long getWaitForIdleTimeout() 获取在开始 UiAutomator 操作之前等待用户界面进入空闲状态的当前超时时间。
long getWaitForSelectorTimeout() 获取等待 UiObject 在用户界面中变为可见,以便能够通过 UiSelector 匹配的当前超时时间。
@NonNull Configurator setActionAcknowledgmentTimeout(long timeout) 设置等待 UiObject 点击确认的超时时间。
@NonNull Configurator setKeyInjectionDelay(long delay) 设置按键注入延迟(此方法已废弃)。
@NonNull Configurator setScrollAcknowledgmentTimeout(long timeout) 设置等待 UiScrollable 滚动操作确认的超时时间。
@NonNull Configurator setToolType(int toolType) 设置用于运动事件的工具类型。
@NonNull Configurator setUiAutomationFlags(int flags) 设置获取 android.app.UiAutomation 实例时使用的标志。
@NonNull Configurator setWaitForIdleTimeout(long timeout) 设置等待用户界面进入空闲状态的超时时间,在开始 UiAutomator 操作之前。
@NonNull Configurator setWaitForSelectorTimeout(long timeout) 设置等待 UiObject 在用户界面中变为可见的超时时间,以便能够通过 UiSelector 匹配。

Viewer

UI Automator Viewer

在 Android 的 SDK 中提供了一个用于查找 UI 界面元素的工具:uiautomatorviewer.bat。该工具的作用是可以通过连接的 Android 设备或模拟器截图当前应用的界面,方便开发者查看界面布局。

点击 uiautomatorviewer.batDevice Screenshot 按钮,工具就会展示 UI 元素的层级树(Hierarchy),并显示每个元素的属性,如 text(文本)、resource-id(资源ID)、class(类名)、content-desc(内容描述)等。开发者可以通过该工具查看 UI 元素的各种属性,进而编写精确的 UI 自动化测试脚本。通过查看 UI 元素的定位信息(如 ID、类名、文本等),可以帮助定位元素进行点击、输入等操作。

UI Automator Viewer

Danger

uiautomatorviewer.bat 在 Java 1.8 的环境下运行良好。在其他的 Java 版本上(比如 11、17、18 等)会出现闪退的情况。

Weditor

除了使用 Android 原生提供的 uiautomatorviewer.bat 工具,其他的三方也提供了类似功能的工具。比如 Python 的自动化框架提供的 ATX Weditor 工具。

安装和使用 ATX Weditor 的步骤如下:

Step 1:安装 Python,推荐安装 Python 3。不知道如何安装的话,可以参考 安装 Python 这篇文章。

python --version

Step 2:安装 weditor 工具:

pip install weditor

Step 3:手机连接电脑,确保 adb 可以识别到设备,然后执行:

python -m weditor

执行成功后,打开浏览器访问:http://localhost:17310/

Weditor

原文链接:https://blog.csdn.net/folcan/article/details/123441086

Warning

在安装过程中,若出现报错 UnicodeDecodeError: 'gbk' codec can't decode byte 0xad in position 825: illegal multibyte sequence 可以换一个版本安装:

pip install weditor==0.6.3

Warning

在启动 Weditor 后,点击浏览器报 AttributeError: 'Device' object has no attribute 'address' 时,可以修改 page.py 文件的 IP 地址:

\Lib\site-packages\weditor\web\handlers\page.py
81
82
83
84
if platform == "android":
# ws_addr = get_device(id).device.address.replace("http://", "ws://") # yapf: disable
ret['screenWebSocketUrl'] = "192.168.1.77" + "/minicap"
self.write(ret)

Android 性能优化利器 —— Perfetto

介绍

Perfetto 是 Android Q 中推出的先进平台级追踪工具,旨在为 Android、Linux 和 Chrome 平台提供统一的性能监测和分析解决方案。其核心特色在于引入了一种全新的用户空间间追踪协议。

该协议基于 protobuf 序列化机制,将采集的数据存储到共享内存缓冲区,从而能够获取平台内部的各种数据源,如 ftrace、atrace 和 logcat。此外,Perfetto 还为上层 C++ 应用程序提供了 SDK 和库,支持自定义功能。用户可以通过灵活的配置文件对数据源抓取进行动态设置, 并能够将长时间的追踪数据流保存到文件系统中。

Perfetto 功能图

核心功能

Perfetto 的三大核心功能包括:记录追踪(record trace)、分析追踪(analyze traces)和可视化追踪(visualize traces)。

  1. 记录追踪(record trace):Perfetto 允许用户记录系统和应用程序的运行时数据,以生成详细的性能追踪信息。这包括系统级别的活动(如 CPU 使用情况、磁盘 I/O、网络流量)和应用级别的活动(如函数调用、内存分配):
    • 系统级和应用级记录:可以捕捉操作系统和应用程序的性能数据。
    • 灵活的记录选项:支持多种记录配置,包括选择特定的跟踪事件和设置记录的时间范围。
    • 原生和 Java 堆分析:支持对原生代码和 Java 堆进行详细的分析,帮助识别内存泄漏和性能瓶颈。
  2. 分析追踪(analyze traces):Perfetto 提供了强大的工具来分析记录的追踪数据。用户可以使用 SQL 查询语言对追踪数据进行深入分析,以提取有用的信息和发现性能问题:
    • SQL 查询支持:通过 SQL 查询语言进行数据分析,能够对复杂的追踪数据进行灵活查询和筛选。
    • 详细的性能分析:分析记录的追踪数据,以识别潜在的性能瓶颈和优化点。
    • 定制化分析:允许用户创建自定义的分析脚本和报告,以满足特定的需求。
  3. 可视化追踪(visualize traces):Perfetto 提供了一个基于 Web 的用户界面,用于可视化和浏览大规模的追踪数据,使得用户能够直观地查看和探索性能数据:
    • 交互式可视化:通过图形化界面展示追踪数据,包括时间轴、事件和图表,帮助用户更好地理解系统行为。
    • 多 GB 数据支持:能够处理和可视化大规模的追踪数据,使得用户可以分析复杂的系统状态和行为。
    • 探索性分析:提供工具和功能,允许用户深入挖掘数据,识别性能瓶颈和系统问题。

与传统的 Systrace 相比,Perfetto 功能更强大,界面标记更醒目。

Systrace UI 界面 Systrace UI 界面
Perfetto UI 界面 Perfetto UI 界面

录制(Record)

Danger

Perfetto 从 Android 11(R)才默认启用。在 Android 9 (P) 和 10 (Q) 上,需要启用 Perfetto 服务。

# 仅在非 Pixel 手机上的 Android 9 (P) 和 10 (Q) 上需要。
adb shell setprop persist.traced.enable 1

在 Android 9(P)之前的版本,可以使用 基于 tools/record_android_trace 脚本 的方式抓取。

抓取 Perfetto 的方式有以下四种:

Record Perfetto Traces Record Perfetto Traces

adb shell perfetto

确保设备通过 USB 连接到电脑,然后执行如下的命令:

adb shell perfetto -o /data/misc/perfetto-traces/trace_file.perfetto-trace -t 20s sched freq idle am wm gfx view binder_driver hal dalvik camera input res memory

上述命令通过 adb shell perfetto 的方式,调用了 /system/bin/perfetto 可执行文件,然后抓取 20 秒(-t参数)的 Perfetto。当抓取完成后将抓取到的 数据保存在 /data/misc/perfetto-traces/trace_file.perfetto-trace 路径下。

使用这种方式抓取 Perfetto,需要注意:

  1. Ctrl+C 通常会导致跟踪正常终止,但在使用时 ADB 不会传播该按钮 adb shell perfetto,只有在通过进行基于 PTY 的交互式会话时才会传播该按钮 adb shell
  2. 在 Android 10 之前的设备上,adb 无法直接拉取 /data/misc/perfetto-traces 。请使用 adb shell cat /data/misc/perfetto-traces/trace > trace来解决。
  3. 在 Android 12 之前的未 root 设备上,cat config | adb shell perfetto -c - 由于 SELinux 规则过于严格,配置只能以 (-: stdin) 形式传递。从 Android 12 开始,/data/misc/perfetto-configs 路径可用于存储。
  4. 在长时间抓取 Perfetto 时,可以使用 PID=$(perfetto --background) 然后 kill $PID 停止 Perfetto。

除了在 adb shell perfetto 命令后传递参数,还可以通过将参数写入到配置文件中,然后使用 -c 命令传递文件参数。受限于 SELinux 的原因,Android 12 版本前后的版本存在区别。

虽然通过命令行传递参数的方式已经足够方便了,但是很多时候需要精细化的配置 Perfetto 的参数,这种情况下就要考虑使用配置文件的方式传递参数。由于 SELinux 的原因,通过配置文件传递参数的方式在 Android 12 版本上有些不同。

Android 12 以前的设备

adb push config.pbtx /data/local/tmp/config.pbtx
adb shell 'cat /data/local/tmp/config.pbtx | perfetto -c - -o /data/misc/perfetto-traces/trace.perfetto-trace'

首先,将本地的 config.pbtx 推送到设备的 /data/local/tmp/ 路径下。然后通过 cat 的方式读取设备中的 config.pbtx 文件内容传递给 Perfetto。

Warning

在 User Debug 或者 User Root 的设备上,可以执行 adb shell setenforce 0 关闭 SELinux 权限,从而让 Perfetto 读取配置文件时不会报权限不足的情况。

Android 12 及其以后的设备

从 Android 12 开始,/data/misc/perfetto-configs 可以用于存储 Perfetto 的配置文件,因此可以直接把文件通过 -c 参数传递给 Perfetto。

adb push config.pbtx /data/misc/perfetto-configs/config.pbtx
adb shell perfetto --txt -c /data/misc/perfetto-configs/config.pbtx -o /data/misc/perfetto-traces/trace.perfetto-trace

Note

Perfetto 源码 中也提供了一些开箱即用的配置文件。

record_android_trace

Perfetto 团队推荐使用 tools/record_android_trace 脚本抓取 Perfetto。因为相比命令行的方式,基于 tools/record_android_trace 脚本的方式,简化了推送配置文件到设备、把抓取到的 Perfetto 导出到本地、使用浏览器访问它的过程,因此在使用和操作上更加便捷。

Linux 系统和 Mac:

# 下载脚本到本地
curl -O https://raw.githubusercontent.com/google/perfetto/main/tools/record_android_trace
# 添加可执行权限
chmod u+x record_android_trace 
# 执行脚本,可以通过 ./record_android_trace --help 查询帮助信息
./record_android_trace -o trace_file.perfetto-trace -t 30s -b 64mb sched freq idle am wm gfx view binder_driver hal dalvik camera input res memory

Windows:

# 下载脚本到本地
curl -O https://raw.githubusercontent.com/google/perfetto/main/tools/record_android_trace
# 执行脚本
python3 record_android_trace -o trace_file.perfetto-trace -t 30s -b 64mb sched freq idle am wm gfx view binder_driver hal dalvik camera input res memory

Note

执行 curl 时可能无法下载脚本,此时可以访问 工具源码 网页。全选源码,然后复制到本地文件,这个文件取名 record_android_trace.py 即可。或者也可以 此处点击 下载该文件。

Perfetto UI

访问 Perfetto 的官网,然后点击 Record new trace 进入到 Record Settings 界面。

通过 Perfetto Web 录制 Perfetto 通过 Perfetto Web 录制 Perfetto

通过 Add ADB Device 的方式选择需要抓取的设备,选择完成后在区域 3 选择配置参数进行配置,详细的配置参数会被显示到区域 5 中。当配置参数选择完成后,点击区域 3 中的 Recording command 命令查看 Perfetto 配置信息,可以选择分享配置信息或者将其复制到本地。

Note

选择将配置信息复制到本地时,若在 Linux 或者 Mac 设备上,可以直接将复制的内容保存为 trace.sh,然后执行:

sh trace.sh

若在 Windows 设备上,需要通过 adb shell perfetto 的方式运行,此时需要删除蓝色背景行的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
adb shell perfetto \
  -c - --txt \
  -o /data/misc/perfetto-traces/trace \
<<EOF

buffers: {
    size_kb: 63488
    fill_policy: DISCARD
}
buffers: {
    size_kb: 2048
    fill_policy: DISCARD
}
data_sources: {
    config {
        name: "android.packages_list"
        target_buffer: 1
    }
}
duration_ms: 10000

EOF

Settings

通过设置(Settings)启动系统跟踪的步骤是:Settings > 开发者选项(Developer options)> System Tracing > Record trace。

在设置中抓取 Perfetto 在设置中抓取 Perfetto

需要停止抓取 Perfetto 时,再次点击 Record trace 按钮即可停止抓取。也可以下拉状态栏,点击按钮停止。停止完成后,可以通过系统提示找到 Perfetto 的保存位置。或者在 Settings > 开发者选项(Developer options)> System Tracing 中查看是否有提示保存的路径。

配置文件

Perfetto 使用的配置文件(TraceConfig)是一个 protobuf 格式文件,在这个文件中定义了:

  • 整个跟踪系统的一般行为。例如:跟踪的最大持续时间、内存缓冲区的数量及其大小和输出跟踪文件的最大大小。
  • 启用哪些数据源及其配置。例如:对于内核跟踪数据源启用哪些 ftrace 事件;对于堆分析器,目标进程名称和采样率。
  • DataSource 和 Buffer 的映射关系,即每个数据源应该写入哪个缓冲区。

在 Perfetto 中,Trace 的跟踪服务(traced)充当配置的分发者的角色。traced 接收从 Perfetto 终端窗口或者其他启动 Perfetto 的命令(类似消费者)接收配置,然后将这些配置信息转发给生产者。当消费者启动跟踪会话时,traced 将:

  • 读取 TraceConfig 的外部部分(例如: duration_msbuffers等),并使用它们来确定其自身的行为。
  • 读取部分中的数据源列表 data_sources。对于配置中列出的每个数据源,如果 linux.ftrace 已注册相应名称,则服务将要求生产者进程启动该数据源。
Trace Config Trace Config

Buffers

缓冲区部分定义跟踪服务拥有的内存缓冲区的数量、大小和策略。它如下所示:

# Buffer #0
buffers {
  size_kb: 4096
  fill_policy: RING_BUFFER
}

# Buffer #1
buffers {
  size_kb: 8192
  fill_policy: DISCARD
}

每个缓冲区都有一个填充策略,可以是以下任一项:

  • RING_BUFFER (默认): 缓冲区的行为类似于环形缓冲区,当缓冲区已满时,写入将覆盖并替换缓冲区中最旧的跟踪数据。
  • DISCARD: 缓冲区在数据满后停止接受数据。尝试写入的新数据将被丢弃。

Danger

DISCARD 可能会对在跟踪结束时提交数据的数据源产生意外的副作用。

跟踪配置必须定义至少一个缓冲区才有效。最简单的情况是,所有数据源都会将其跟踪数据写入同一个缓冲区。虽然这对于大多数基本情况来说都没问题, 但当不同数据源的写入速率明显不同时,可能会出现问题。

例如,一个同时启用以下两项的跟踪配置:

  1. 内核调度程序跟踪器。在 Android 设备上,它每秒记录约 10000 个事件,将约 1 MB/s 的跟踪数据写入缓冲区。
  2. 内存统计轮询。此数据源将 /proc/meminfo 的内容写入跟踪缓冲区,并配置为每 5 秒轮询一次,每个轮询间隔写入 ~100 KB。

如果两个数据源都配置为写入同一个缓冲区,并且该缓冲区设置为 4MB,则大多数跟踪将仅包含一个内存快照。即使第二个数据源运行正常,大多数跟踪也很可能根本不包含任何内存快照。这是因为在 5 秒的轮询间隔内,调度程序数据源最终可能会填满整个缓冲区,从而将内存快照数据推出缓冲区。

动态缓冲区映射

在 Perfetto 中,数据源 ↔ 缓冲区 映射是动态的。在最简单的情况下,跟踪会话只能定义一个缓冲区。默认情况下,所有数据源都会将数据记录到该缓冲区中。 只定义一个缓存区对于上述提到的例子就可能存在问题。最好将这些数据源分离到不同的缓冲区中。这可以通过 TraceConfig 中的 target_buffer 字段来实现。

数据源和缓存的映射 数据源和缓存的映射

可以通过以下的方式实现:

data_sources {
  config {
    name: "linux.ftrace"
    target_buffer: 0       # <-- This goes into buffer 0.
    ftrace_config { ... }
  }
}

data_sources: {
  config {
    name: "linux.sys_stats"
    target_buffer: 1     # <-- This goes into buffer 1.
    sys_stats_config { ... }
  }
}

data_sources: {
  config {
    name: "android.heapprofd"
    target_buffer: 1       # <-- This goes into buffer 1 as well.
    heapprofd_config { ... }
  }
}

注释版配置文件

Note

详细的配置参数可以前往 TraceConfig 查询。

以下是一个较为完整的注解版的配置文件示例。

buffers: {
  size_kb: 63488  # 设置第一个缓冲区的大小为 63488 KB
  fill_policy: DISCARD  # 缓冲区满时丢弃最旧的数据
}

buffers: {
  size_kb: 2048  # 设置第二个缓冲区的大小为 2048 KB
  fill_policy: DISCARD  # 缓冲区满时丢弃最旧的数据
}

data_sources: {
  config {
    name: "android.packages_list"  # 配置数据源以收集 Android 包列表
    target_buffer: 1  # 指定该数据源使用的缓冲区
  }
}
data_sources: {
  config {
    name: "android.gpu.memory"  # 配置数据源以收集 GPU 内存信息
  }
}
data_sources: {
  config {
    name: "linux.process_stats"  # 配置数据源以收集 Linux 进程统计信息
    target_buffer: 1  # 指定该数据源使用的缓冲区
    process_stats_config {
      scan_all_processes_on_start: true  # 启动时扫描所有进程
      proc_stats_poll_ms: 1000  # 每 1000 毫秒轮询进程统计信息
    }
  }
}
data_sources: {
  config {
    # 配置数据源以收集 SurfaceFlinger 帧时间线
    name: "android.surfaceflinger.frametimeline"
  }
}
data_sources: {
  config {
    name: "android.game_interventions"  # 配置数据源以收集游戏干预信息
  }
}
data_sources: {
  config {
    name: "android.network_packets"  # 配置数据源以收集网络数据包
    network_packet_trace_config {
      poll_ms: 250  # 每 250 毫秒轮询一次网络数据包
    }
  }
}
data_sources: {
  config {
    name: "linux.sys_stats"  # 配置数据源以收集 Linux 系统统计信息
    sys_stats_config {
      meminfo_period_ms: 1000  # 每 1000 毫秒收集一次内存信息
      meminfo_counters: MEMINFO_ACTIVE_FILE  # 收集活动文件计数
      meminfo_counters: MEMINFO_ANON_PAGES  # 收集匿名页面计数
      meminfo_counters: MEMINFO_BUFFERS  # 收集缓冲区计数
      meminfo_counters: MEMINFO_COMMITED_AS  # 收集提交地址空间计数
      meminfo_counters: MEMINFO_DIRTY  # 收集脏页面计数
      vmstat_period_ms: 1000  # 每 1000 毫秒收集一次虚拟内存统计
      stat_period_ms: 1000  # 每 1000 毫秒收集一次统计信息
      stat_counters: STAT_CPU_TIMES  # 收集 CPU 时间统计
      stat_counters: STAT_FORK_COUNT  # 收集进程生成统计
      cpufreq_period_ms: 1000  # 每 1000 毫秒收集一次 CPU 频率
    }
  }
}
data_sources: {
  config {
    name: "android.heapprofd"  # 配置数据源以收集 Android 堆分析数据
    target_buffer: 0  # 指定该数据源使用的缓冲区
    heapprofd_config {
      sampling_interval_bytes: 4096  # 每 4096 字节采样一次
      shmem_size_bytes: 8388608  # 共享内存大小为 8388608 字节
      block_client: true  # 阻止客户端操作
    }
  }
}
data_sources: {
  config {
    name: "android.java_hprof"  # 配置数据源以收集 Java HPROF 数据
    target_buffer: 0  # 指定该数据源使用的缓冲区
    java_hprof_config {
    }
  }
}
data_sources: {
  config {
    name: "linux.ftrace"  # 配置数据源以收集 Linux ftrace 数据
    ftrace_config {
      ftrace_events: "clk/*"  # 收集所有时钟相关事件
      ftrace_events: "ext4/*"  # 收集所有 ext4 文件系统事件
      ftrace_events: "f2fs/*"  # 收集所有 f2fs 文件系统事件
      ftrace_events: "fastrpc/*"  # 收集所有 fastrpc 事件
      ftrace_events: "power/suspend_resume"  # 收集电源暂停和恢复事件
      ftrace_events: "sched/sched_wakeup"  # 收集调度唤醒事件
      ftrace_events: "sched/sched_wakeup_new"  # 收集新调度唤醒事件
      ftrace_events: "sched/sched_waking"  # 收集调度唤醒事件
      ftrace_events: "oom/oom_score_adj_update"  # 收集 OOM 分数调整更新事件
      ftrace_events: "sched/sched_blocked_reason"  # 收集调度阻塞原因事件
      ftrace_events: "ftrace/print"  # 收集 ftrace 打印事件
      atrace_categories: "aidl"  # 收集 AIDL 类别数据
      atrace_categories: "dalvik"  # 收集 Dalvik 类别数据
      atrace_categories: "audio"  # 收集音频类别数据
      atrace_categories: "binder_lock"  # 收集 Binder 锁类别数据
      atrace_categories: "binder_driver"  # 收集 Binder 驱动类别数据
      atrace_categories: "bionic"  # 收集 Bionic 类别数据
      atrace_categories: "ss"  # 收集 SS 类别数据
      atrace_categories: "vibrator"  # 收集振动器类别数据
      atrace_categories: "video"  # 收集视频类别数据
      atrace_categories: "view"  # 收集视图类别数据
      atrace_categories: "webview"  # 收集 WebView 类别数据
      atrace_apps: "*"  # 收集所有应用的数据
      buffer_size_kb: 1024  # 设置缓冲区大小为 1024 KB
      drain_period_ms: 250  # 每 250 毫秒清空缓冲区
      symbolize_ksyms: true  # 启用符号化内核符号
    }
  }
}
duration_ms: 10000  # 设置跟踪持续时间为 10000 毫秒

Trace Viewer

Perfetto UI 可以将抓取的 Perfetto Trace 文件通过浏览器的方式查看。它支持多种不同的跟踪格式,包括 Perfetto proto 跟踪记录格式和旧版 Json 跟踪记录格式。

Perfetto UI

Note

为了方便学习 Perfetto,在 Perfetto 导航栏的左边提供了两种类型的 Trace 案例供初学者学习。 点击左边的 Example Traces 选择 Open Android example 就可以打开一个 Android 的 Perfetto Trace 示例。

导航栏

Perfetto UI 左边显示的是导航栏,功能的作用如下表所示:

标签名 含义 作用
Open trace file 打开追踪文件 打开一个现有的 trace 文件
Open with legacy UI 使用旧版界面 使用旧版用户界面打开当前 trace 文件
Record new trace 记录新追踪 开始记录一个新的 trace 文件
Show timeline 显示时间线 显示或隐藏时间线视图
Download 下载文件 下载当前 trace 文件
Query(SQL) 执行 SQL 查询 执行 SQL 查询并查看结果
Viz 数据可视化 显示可视化图表和数据
Metrics 性能指标 查看性能指标和测量数据
Info and stats 信息和统计 查看有关当前 trace 的信息和统计数据
Switch to legacy UI 切换到旧版界面 切换到旧版用户界面
Convert to .json 转换为 .json 格式 将 trace 文件转换为 .json 格式
Convert to .systrace 转换为 .systrace 格式 将 trace 文件转换为 .systrace 格式
Open Android example 打开 Android 示例 打开 Android 示例 trace 文件
Open Chrome example 打开 Chrome 示例 打开 Chrome 示例 trace 文件
Keyboard shortcuts 键盘快捷键 查看和使用键盘快捷键
Documentation 文档 查看 Perfetto 的文档
Flags 标志设置 查看和修改 Perfetto 的标志设置
Report a bug 报告 bug 报告 Perfetto 中发现的 bug
Record metatrace 记录 metatrace 记录 metatrace 数据

快捷键

在左边的导航栏 Support 选项卡中可以点击 Keyboard shortcuts 或者 Shift + ? 查看 Perfetto 支持的快捷键。

Perfetto 快捷键

以下是使用频率最高的快捷键:

f 键让内容更聚焦

当鼠标选中任意一个 trace 数据区域时,按 f 键可以快速居中、放大选中的区域。

f 键缩放鼠标选中内容 f 键缩放鼠标选中内容
m 键让内容显示时间

当鼠标选中任意一个 trace 数据区域时,按 m 键可以显示数据区域的时间信息,按 Esc 键退出选择。若已经存在一个区域被 m 键选中,当再次选中另外一个区域时,上一区域的信息会消失。若希望持续保留上个区域的时间信息,可以使用 shift + m 组合键。

m 键让内容显示时间 m 键让内容显示时间
shift + m 键让内容显示时间 shift + m 键让内容显示时间
q 键弹出底部栏

当选中数据区域时,使用 q 键可以快速打开底部栏查看该数据区域的详细信息。

q 键弹出底部栏 q 键弹出底部栏
插旗做标记

将鼠标移动到界面插旗区域后,可以对任意区域进行插旗做标记,方便观察某个时间点的信息。选中任意一面旗帜,然后在底部栏中可以更改旗帜的颜色或者将其移除。

插旗区域 插旗区域
插旗做标记 插旗做标记

线程运行状态

在 Perfetto 中,通常会有两个轨道标记一个线程的运行情况。例如,渲染线程 RenderThread 的运行轨迹,区域 1 是该线程在 CPU 的运行情况,也就是线程的运行状态。区域 2 是该线程执行的具体方法。区域 3 是该线程运行在哪个 CPU 核上。

RenderThread 运行状态 RenderThread 运行状态

线程的运行状态有如下几种情况:

状态 颜色 含义
Sleep 线程处于休眠状态(通常是由于调用了 sleep() 或类似的系统调用)。线程不执行任何操作,CPU不会为其分配时间。
Runnable 线程处于可运行状态。这意味着线程在调度程序的调度队列中,系统会在未来的某个时刻给予它CPU时间。尽管线程处于可运行状态,但可能因为调度延迟或优先级问题,实际未能立即运行。
Running 线程正在运行中,实际占用CPU时间并执行代码。这个状态表明线程目前是活动的,正在执行任务。
Uninterruptible Sleep 线程处于不可中断的睡眠状态。这通常发生在等待I/O操作(如磁盘读取、网络数据接收等)或其他系统资源的情况下。在这种状态下,线程无法被信号或其他事件中断,直到资源变得可用。

代码插桩

在应用开发、Framework 开发,甚至是 Kernel 开发过程中,通过在代码插桩添加 trace 点,使其可以被 Perfetto 记录,从而在 Trace Viewer 中显示出来。

应用开发添加 trace

Perfetto 中的 Trace 类用于捕获和记录 Java 应用程序的性能数据。该类提供方法来开始和停止跟踪特定事件,以便分析应用程序的性能瓶颈和资源使用情况。 通过调用这些方法,开发者可以获取线程活动、方法调用和其他相关指标,帮助深入了解应用程序的运行时行为。 使用 Perfetto 进行性能分析,能够有效地优化 Java 应用的性能。

方法跟踪

onCreate 方法中添加 Trace 跟踪为例:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    Trace.beginSection("perfetto_demo_begin");

    // ... 

    Trace.endSection();
}

在 Java 代码中,通过调用 beginSection()endSection() 方法开始和停止 Trace。在应用启动前,通过如下的命令抓取 trace 信息:

adb shell perfetto -o /data/misc/perfetto-traces/perfetto_demo-1.perfetto-trace -t 10s sched freq idle \
am wm gfx view binder_driver hal dalvik camera input res memory -a com.example.perfettodemo2

在 Perfetto UI 中,可以看到在代码中添加的 Trace 信息:

Android Java 层添加 Trace 信息 Android Java 层添加 Trace 信息

Warning

使用 beginSection()endSection() 时需要注意以下三点:

  1. beginSection()endSection() 遵循就近匹配原则,在使用时应当注意它们的顺序位置;
  2. beginSection()endSection() 必须在同一个线程中;
  3. 如果 beginSectiontry catch 语句包含,则 endSection 必须放到 finally 语句块中。

Danger

默认情况下,Perfetto 只会记录和收集系统层面(比如:WMS、AMS、WindowManager、ActivityManager等)的信息。在使用 adb shell perfetto 命令时,需要添加 -a 参数,传递应用的包名信息。只有这样,Perfetto 才会记录该应用相关的信息。

Danger

设备需要开启 Trace 系统属性,否则可能无法抓取(只有在 Android 9 或者 Android 10 的设备上才需要手动开启):

adb shell getprop persist.traced.enable

Counter

Trace 提供了添加计数器的 setCounter() 方法,用于采集一些计数类信息。

public void trackEvent(int eventCount) {
    Trace.setCounter(COUNTER_NAME, eventCount);
}

public void someMethod() {
    for (int i = 0; i < 10; i++) {
        trackEvent(i);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

@Override
protected void onCreate(Bundle savedInstanceState) {
    // ...
    someMethod();
}   
Android Java 层添加 Counter 信息 Android Java 层添加 Counter 信息
android.os.Trace
android.os.Trace 类方法 描述
beginSection(String sectionName) 在一个函数中打印 Trace 开始标记,表示某个操作的开始,显示在特定线程中。可用于分析方法执行时间。
endSection() 在一个函数中打印 Trace 结束标记,表示某个操作的结束,显示在特定线程中。可与 beginSection 配对使用。
beginAsyncSection(String methodName, int cookie) 打印异步 Trace 开始标记,cookie 用来区分相同 methodName 的不同异步 Trace,独立成一行显示。适用于异步任务的性能分析。
endAsyncSection(String methodName, int cookie) 打印异步 Trace 结束标记,cookie 用于区分相同 methodName 的不同异步 Trace,独立成一行显示。
setCounter(String counterName, long counterValue) 以给定计数器的值打印 Trace,便于监控特定事件的频率或状态。可以用于性能调优和资源监测。
isEnabled() 判断 Trace 是否开启,避免在 Trace 关闭时创建无用的临时对象。建议在调用 Trace 方法前进行检查以提高性能。
asyncTraceBegin(String name) 启动一个异步 Trace,适用于较长时间的操作,提供清晰的开始标记。
asyncTraceEnd(String name) 结束一个异步 Trace,记录操作完成的时间点。

Framework 添加 trace

通过查询 AOSP 源码,可以看到在 Framework 层,AOSP 使用如下的方式添加标记:

final void performCreate(Bundle icicle, PersistableBundle persistentState) {
    if (Trace.isTagEnabled(Trace.TRACE_TAG_WINDOW_MANAGER)) {
        Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "performCreate:"
                + mComponent.getClassName());
    }
    // ...
    Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
}
Framework 层添加 performCreate 标记 Framework 层添加 performCreate 标记

在系统 Framework 层面,Trace 类新提供了一些方法给 Framework 层调用。这些方法会被标记为 SystemApi

@UnsupportedAppUsage
@SystemApi(client = MODULE_LIBRARIES)
public static boolean isTagEnabled(long traceTag) {
    return nativeIsTagEnabled(traceTag);
}

android.os.Trace 的基础上,新增了如下的方法:

android.os.Trace 类方法 描述
traceBegin(long traceTag, String methodName) 开始一个新的追踪段,记录方法名称。
traceEnd(long traceTag) 结束最近的追踪段。
asyncTraceBegin(long traceTag, String methodName, int cookie) 开始一个异步追踪段,记录方法名称和 cookie。
asyncTraceEnd(long traceTag, String methodName, int cookie) 结束异步追踪段。
traceCounter(long traceTag, String counterName, int counterValue) 设置一个计数器的值,用于监控特定事件的频率。
isTagEnabled(long traceTag) 检查特定追踪标签是否启用。

Warning

Trace 类中 traceBegin 和 beginSection 都可以开启 trace 跟踪,两者的区别主要体现在:

  1. traceBegin 方法接收两个参数,而 beginSection 方法只接收一个参数;
  2. traceBegin 方法添加了 @UnsupportedAppUsage@SystemApi(client = MODULE_LIBRARIES) 注解,表明该方法只能在系统层面进行调用。 而 beginSection 没有任何的注解,表明该方法不仅可以在系统层面也可以在应用层进行调用;

不管是 traceBegin 还是 beginSection,亦或是 traceEndendSection,其逻辑都会调用 Native 方法 nativeTraceBeginnativeTraceEnd

Danger

在抓取的 Perfetto trace 中没有看到想要的 trace 信息,可以添加 category 指定需要抓取的 trace 包含的信息:

adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace -t 10s sched freq idle \
am wm gfx view binder_driver hal dalvik camera input res memory

这些 category 被声明可以在 Trace.javaatrace.cpp 中:

public static final long TRACE_TAG_NEVER = 0;
public static final long TRACE_TAG_ALWAYS = 1L << 0;
...
public static final long TRACE_TAG_AUDIO = 1L << 8;
public static final long TRACE_TAG_VIDEO = 1L << 9;
public static final long TRACE_TAG_CAMERA = 1L << 10;
public static final long TRACE_TAG_HAL = 1L << 11;
...

Kernel 开发添加 trace

ftrace 是 Linux 内核中的一个功能强大的跟踪工具,用于监控和分析内核的运行时行为。它允许开发者和系统管理员跟踪函数调用、上下文切换、调度事件等, 以帮助识别和解决性能问题。通过 ftrace,用户可以启用不同的跟踪选项,获取有关内核各个部分的详细信息。这些信息通常以文本形式输出,可以通过 /sys/kernel/debug/tracing 文件系统进行访问和配置。ftrace 的灵活性使其适用于多种场景,如性能调优、安全审计和故障排查。

Note

ftraceatrace 是 Android 中用于性能分析和调试的两种工具,但它们适用的层次和用途不同。 ftrace 是 Linux 内核提供的功能,主要用于内核空间的调试和性能分析,能够跟踪各种内核事件, 如系统调用和上下文切换,输出详细的内核级别信息。相对而言,atrace 是针对用户空间的工具, 专注于应用程序的性能监测,支持生成可视化的执行过程和与 Systrace 和 Perfetto 的集成。总的来说, ftrace 更适合内核层面的分析,而 atrace 则更关注应用程序的性能问题,选择使用哪一个工具取决于具体的分析需求。

在 Kernel 层添加 Trace,可以使用下面的两个库文件:

  1. libcutils.so/system/core/libcutils/include/cutils/trace.h
  2. libutils.so/system/core/libutils/include/utils/Trace.h

libcutils.so 提供如下的方法:

函数名 描述
ATRACE_BEGIN(name) 在一个函数中打印 Trace 开始标记,会显示在某个线程中。
ATRACE_END() 在一个函数中打印 Trace 结束标记,会显示在某个线程中。
ATRACE_ASYNC_BEGIN(name, cookie) 打印异步 Trace 开始标记,cookie 用来区分相同名称但不同的异步 Trace,独立成一行显示。
ATRACE_ASYNC_END(name, cookie) 打印异步 Trace 结束标记,cookie 用来区分相同名称但不同的异步 Trace,独立成一行显示。
ATRACE_INT(name, value) 以给定计数器的值打印 Trace。
ATRACE_INT64(name, value) 以给定计数器的值打印 Trace。
ATRACE_ENABLED() 判断是否开启了 Trace。

libutils.so 提供如下的方法:

函数名 描述
ATRACE_NAME(name) ATRACE_BEGIN(name)ATRACE_END() 的简写形式。
ATRACE_CALL() ATRACE_BEGIN()ATRACE_END() 的简写形式,name 固定为当前方法名。

Trace Processor Shell

trace_processor_shell 是 Perfetto 项目中的一个重要工具,它是一个交互式的 SQL shell,用于分析和查询性能跟踪数据。 通过 trace_processor_shell 可以加载和分析 Perfetto 跟踪文件,尤其是对一些“大”的 trace 文件可以离线打开。 同时,还提供了强大的 SQL 查询能力,允许用户使用 SQL 语句查询跟踪数据。

下载工具

访问 Github 可以下载各个版本的 Trace Processor Shell 工具。 根据系统选择一个可运行的可执行文件:

Trace Processor Shell

运行工具

Note

本章节的运行环境系统是 Windows 11。

下载成功后,执行 --help 参数显示工具的帮助信息:

$ trace_processor_shell.exe --help
Usage: trace_processor_shell.exe [FLAGS] trace_file.pb
类别 选项 描述说明
基础选项 -h, --help 打印帮助指南
-v, --version 打印跟踪处理器版本
-d, --debug 启用虚拟表调试
-W, --wide 以双倍列宽打印交互式输出
性能分析 -p, --perf-file 文件 将跟踪加载和查询执行耗时写入指定文件(仅配合-q或--run-metrics使用)
查询相关 -q, --query-file 文件 从文件读取并执行SQL查询
-i, --interactive 即使指定了查询文件也启动交互模式
HTTP服务 -D, --httpd 启用HTTP RPC服务器
--http-port 端口号 指定HTTP RPC服务器端口
数据导出 -e, --export 文件 将跟踪处理器内容导出到SQLite数据库
功能开关 --full-sort 强制全排序(忽略窗口逻辑)
--no-ftrace-raw 阻止将类型化ftrace事件摄入原始表
--analyze-trace-proto-content 启用跟踪协议内容分析
--crop-track-events 忽略兴趣范围之外的轨道事件
--dev 启用仅限开发使用的功能
--dev-flag 键=值 设置开发标志值(需配合--dev使用)
标准库 --add-sql-module 模块路径 将目录文件视为新SQL模块
--override-sql-module 模块路径 用指定内容覆盖跟踪处理器模块
--override-stdlib=[标准库路径] 覆盖trace_processor/stdlib(需配合--dev使用)
指标分析 --run-metrics x,y,z 运行逗号分隔的指标列表
--pre-metrics 文件 在执行指标前执行SQL查询(无输出)
--metrics-output=[binary\|text\|json] 指定指标输出格式
--metric-extension 磁盘路径@虚拟路径 从磁盘路径加载指标proto/sql文件
元跟踪 -m, --metatrace 文件 启用元跟踪并将结果写入文件
--metatrace-buffer-capacity 数量 设置元跟踪事件缓冲区容量
--metatrace-categories 类别列表 逗号分隔的元跟踪类别列表

创建本地服务

trace_processor_shell 可以在本地开启一个 HTTP 服务,用于离线打开一些过大的 Perfetto trace 文件。通过 --httpd 参数后,默认会在本地端口 9001 启动 HTTP 服务:

trace_processor_shell.exe --httpd

启动 HTTP 服务后,浏览器访问 http://localhost:9001/ 会提示跳转到 https://ui.perfetto.dev/

Perfetto Trace Processor RPC Server
This service can be used in two ways:

1. Open or reload https://ui.perfetto.dev/

It will automatically try to connect and use the server on localhost:9001 when
available. Click YES when prompted to use Trace Processor Native Acceleration
in the UI dialog.
See https://perfetto.dev/docs/visualization/large-traces for more.
...

界面会提示 Incompatible RPC version,选择 Use Bultin Wasm

Perfetto Httpd Dev

通过 HTTP 服务功能的优势体现在:

  1. 原生加速引擎。绕过 WASM 限制直接调用底层 C++ 解析引擎(非浏览器 WASM 模拟),实现10倍以上解析速度提升(实测 1GB trace 文件解析时间从 30s 降至 3s 内)。
  2. 混合架构优化。Rust/C++ 混合实现的解析器,通过内存布局优化,紧凑型数据结构降低 40% 内存占用,线程分段解析 trace 文件(支持 mmap 流式读取),直接操作原始 trace 数据块,避免序列化开销。
  3. 交互式分析能力。深度集成 Perfetto UI,与官网提供的 Perfetto UI 功能上和使用方法一样。
  4. 安全离线调试。数据全程驻留本地内存(memfd 匿名内存文件),同时支持 TLS 1.3 加密(通过 --http-port 443 绑定)。
  5. 流式解析技术允许超大文件解析,50GB+ trace 文件处理。

下表是使用 trace_processor_shell --httpd 和纯网页版的功能对比:

对比维度 命令行本地服务 (trace_processor_shell --httpd) 纯网页版 (ui.perfetto.dev)
启动方式 需本地执行命令启动服务 直接浏览器访问
核心架构 C++/Rust 原生进程,多线程优化 WebAssembly 单线程模拟
解析性能 ▶ 1GB trace 解析约 2-5s
▶ 支持 50GB+ 文件流式处理
▶ 1GB trace 解析约 20-30s
▶ 超过2GB易崩溃
内存效率 内存占用降低 60%(紧凑数据结构+mmap) 全文件加载,内存占用高
功能支持 ✅ 完整 SQL 查询
✅ 自定义指标计算
✅ 跨会话状态保持
⚠️ 基础 SQL
❌ 无法保存临时表
高级特性 🔧 Metatracing 调试
🔧 多标签页协同分析
🔗 文件分享链接
📥 修改后下载
网络要求 纯本地操作,支持内网隔离环境 需初始加载 15MB WASM 资源
安全控制 可绑定 TLS/SSL
支持审计日志
依赖浏览器沙箱
典型场景 🔧 内核/车载长时日志分析
🔬 芯片厂商深度调试
🚀 快速查看小文件
🤝 团队协作分享
扩展性 支持插件式指标扩展(--metric-extension) 固定功能集
调试支持 原生崩溃日志、gdb 调试 仅限浏览器 DevTools

在性能方面,根据网上公开的资料显示性能实测数据(i7-1280P/32GB 环境):

测试项 本地服务 WASM 网页版
500MB trace 加载 1.2s / 800MB 内存 12s / 2.1GB 内存
10GB trace 流式处理 ✅ 峰值 3.2GB 内存 ❌ 崩溃
10万行 SQL 聚合查询 0.8s 6.5s

Note

Q:命令行本地服务和纯网页版的选型建议?

  1. 开发调试/企业环境 ➔ 本地服务(支持 Docker 部署)

  2. 协作分享/快速检查 ➔ 网页版

  3. 混合方案:用本地服务分析后,导出 SQLite 再上传网页版分享

Trace to SQLite

trace_processor_shell 可以将 Perfetto trace 转换成 SQLite 数据库文件,然后使用 SQLite 数据库工具打开。转换的命令如下:

trace_processor_shell.exe -e trace.db trace_file.perfetto-trace

打开 SQLite 文件的工具有很多,推荐使用 SQLiteStudio。下载 SQLiteStudio 后,打开转换后的 trace.db 文件可以看到 Perfetto trace 的数据表和视图结构。

SQLiteStudio

在 SQLiteStudio 的 Tools 菜单,找到 Open SQL editor。在打开的新窗口中输入 SQL 语句就可以查询 Perfetto trace 中的数据了:

SELECT * FROM cpu;

Note

数据表结构和关系可以在 Perfetto 官网 查询。

Python for Perfetto

在某些情况下,需要使用 Python 自动化的读取和解析 Perfetto trace 文件,核心的步骤如下:

Step 1:安装 Python 解析库

pip install perfetto
# 或安装指定的版本
pip install perfetto==0.11.0

Warning

Perfetto API 只兼容了 Python 3+ 。

Step 2:编写代码

from perfetto.trace_processor import TraceProcessor

# 加载trace文件
tp = TraceProcessor(trace='trace.perfetto-trace')

# 查询trace中的表
tables = tp.tables()
print("Available tables:", tables)

# 查询特定表的数据
processes = tp.query('SELECT * FROM process')
print("Processes:", processes)

# 查询特定进程的线程
threads = tp.query('SELECT * FROM thread WHERE upid = 123')
print("Threads:", threads)

tp.close()

Danger

由于受到国内网络的影响,在解析 trace 的时候可能会出现 Trace processor failed to start. 的错误。解决办法是手动下载 trace_processor_shell 文件:

  • Mac 或 Linux:
https://get.perfetto.dev/trace_processor      
  • Windows:
https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v37.0/windows-amd64/trace_processor_shell.exe

下载完成后,在构造 TraceProcessor 对象时,作为 bin_path 的参数:

from perfetto.trace_processor import TraceProcessor

# 以 Windows 环境为例
tp = TraceProcessor(trace=r'trace_file.perfetto-trace', config=TraceProcessorConfig(
    bin_path=r'trace_processor_shell.exe'
))

Android 编译和刷机 —— 源码编译

版本类型

Android 的工程名按照编译类型可以分成三种:user、userdebg 和 eng。

  • user:最接近生产环境的稳定版本,适合最终用户使用。
  • userdebug:适合开发人员和测试人员,提供调试和开发功能,但保持一定的稳定性和接近生产环境的配置。
  • eng:专为工程师和内部测试设计,包含最全面的调试工具和最低的限制,主要用于开发和深度测试。

编译 Android 源码

编译全部源码

编译 Android 源码的命令如下:

cd AOSP
source build/envsetup.sh
lunch

进入到 AOSP 源码目录,然后调用 envsetup.sh 初始化环境。lunch 后需要选择对应的工程,可以填写数字或者对应的工程名称。

You're building on Linux

Lunch menu... pick a combo:
 1. aosp_arm-eng
 2. aosp_arm64-eng
 3. aosp_mips-eng
 4. aosp_mips64-eng
 5. aosp_x86-eng
 6. aosp_x86_64-eng
 7. full_fugu-userdebug
 8. aosp_fugu-userdebug
 9. mini_emulator_arm64-userdebug
 10. m_e_arm-userdebug
 11. m_e_mips64-eng
 12. m_e_mips-userdebug
 13. mini_emulator_x86_64-userdebug
 14. mini_emulator_x86-userdebug
 15. aosp_dragon-userdebug
 16. aosp_dragon-eng
 17. aosp_marlin-userdebug
 18. aosp_sailfish-userdebug
 19. aosp_flounder-userdebug
 20. aosp_angler-userdebug
 21. aosp_bullhead-userdebug
 22. hikey-userdebug
 23. aosp_shamu-userdebug

Which would you like? [aosp_arm-eng] 

执行完 lunch 命令后,就可以正式开始编译 Android 源码。

make -j8

编译完成后,在源码的根目录 out/target/product/ 下会生成编译产物和文件:

  • system.img: 包含 Android 操作系统的核心部分,如系统应用程序和库,是系统的主要镜像。
  • ramdisk.img: 包含根文件系统的初步镜像,提供启动时的基本文件和配置。
  • userdata.img: 存储用户的数据和应用程序,是用户存储空间的镜像。
  • recovery.img: 包含恢复模式的镜像,用于系统恢复和维修操作。
  • boot.img: 包含内核和启动程序,是启动 Android 系统时加载的镜像。
  • vendor.img: 存储供应商提供的特定驱动程序和库,通常包括硬件相关的代码和驱动。

单仓库编译

AOSP 除了支持全部编译(整编),还支持单仓库编译(单编)源码。初始化环境后,进入到需要编译的模块中,然后执行编译命令:

source build/envsetup.sh
lunch
cd AOSP/packages/apps/Settings
mm

编译完成后,会提示生成的文件路径。比如编译 Setting 会提示在 out/target/product/generic_x86/system/priv-app/Settings 生成了 Settings.apk 文件。

除了使用 mm 命令编译以外,还可以使用如下的命令:

  • mmm:编译指定目录下的模块,不编译它所依赖的其它模块。
  • mma:编译当前目录下的模块及其依赖项。
  • mmma:编译指定路径下所有模块,并且包含依赖。

获取到编译产物后,可以通过如下的两种方式安装:

  1. 通过 adb push 到 SDCard 下的 Download 路径安装或者直接使用 adb install 安装。
  2. 通过 make snod 命令重新生成 system.img,允许模拟器查看。

在 Docker 上编译 Android 源码

谷歌推荐使用 Docker 编译 Android 源码。在 Ubuntu 上安装 Docker,可以参见 此文档 。在编译前,需要确保 Docker 能够正常运行。

Step 1:把 Android 源码挂载到 Docker 根路径

# 第一次创建。执行如下命令,每次都会创建一个容器
docker run -it  -v ~/Android/AOSP:/Android/AOSP ubuntu:20.04

# 非第一次创建,先获取上次创建容器的 ID,然后启动这个 ID
docker ps -a
docker start -i <ID>

Danger

区分第一次创建 Docker 还是不是第一次创建非常重要,否则会导致后续安装的软件失效。

Step 2:初始化编译环境和选择编译的工程

source build/envsetup.sh
lunch

Step 3:开始编译 Android 工程

make -j50

要是报无法 libncurses.so.5 无法找的错误,可以参考 FAQs 章节。 在

启动模拟器

启动 emulator 模拟器:

emulator

源码结构

目录 描述 子目录及描述
frameworks/ Android 框架的源代码 base/: 核心框架代码
native/: 本地代码
opt/: 可选功能
support/: 支持库
system/ 系统服务和工具的代码 core/: 核心服务
extras/: 额外服务
tools/: 构建工具
hardware/ 硬件抽象层(HAL)的源代码 interfaces/: HAL 接口
libhardware/: HAL 实现库
device/ 特定设备的配置和代码 <manufacturer>/: 制造商目录
<device>/: 设备目录
vendor/ 供应商特定的代码和文件 <manufacturer>/: 供应商目录
packages/ 系统应用和包 apps/: 系统应用
providers/: 内容提供者
services/: 系统服务
build/ 构建系统和工具的源代码 core/: 构建核心代码
env/: 构建环境配置
tools/: 构建工具
kernel/ Android 内核的源代码 <kernel_version>/: 内核版本目录
art/ Android Runtime(ART)的源代码 runtime/: 运行时系统实现
external/ 外部项目的源代码和库 <project>/: 外部项目源代码
tools/ 开发和构建工具的源代码 sdk/: Android SDK 工具
lint/: 代码检查工具
dalvik/ Dalvik 虚拟机的源代码(已被 ART 取代)
testing/ 测试框架和工具的源代码 common/: 通用测试工具
support/: 支持库
docs/ 文档和开发指南
scripts/ 构建和开发过程中的脚本和工具

FAQs

缺少 libncurses5 库文件

报错信息如下:

FAILED: out/target/product/generic_x86/obj/RENDERSCRIPT_BITCODE/libclcore.bc_intermediates/rs_mesh.bc
/bin/bash -c "PWD=/proc/self/cwd prebuilts/clang/host/linux-x86/clang-3289846/bin/clang -Iframeworks/rs/script_api/include -Iexternal/clang/lib/Headers -MD -DRS_VERSION=24 -std=c99 -c -O3 -fno-builtin -emit-llvm -target renderscript32-linux-androideabi -fsigned-char -D__i386__ -Wno-deprecated -Werror  -Werror -Wall -Wextra -Iframeworks/rs/cpu_ref -DRS_DECLARE_EXPIRED_APIS  -x renderscript frameworks/rs/driver/runtime/rs_mesh.c -o out/target/product/generic_x86/obj/RENDERSCRIPT_BITCODE/libclcore.bc_intermediates/rs_mesh.bc"
prebuilts/clang/host/linux-x86/clang-3289846/bin/clang.real: error while loading shared libraries: libncurses.so.5: cannot open shared object file: No such file or directory

可以看到报错的地方在 libncurses.so.5 库文件上,解决办法:

sudo apt install libncurses5

在 Docker 上,需要运行:

apt-get install -y libncurses5

缺少 unzip 工具

在 Ubuntu 系统上运行:

sudo apt install unzip

在 Docker 上,需要运行:

apt-get install -y unzip

内存空间不足

Google 建议编译 AOSP 的内存不低于 32 GB,个人电脑很难达到这个配置。在执行完成 make -j8 后会报如下的错误;

FAILED: out/soong/build.ninja
cd "$(dirname "out/host/linux-x86/bin/soong_build")" && BUILDER="$PWD/$(basename "out/host/linux-x86/bin/soong_build")" && cd / && env -i  "$BUILDER"     --top "$TOP"     --soong_out "out/soong"     --out "out"     -o out/soong/build.ninja --globListDir build --globFile out/soong/globs-build.ninja -t -l out/.module_paths/Android.bp.list --available_env out/soong/soong.environment.available --used_env out/soong/soong.environment.used.build Android.bp
Killed
11:54:56 soong bootstrap failed with: exit status 1
ninja: build stopped: subcommand failed.

#### failed to build some targets (01:04 (mm:ss)) ####

看到 soong_build 失败并显示 Killed 通常表明构建过程中发生了严重的问题,可能与系统资源(如内存)不足有关。可以通过增加交换空间来缓解内存不足的问题。 增加 Swap 空间的步骤如下:

Step 1:查看当前的 Swap 空间

free -m
              total        used        free      shared  buff/cache   available
Mem:           3908        3211         244           1         451         415
Swap:          3907           0        3907
cat /proc/swaps 

Filename                                Type            Size    Used    Priority
/swap.img                               file            4001788 0       -2

Step 2:停止已有的 Swap 空间

sudo swapoff /swap.img

Step 3:删除 Swap 空间

sudo rm /swap.img 

Step 4:重建 Swap 空间

# 16GB
sudo dd if=/dev/zero of=/swap.img bs=1M count=16384

Step 5:启用 Swap 空间

sudo chmod 600 /swap.img 
sudo mkswap -f /swap.img
sudo swapon /swap.img

Step 6:检查 Swap 空间

free -m
cat /proc/swaps

Android 编译和刷机 —— 源码环境搭建

环境配置

下载 Android 源码前,需要主机环境:

  1. 安装 Git 工具。
  2. 安装 Python。
  3. 安装 Repo 工具。

安装 Git 工具

sudo apt install git

安装完成后,配置 Git 工具:

git config --global user.name "your_name"
git config --global user.email "email_address@example.com"

安装 Python

Ubuntu 22.04.03 已经内置了 Python 3,安装的位置在 /usr/bin/python3.x, 可以使用如下的命令检查 Python 是否存在:

python3 --version

在 Android 12 及其以上版本,只需要为 Python 3 创建一个软链接就可以下载代码了:

sudo ln -s /usr/bin/python3 /usr/bin/python

在 Android 12 以下的版本,需要安装 Python 2:

sudo apt install python2-minimal
python2 --version

经查询,python2 的安装路径为 /usr/bin/python2.7。因为系统中安装了多个 Python 版本,需要使用 update-alternatives 来切换版本, update-alternatives 是 Linux 上的一个版本管理工具。

sudo update-alternatives --install /usr/bin/python python /usr/bin/python2.7 2
sudo update-alternatives --install /usr/bin/python python /usr/bin/python3.10 3

update-alternatives 参数的含义如下:

  1. --install 表示向 update-alternatives 注册服务名。
  2. /usr/bin/python 是注册的最终地址,后面会由它管理这个软链接。
  3. python 是服务名。
  4. /usr/bin/python2.7/usr/bin/python3.10 参数为被管理命令的绝对路径。
  5. 23 表示优先级,数字越大,优先级越高。

配置完毕后,可以使用 python --version 查看 Python 的版本信息。后续需要切换 Python 版本,可以使用如下的命令:

sudo update-alternatives --config python

安装 Repo 工具

Step 1:创建 bin 文件夹

mkdir ~/bin
PATH=~/bin:$PATH

Step 2:安装 curl

sudo apt install curl

Step 3:下载 Repo 工具,并且确保它可以正常运行

curl https://storage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod a+x ~/bin/repo

如果上述地址下载失败,可以换成清华源镜像:

curl https://mirrors.tuna.tsinghua.edu.cn/git/git-repo > ~/bin/repo
chmod a+x ~/bin/repo

下载 Android 源码

硬件配置

根据 Android 官网的 硬件要求 的描述,需要至少预留 400GB 的可用磁盘空间(250 GB 用于检出代码 + 150 GB 用于构建代码)。

推荐使用 64 GB RAM。Google 使用 72 核机器和 64 GB RAM 来构建 Android。采用此硬件配置时,一个完整的 Android build 大约需要 40 分钟。 Android 增量 build 大约需要几分钟的时间。相比之下,使用 6 核机器和 64 GB RAM 构建一个完整 build 大约需要 6 个小时。

确认需要下载分支版本

在下载源码前,可以通过 Android 的 官网 查询到源码的标签信息。若 Android 的官网更新不及时的话,可以直接访问 manifest 文件查询到每个分支信息。

国内访问 Android 源码很慢,建议通过清华大学的 AOSP 镜像下载。下载的步骤如下:

Step 1:创建一个目录,存放 Android 源码

mkdir AOSP
cd AOSP

Step 2:设置清华大学的 AOSP 镜像地址

export REPO_URL='https://mirrors.tuna.tsinghua.edu.cn/git/git-repo'

Step 3:指定需要下载的分支

repo init -u https://mirrors.tuna.tsinghua.edu.cn/git/AOSP/platform/manifest -b android-13.0.0_r43

Step 4:同步 Android 代码

repo sync -c --no-tags

-c 表示只拉取当前分支的代码,--no-tags 表示在下载代码时不需要 tags 信息。由于 Android 的代码非常庞大,在下载过程中要是出现中断或者卡住的情况, 可以使用 Ctrl+Z 的方式停止,然后重新执行 repo sync -c --no-tags

Android 编译和刷机 —— 设备解锁

解锁 Bootloader

解锁 Bootloader 需要先确保本地的 fastboot 命令可用。若没有请先 配置 fastboot 环境。 配置完成后,按照如下的方式解锁 Bootloader:

Step 1:连外网,登录 Google 账号

Step 2:Enable OEM unlocking & USB Debugging

Settings > About Phone > Tap on Build Number several times > Go back to Settings > System > Developer Options > enable OEM unlocking & USB Debugging

Step 3: Windows 命令

# 重启 bootloader
adb reboot bootloader

# 根据提示选择Unlock the bootloader
fastboot flashing unlock

# 重启会擦除unlock bootloader之前的数据,并提示手机已经解锁bootloader
fastboot reboot

Pixel 刷机

使用 Android Flash Tool 对谷歌的 Pixel 手机进行刷机。