🔖

Common Lispでモックを使いたい

2023/12/02に公開

Lisp Advent Calendar 二日目の記事です。

自分がモックについて試行錯誤したことについて書いていきます。

注: ここでいうモックは、テスト時にとある処理を別の簡易的な処理に一時的に置き換える、程度の意味合いで使っていて厳密なモックの定義とは異なるかもしれません。
また、ロンドン派とデトロイト派がありますが、この記事ではロンドン派よりに書いています。

背景

例えば開発中のWebアプリケーションのあるAPIが外部サービスに依存していて、そのAPIをテストする時、またはとあるテキストエディタのある機能がUIと密接に関係していて、その機能のテストを書くとき、モックを使いたいことがあるかと思います。

具体的な例を示すため、ここではCommon Lispのライブラリを検索するためのサービスであるQuickdocsの簡易的なクライアントを作っていて、その機能をテストしたいという体でやっていきます。

(ql:quickload '(:quri :dexador :com.inuoe.jzon))

(defpackage :example
  (:use :cl)
  (:export :search-project
           :project-name
           :project-description))
(in-package :example)

(defstruct project
  name
  description)

(defun fetch-project (project-name)
  (dex:request (quri:make-uri :path (format nil "/projects/~A" project-name)
                              :defaults "https://api.quickdocs.org/projects/")))

(defun search-project (project-name)
  (let ((json
          (com.inuoe.jzon:parse
           (fetch-project project-name))))
    (make-project :name (gethash "name" json)
                  :description (gethash "description" json))))

search-project というユーザーが呼び出すための関数と、fetch-projectがあります。
search-projectにプロジェクト名を渡す事でprojectを取得できます。
この関数のテストを書いてみると以下のようになりました。
(ここでテストフレームワークを導入すると冗長なので割愛しました)

(defpackage :example/tests
  (:use :cl))
(in-package :example/tests)

(defun test ()
  (let ((project (example:search-project "alexandria")))
    (assert (equal "alexandria" (example:project-name project)))
    (assert (equal "Alexandria is a collection of portable public domain utilities."
                   (example:project-description project)))))

上記のテストは、テスト実行毎にquickdocsのAPIへのリクエストが行なわれます。
CIでテストを実行するとき等、毎回外部のサービスにリクエストを送信するのはあんまりなので、最小限のhttpリクエストだけをモックしようと思います。

Common Lispでモックをするには考えられる方法はいくつかあります。

symbolに紐付く関数定義を一時的に別の値にする

(setf (fdefinition <symbol>) <function>)でテスト時にだけ別の関数に置き換える事でモックをする方法があります。
使い終わった後は元の値に戻します。

with-mock-functionというマクロを導入してテストしてみます。

(defmacro with-mock-function ((name mock-function) &body body)
  (alexandria:with-unique-names (original-definition)
    `(let ((,original-definition (fdefinition ',name)))
       (setf (fdefinition ',name) ,mock-function)
       (unwind-protect (progn ,@body)
         (setf (fdefinition ',name) ,original-definition)))))

(defun test ()
  (with-mock-function (example::fetch-project
                       (lambda (project-name)
                         ...))
    (let ((project (search-project "quri")))
       ...)))

上記の欠点としていくつか考えられます。

  1. スレッドセーフではない
    ある程度テストの規模が大きくなってくると、各テストの実行を並列に走らせたくなることがあるかと思いますが、 symbolの関数をsetfで置き換えているので、その間に別のテストで同じ関数が呼び出されると予期しない振舞をしてしまいます。
  2. どの関数でもモックできてしまう
    モックはあるモジュールやパッケージの外部とのインターフェースだけを対象にするのが良いですが、上で用意したwith-mock-functionマクロではどの関数に対しても見境なく使えてしまいます。
  3. モック対象の関数が呼び出された回数は一回だけか、といったテストが書けない
    モック対象の機能が、あるAPIの中で一度だけ呼び出されたか、といったテストがあることが理想ですが、上記の方法では、それを簡潔に書けません。モック関数の中で変数をインクリメントしていくことによって実現できますが煩雑になり、テストの可読性が下がってしまいます。

モック対象になりうる関数をスペシャル変数で呼び出すようにする

モック対象の関数を呼び出すインターフェースを用意します。
ここではスペシャル変数*fetch-project-function*を定義し、これを一時的にletで束縛できるようにします。

(defpackage :example
  (:use :cl)
  (:export *fetch-project-function*
           :search-project
           :project-name
           :project-description))
(in-package :example)

(defvar *fetch-project-function* 'fetch-project)

(defun fetch-project (project-name)
  (dex:request (quri:make-uri :path (format nil "/projects/~A" project-name)
                              :defaults "https://api.quickdocs.org/projects/")))

(defun search-project (project-name)
  (let ((json
          (com.inuoe.jzon:parse
           (funcall *fetch-project-function* project-name))))
    (make-project :name (gethash "name" json)
                  :description (gethash "description" json))))

テストは次のようになりました。

(defun test ()
  (let ((example:*fetch-project-function*
          (lambda (project-name)
            ...)))
    (let ((project (search-project "quri")))
      ...)))

スペシャル変数をletで束縛することで別のスレッドに影響を与えずにモックすることが出来ます。
https://www.sbcl.org/manual/#Special-Variables

bindings (e.g. using LET) are local to the thread;

この変更によって 「1. スレッドセーフではない」と「2. どの関数でもモックできてしまう」が解消できました。
ただ、まだ「3. モック対象の関数が呼び出された回数は一回だけか、といったテストが書けない」という問題が残っています。

モックオブジェクトを定義する

対象の関数が何回呼び出されたか、などを保存するためにモックオブジェクトを定義してみます。

(defclass mock-function-class ()
  ((calls :initform '()
          :accessor mock-function-class-calls)
   (results :initform '()
            :accessor mock-function-class-results))
  (:metaclass c2mop:funcallable-standard-class))

(defun mock (function)
  (let ((instance (make-instance 'mock-function-class)))
    (c2mop:set-funcallable-instance-function
     instance
     (lambda (&rest args)
       (push args (mock-function-class-calls instance))
       (let ((results (multiple-value-list (apply function args))))
         (push results
               (mock-function-class-results instance))
         (apply #'values results))))
    instance))

(defun should-be-number-of-calls (mock number)
  (= (length (mock-function-class-calls mock)) number))

(defun arguments-last-call (mock)
  (first (mock-function-class-calls mock)))

mock-function-classを定義しました。
c2mop:funcallable-standard-classをmetaclassに指定して、set-funcallable-instance-functionを使って関数をセットすることによってオブジェクトを関数としてfuncallできるようになります。

これは内部で引数と返り値を保存し、補助関数should-be-number-of-calls, arguments-last-callを用意して呼び出された回数や最後に受け取った引数を取得できます。

実際にテストを書いてみると以下のようになりました。

(defun test ()
  (let ((*fetch-project-function*
          (mock (lambda (project-name)
                  ...))))
    (let ((project (search-project "quri")))
      ...
      ;; fetch-functionが呼び出された回数は一回であるか
      (assert (should-be-number-of-calls *fetch-project-function* 1))
      ;; 引数としてquriを受け取ったか
      (assert (equal '("quri") (arguments-last-call *fetch-project-function*))))))

上記以外だと、他の言語のモックライブラリでは以下のような機能があります。

  • モックオブジェクトに期待する引数や返り値を設定する
  • 期待するエラーを定義する

これらもこのmock-function-classの拡張と追加のマクロによって実現できるかと思います。

おわりに

ここではモックを実現するためにはどうすればいいかについての試行錯誤を書いていきました。
実用するときにはもう少し定義を拡張しても良さそうですね。

その後知ったのですがCommon Lispではcl-mockというライブラリがありました、これを使っても良いかもしれません。
https://github.com/Ferada/cl-mock/

Discussion