;;; mocker.el --- mocking framework for emacs ;; Copyright (C) 2011 Yann Hodique. ;; Author: Yann Hodique ;; Keywords: lisp, testing ;; Version: 0.3.0 ;; Package-Requires: ((eieio "1.3") (el-x "0.2.4")) ;; This file is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation; either version 2, or (at your option) ;; any later version. ;; This file is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with GNU Emacs; see the file COPYING. If not, write to ;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330, ;; Boston, MA 02111-1307, USA. ;;; Commentary: ;; ;;; Code: (eval-when-compile (require 'cl)) (require 'eieio) (eval-and-compile ;; use dflet from el-x if available (if (require 'dflet nil t) (defalias 'mocker-flet 'dflet) ;; fallback to regular flet, hoping it's still there (defalias 'mocker-flet 'flet))) (defvar mocker-mock-default-record-cls 'mocker-record) (put 'mocker-mock-error 'error-conditions '(mocker-mock-error error)) (put 'mocker-mock-error 'error-message "Mocker mock error") (put 'mocker-record-error 'error-conditions '(mocker-record-error error)) (put 'mocker-record-error 'error-message "Mocker record error") (defun mocker--plist-remove (plist key) ;; courtesy of pjb (if (eq (car plist) key) (cdr (cdr plist)) (cons (car plist) (cons (cadr plist) (mocker--plist-remove (cddr plist) key))))) ;;; Mock object (defclass mocker-mock () ((function :initarg :function :type symbol) (orig-def :initarg :orig-def :initform nil) (argspec :initarg :argspec :initform nil :type list) (ordered :initarg :ordered :initform t) (records :initarg :records :initform nil :type list))) (defmethod constructor :static ((mock mocker-mock) newname &rest args) (let* ((obj (call-next-method)) (recs (oref obj :records)) (func (oref obj :function))) (oset obj :orig-def (when (fboundp func) (symbol-function func))) (oset obj :records nil) (mapc #'(lambda (r) (apply 'mocker-add-record obj r)) recs) obj)) (defmethod mocker-add-record ((mock mocker-mock) &rest args) (object-add-to-list mock :records (let ((cls mocker-mock-default-record-cls) (tmp (plist-get args :record-cls))) (when tmp (setq cls tmp args (mocker-read-record cls (mocker--plist-remove args :record-cls)))) (apply 'make-instance cls :-mock mock :-sym (make-symbol "unique") args)) t)) (defmethod mocker-fail-mock ((mock mocker-mock) args) (signal 'mocker-mock-error (list (format (concat "Unexpected call to mock `%s'" " with input `%s'") (oref mock :function) args)))) (defvar mocker-inhibit nil) (defmethod mocker-run ((mock mocker-mock) &rest args) (if (not mocker-inhibit) (let* ((mocker-inhibit t) (rec (mocker-find-active-record mock args)) (ordered (oref mock :ordered))) (cond ((null rec) (mocker-fail-mock mock args)) ((or (not ordered) (mocker-test-record rec args)) (mocker-run-record rec args)) (t (mocker-fail-record rec args)))) (apply (oref mock :orig-def) args))) (defmethod mocker-find-active-record ((mock mocker-mock) args) (let ((first-match (lambda (pred seq) (let ((x nil)) (while (and seq (not (setq x (funcall pred (pop seq)))))) x)))) (let* ((ordered (oref mock :ordered)) rec) (if ordered (setq rec (funcall first-match #'(lambda (r) (when (oref r :-active) (if (mocker-test-record r args) (progn (mocker-use-record r) r) (mocker-skip-record r args)))) (oref mock :records))) (setq rec (funcall first-match #'(lambda (r) (and (oref r :-active) (mocker-test-record r args) (progn (mocker-use-record r) r))) (oref mock :records)))) rec))) (defmethod mocker-verify ((mock mocker-mock)) (mapc #'(lambda (r) (when (and (oref r :-active) (< (oref r :-occurrences) (oref r :min-occur))) (signal 'mocker-record-error (list (format (concat "Expected call to mock `%s'," " with input like %s," " was not run.") (oref mock :function) (mocker-get-record-expectations r)))))) (oref mock :records))) ;;; Mock record base object (defclass mocker-record-base () ((min-occur :initarg :min-occur :initform 1 :type number) (max-occur :initarg :max-occur :initform nil :type (or null number)) (-occur :initarg :occur :initform nil :type (or null number)) (-occurrences :initarg :-occurrences :initform 0 :type number :protection :protected) (-mock :initarg :-mock) (-active :initarg :-active :initform t :protection :protected) (-sym :initarg :-sym))) (defmethod constructor :static ((rec mocker-record-base) newname &rest args) (let* ((obj (call-next-method)) (occur (oref obj :occur))) (when occur (oset obj :min-occur (max (oref obj :min-occur) occur)) (oset obj :max-occur (if (oref obj :max-occur) (min (oref obj :max-occur) occur) occur))) obj)) (defmethod mocker-read-record :static ((rec mocker-record-base) spec) spec) (defmethod mocker-use-record ((rec mocker-record-base)) (let ((max (oref rec :max-occur)) (n (1+ (oref rec :-occurrences)))) (oset rec :-occurrences n) (when (and (not (null max)) (= n max)) (oset rec :-active nil)))) (defmethod mocker-skip-record ((rec mocker-record-base) args) (if (>= (oref rec :-occurrences) (oref rec :min-occur)) (oset rec :-active nil) (mocker-fail-record rec args))) (defmethod mocker-test-record ((rec mocker-record-base) args) (error "not implemented in base class")) (defmethod mocker-run-record ((rec mocker-record-base) args) (error "not implemented in base class")) (defmethod mocker-get-record-expectations ((rec mocker-record-base))) (defmethod mocker-fail-record ((rec mocker-record-base) args) (signal 'mocker-record-error (list (format (concat "Violated record while mocking `%s'." " Expected input like: %s, got: `%s' instead") (oref (oref rec :-mock) :function) (mocker-get-record-expectations rec) args)))) ;;; Mock input recognizer (defclass mocker-input-record (mocker-record-base) ((input :initarg :input :initform nil :type list) (input-matcher :initarg :input-matcher :initform nil))) (defmethod constructor :static ((rec mocker-input-record) newname &rest args) (let* ((obj (call-next-method))) (when (or (not (slot-boundp obj :max-occur)) (and (oref obj :max-occur) (< (oref obj :max-occur) (oref obj :min-occur)))) (oset obj :max-occur (oref obj :min-occur))) obj)) (defmethod mocker-test-record ((rec mocker-input-record) args) (let ((matcher (oref rec :input-matcher)) (input (oref rec :input))) (cond (matcher (apply matcher args)) (t (equal input args))))) (defmethod mocker-get-record-expectations ((rec mocker-input-record)) (format "`%s'" (or (oref rec :input-matcher) (oref rec :input)))) ;;; Mock record default object (defclass mocker-record (mocker-input-record) ((output :initarg :output :initform nil) (output-generator :initarg :output-generator :initform nil))) (defmethod mocker-run-record ((rec mocker-record) args) (let ((generator (oref rec :output-generator)) (output (oref rec :output))) (cond (generator (apply generator args)) (t output)))) ;;; Mock simple stub object (defclass mocker-stub-record (mocker-record-base) ((output :initarg :output :initform nil))) (defmethod constructor :static ((rec mocker-stub-record) newname &rest args) (let* ((obj (call-next-method))) (unless (slot-boundp obj :min-occur) (oset obj :min-occur 0)) (unless (slot-boundp obj :max-occur) (oset obj :max-occur nil)) obj)) (defmethod mocker-test-record ((rec mocker-stub-record) args) t) (defmethod mocker-run-record ((rec mocker-stub-record) args) (oref rec :output)) (defmethod mocker-get-record-expectations ((rec mocker-stub-record)) "anything") ;;; Mock passthrough record (defclass mocker-passthrough-record (mocker-input-record) ()) (defmethod mocker-run-record ((rec mocker-passthrough-record) args) (let* ((mock (oref rec :-mock)) (def (oref mock :orig-def))) (when def (apply def args)))) ;;; Helpers (defun mocker-gen-mocks (mockspecs) "helper to generate mocks from the input of `mocker-let'" (mapcar #'(lambda (m) (let* ((func (car m)) (argspec (cadr m)) (rest (cddr m)) (sym (make-symbol (concat (symbol-name func) "--mock")))) (list sym (apply 'make-instance 'mocker-mock :function func :argspec argspec (let* ((order (if (plist-member rest :ordered) (prog1 (plist-get rest :ordered) (setq rest (mocker--plist-remove rest :ordered))) (oref-default 'mocker-mock :ordered)))) (list :ordered order))) (if (plist-member rest :records) (plist-get rest :records) (car rest))))) mockspecs)) ;;;###autoload (defmacro mocker-let (mockspecs &rest body) "Generate temporary bindings according to MOCKSPECS then eval BODY. The value of the last form in BODY is returned. Each element of MOCKSPECS is a list (FUNC ARGS [OPTIONS] RECORDS). FUNC is the name of the function to bind, whose original definition must accept arguments compatible with ARGS. OPTIONS can be :ordered nil if the records can be executed out of order (by default, order is enforced). RECORDS is a list ([:record-cls CLASS] ARG1 ARG2...). Each element of RECORDS will generate a record for the corresponding mock. By default, records are objects of the `mocker-record' class, but CLASS is used instead if specified. The rest of the arguments are used to construct the record object. They will be passed to method `mocker-read-record' for the used CLASS. This method must return a valid list of parameters for the CLASS constructor. This allows to implement specialized mini-languages for specific record classes. " (declare (indent 1) (debug t)) (let* ((mocks (mocker-gen-mocks mockspecs)) (vars (mapcar #'(lambda (m) `(,(car m) ,(cadr m))) mocks)) (specs (mapcar #'(lambda (m) (let* ((mock-sym (car m)) (mock (cadr m)) (func (oref mock :function)) (spec (oref mock :argspec)) (call (or (and (member '&rest spec) 'apply) 'funcall)) (args (loop for el in spec if (or (not (symbolp el)) (not (equal (elt (symbol-name el) 0) ?&))) collect el))) (list func spec `(,call #'mocker-run ,mock-sym ,@args)))) mocks)) (inits (mapcar #'(lambda (m) (cons 'progn (mapcar #'(lambda (rec) `(mocker-add-record ,(car m) ,@rec)) (nth 2 m)))) mocks)) (verifs (mapcar #'(lambda (m) `(mocker-verify ,(car m))) mocks))) `(let (,@vars) ,@inits (prog1 ,(macroexpand `(mocker-flet (,@specs) ,@body)) ,@verifs)))) (provide 'mocker) ;;; mocker.el ends here