暗无天日

=============>DarkSun的个人博客

ERT 测试交互命令的三种方式

最近翻 Emacs 31 的 NEWS 文件 时,看到一条新增:ERT 增加了 ert-play-keys 函数,专门用来模拟用户按键。ERT 本身就有 ert-simulate-keysert-simulate-command 两个类似工具,这个新的到底解决了什么老工具解决不了的问题?顺着 NEWS 里的线索去看 源码测试文件 后发现,这三个工具各有明确的适用场景,本文逐一介绍。

前置条件: 这三个工具都在 ert-x.el 里,不在 ert.el 里。ERT 自 Emacs 24.1 起就是内置的,但只 (require 'ert) 是不够的,必须额外加载扩展库:

(require 'ert-x)

ert-simulate-keysert-simulate-command 从 Emacs 24.1 就有了,而 ert-play-keys 是 Emacs 31 才新增的。

ert-simulate-keys:模拟 minibuffer 输入

ert-simulate-keys 的原理是把按键事件注入到 unread-command-events 变量中,这样当被测代码调用 read-from-minibuffer 之类从命令事件队列读输入的函数时,就会读到我们预先塞进去的内容。

(ert-deftest test-simulate-keys-for-minibuffer ()
  "ert-simulate-keys 可以向 read-from-minibuffer 提供预设输入。"
  (ert-simulate-keys (listify-key-sequence "hello\n")
    (should (string= (read-from-minibuffer "Input: ") "hello"))))

但它的局限也很明显:它 只能 用于从 unread-command-events 读输入的函数。如果你想测试"用户按下某个键,触发了 keymap 里绑定的命令"这种场景, ert-simulate-keys 做不到——因为按键事件只是被塞进了 unread-command-events 变量,并没有一个命令循环在跑来消费它们。只有 read-from-minibuffer 这类函数会主动从 unread-command-events 取输入。

(ert-deftest test-simulate-keys-cannot-start-commands ()
  "ert-simulate-keys 无法启动按键命令——事件只存在 unread-command-events 中。"
  (ert-with-test-buffer ()
    (let (command-ran)
      (let* ((map (let ((map (make-sparse-keymap)))
                    (define-key map [?b]
                                (lambda ()
                                  (interactive)
                                  (setq command-ran t)))
                    map))
             (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist)))
        ;; 事件被放入 unread-command-events,但没有命令循环来处理它们
        (ert-simulate-keys (listify-key-sequence "b")
          ;; body 里没有函数消费这些事件
          nil))
      ;; ?b 的 keymap 绑定没有被触发
      (should (eq command-ran nil)))))

ert-simulate-command:手动调用指定命令

ert-simulate-command 的思路完全不同:你不需要模拟按键,而是直接告诉它"我要调用这个命令,参数是这些"。它会模拟命令循环的行为——运行 pre-command-hook 、设置 this-command 、调用命令、运行 post-command-hook ——但不会经过 keymap 查找。

(ert-deftest test-simulate-command ()
  "ert-simulate-command 可以直接以交互方式调用命令。"
  (ert-with-test-buffer ()
    (let (hook-ran)
      (let ((pre-command-hook (list (lambda () (setq hook-ran t)))))
        (ert-simulate-command
         (list (lambda (x)
                 (interactive (list "test input"))
                 (insert x)
                 :ok)
               "test input")))
      (should (eq hook-ran t)))
    (should (string= "test input" (buffer-substring (point-min) (point-max))))))

注意 ert-simulate-command 有一个重要限制:命令 不是 通过 call-interactively 调用的。这意味着如果你的命令内部调用 called-interactively-p 来判断自己是不是被交互调用的,结果会是 nil 。下面这个测试展示了这个问题:

(ert-deftest test-simulate-command-not-call-interactively ()
  "ert-simulate-command 中 called-interactively-p 返回 nil。"
  (let (result)
    (ert-simulate-command
     (list (lambda ()
             (interactive)
             (setq result (called-interactively-p 'any)))))
    ;; 不是通过 call-interactively 调用的,所以返回 nil
    (should (eq result nil))))

另外, ert-simulate-command 需要你明确知道要调用哪个命令。如果你需要测试"用户按下某个键之后发生了什么"(即经过 keymap 查找后的完整流程),它帮不上忙。

ert-play-keys:模拟真实的按键序列(Emacs 31 新增)

Emacs 31 新增的 ert-play-keys 填补了上面两个工具留下的空白。它的实现只有一行:

(defun ert-play-keys (keys)
  "Play the key sequence KEYS as if it was user input."
  (funcall (kmacro keys)))

它调用 kmacro 把按键序列当作键盘宏来执行。因为键盘宏走的是完整的命令循环路径——包括 keymap 查找、命令执行、hook 触发——所以它可以触发 keymap 绑定的命令,也能正确处理 called-interactively-p

使用时需要配合 ert-with-test-buffer:selected t 参数(或 ert-with-buffer-selected ),这会在临时窗口中选中 buffer,让按键序列能正确路由到目标 buffer:

(ert-deftest test-play-keys-triggers-keymap ()
  "ert-play-keys 可以触发 keymap 绑定的命令。"
  (ert-with-test-buffer (:selected t)
    (let (command-ran)
      (let* ((map (let ((map (make-sparse-keymap)))
                   (define-key map [?b]
                               (lambda ()
                                 (interactive)
                                 (setq command-ran t)
                                 (insert "ran")))
                   map))
            (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist)))
        (ert-play-keys (vconcat [?b])))
      (should (eq command-ran t))
      (should (string= "ran" (buffer-substring (point-min) (point-max)))))))

ert-play-keys 还可以在一次调用中混合不同类型的输入——既有触发命令的按键,也有直接插入的文本:

(ert-deftest test-play-keys-mixed-input ()
  "ert-play-keys 可以混合按键命令和文本插入。"
  (ert-with-test-buffer (:selected t)
    (let* ((map (let ((map (make-sparse-keymap)))
                 (define-key map [?X]
                             (lambda ()
                               (interactive)
                               (insert "[pressed-X]")))
                 map))
          (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist)))
      ;; [?X] 触发 keymap 命令,"hello" 作为文本插入
      (ert-play-keys (vconcat [?X] "hello")))
    (should (string= "[pressed-X]hello"
                     (buffer-substring (point-min) (point-max))))))

三种方式对比

特性 ert-simulate-keys ert-simulate-command ert-play-keys
触发 keymap 绑定
支持 called-interactively-p N/A
运行 pre/post-command-hook
模拟文本插入 仅通过 minibuffer 需手动传参 是(直接输入)
使用方式 宏(包裹 body) 函数(传入命令) 函数(传入按键序列)
Emacs 版本 一直有 一直有 31+

简单来说:

  • 需要测试命令的 minibuffer 输入 → ert-simulate-keys
  • 需要测试特定命令的执行效果(不需要经过 keymap) → ert-simulate-command
  • 需要模拟用户真实按键操作 → ert-play-keys
Emacs : ERT : 测试 : elisp