changeset 96: |
301fd45bbe73 |
child: |
ac7ef5ddcaab |
author: |
ellis <ellis@rwest.io> |
date: |
Wed, 13 Dec 2023 20:02:36 -0500 |
permissions: |
-rw-r--r-- |
description: |
big refactor of lisp code |
1 ;;; rt.lisp --- regression testing 3 ;; Regression Testing framework. inspired by PCL, the original CMUCL 4 ;; code, and the SBCL port. 8 ;; - :rt https://www.merl.com/publications/docs/TR91-04.pdf Chapter 1 9 ;; - :com.gigamonkeys.test https://github.com/gigamonkey/monkeylib-test-framework 10 ;; - :sb-rt https://github.com/sbcl/sbcl/blob/master/contrib/sb-rt/rt.lisp 12 ;; This package is intended to provide a modernized Lisp testing 13 ;; library with features found in some of the test frameworks listed 16 ;; - :it.bese.fiveam https://github.com/lispci/fiveam 17 ;; - :try https://github.com/melisgl/try 18 ;; - :rove https://github.com/fukamachi/rove 23 - [ ] benchmark support: do-bench, test-count, 31 (eval-when (:compile-toplevel :load-toplevel :execute) 38 :sb-aprof #+x86-64 :sb-sprof) 39 (:import-from :sb-cover :store-coverage-data) 47 :*default-test-suite-name* 50 ;; TODO 2023-09-04: :*test-profiler-list* not yet 52 :test-suite-designator 53 :check-suite-designator 65 :make-fixture-prototype 104 (defvar *default-test-opts* '(optimize sb-c::instrument-consing (debug 3))) 105 (defvar *compile-tests* t 106 "When nil do not compile tests. With a value of t, tests are compiled 107 with default optimizations else the value is used to configure 108 compiler optimizations.") 109 (defvar *coverage-directory* #P"/tmp/rt/") 110 (defvar *catch-test-errors* t "When non-nil, cause errors in a test to be caught.") 111 (defvar *test-suffix* "-test" "A suffix to append to every `test' defined with `deftest'.") 112 (defvar *test-suite-list* nil "List of available `test-suite' objects.") 113 (defvar *test-suite* nil "A 'test-suite-designator' which identifies the current `test-suite'.") 114 (eval-when (:compile-toplevel :load-toplevel :execute) 115 (defvar *default-test-suite-name* "default")) 116 (declaim (type (or stream boolean string) *test-input*)) 117 (defvar *test-input* nil "When non-nil, specifies an input stream or buffer for `*testing*'.") 118 (defvar *default-bench-count* 100 "Default number of iterations to repeat a bench test for. This value is 119 used when the slot value of :BENCH is t.") 120 (defvar *testing* nil "Testing state var.") 123 (eval-when (:compile-toplevel :load-toplevel :execute) 124 (defun make-test (&rest slots) 125 (apply #'make-instance 'test slots)) 126 (defun make-suite (&rest slots) 127 (apply #'make-instance 'test-suite slots))) 129 ;; TODO 2023-09-04: optimize 130 (declaim (inline do-tests)) 131 (defun do-tests (&optional (suite *test-suite*) (output *standard-output*)) 132 (if (pathnamep output) 133 (with-open-file (stream output :direction :output) 134 (do-suite (ensure-suite suite) :stream stream)) 135 (do-suite (ensure-suite suite) :stream output))) 137 (defun reset-tests () 140 *test-suite-list* nil 143 ;; this assumes that *test-suite* is re-initialized correctly to the 144 ;; correct test-suite object. 145 (defun continue-testing () 146 (if-let ((test *testing*)) 147 (throw '%in-test test) 148 (do-suite *test-suite*))) 150 ;; NOTE 2023-09-01: `pushnew' does not return an indication of whether 151 ;; place is changed - it returns place. This is functionally sound but 152 ;; means that if we want to do something else in the event that place 153 ;; is unchanged, we run into some friction, 154 ;; https://stackoverflow.com/questions/56228832/adapting-common-lisp-pushnew-to-return-success-failure 155 (defun spush (item lst &key (test #'equal)) 156 "Substituting `push'" 157 (declare (type function test)) 159 ((null lst) (push item lst)) 161 (if-let ((found (member item lst 167 #|(or nil '(t (cons item lst)))|#)) 169 ;; FIX 2023-08-31: spush, replace with `add-test' method. 170 ;; (declaim (inline normalize-test-name)) 171 (defun normalize-test-name (a) 172 "Return the normalized `test-suite-designator' of A." 175 (symbol (string-downcase (symbol-name a))) 176 (test-object (test-name a)) 177 (t (format nil "~A" a)))) 179 (defun test-name= (a b) 180 "Return t if A and B are similar `test-suite-designator's." 181 (let ((a (normalize-test-name a)) 182 (b (normalize-test-name b))) 185 ;; (declaim (inline assert-suite ensure-suite)) 186 (defun ensure-suite (name) 187 (if-let ((ok (member name *test-suite-list* :test #'test-name=))) 189 (when (or (eq name t) (null name)) (make-suite :name *default-test-suite-name*)))) 191 (defun check-suite-designator (suite) (check-type suite test-suite-designator)) 193 (defun assert-suite (name) 194 (check-suite-designator name) 195 (assert (ensure-suite name))) 197 (declaim (inline test-opt-key-p test-opt-valid-p)) 198 (defun test-opt-key-p (k) 199 "Test if K is a `test-opt-key'." 200 (member k '(:profile :save :stream))) 202 (defun test-opt-valid-p (f) 203 "Test if F is a valid `test-opt' form. If so, return F else nil." 204 (when (test-opt-key-p (car f)) 208 (define-condition test-failed (error) 209 ((reason :accessor fail-reason :initarg :reason :initform "unknown") 210 (name :accessor fail-name :initarg :name) 211 (form :accessor fail-form :initarg :form)) 212 (:documentation "Signaled when a test fails.") 213 (:report (lambda (c s) 214 (format s "The following expression failed: ~S~%~A." 219 (defgeneric eval-test (self) 220 (:documentation "Eval a `test'.")) 222 (defgeneric compile-test (self &key &allow-other-keys) 223 (:documentation "Compile a `test'.")) 225 (defgeneric locked-tests (self) 226 (:documentation "Return a list of locked tests in `test-suite' object SELF.")) 228 (defgeneric push-test (self place) 230 "Push `test' SELF to the value of slot ':tests' in `test-suite' object PLACE.")) 232 (defgeneric pop-test (self) 234 "Pop the first `test' from the slot-value of ':tests' in `test-suite' object SELF.")) 236 (defgeneric push-result (self place) 238 "Push object SELF to the value of slot ':results' in object PLACE.")) 240 (defgeneric pop-result (self) 242 "Pop the first `test-result' from the slot-value of ':tests' from object SELF.")) 244 (defgeneric push-fixture (self place) 246 "Push object SELF to the value of slot ':results' in object PLACE.")) 248 (defgeneric delete-test (self &key &allow-other-keys) 249 (:documentation "Delete `test' object specified by `test-object' SELF and optional keys.")) 251 (defgeneric find-test (self name &key &allow-other-keys) 252 (:documentation "Find `test' object specified by name and optional keys.")) 254 (defgeneric do-test (self &optional test) 256 "Run `test' SELF, printing results to `*standard-output*'. The second 257 argument is an optional fixture. 259 SELF can also be a `test-suite', in which case the TESTS slot is 260 queried for the value of TEST. If TEST is not provided, pops the car 263 (defgeneric do-suite (self &key &allow-other-keys) 265 "Perform actions on `test-suite' object SELF with optional keys.")) 268 (deftype result-tag () 269 '(or (member :pass :fail :skip) null)) 271 (declaim (inline %make-test-result)) 272 (defstruct (test-result (:constructor %make-test-result) 274 (tag nil :type result-tag :read-only t) 275 (form nil :type form)) 277 (defun make-test-result (tag &optional form) 278 (%make-test-result :tag tag :form form)) 280 (defmethod test-pass-p ((res test-result)) 281 (when (eq :pass (tr-tag res)) t)) 283 (defmethod test-fail-p ((res test-result)) 284 (when (eq :fail (tr-tag res)) t)) 286 (defmethod test-skip-p ((res test-result)) 287 (when (eq :skip (tr-tag res)) t)) 289 (defmethod print-object ((self test-result) stream) 290 (print-unreadable-object (self stream) 291 (format stream "~A ~A" 296 (defclass test-object () 297 ((name :initarg :name :initform (required-argument) :type string :accessor test-name) 298 #+nil (cached :initarg :cache :allocation :class :accessor test-cached-p :type boolean)) 299 (:documentation "Super class for all test-related objects.")) 301 (defmethod print-object ((self test-object) stream) 303 (print-unreadable-object (self stream :type t :identity t) 308 ;; HACK 2023-08-31: inherit sxp? 310 (defclass test (test-object) 311 ((fn :type symbol :accessor test-fn) 312 (bench :type (or boolean fixnum) :accessor test-bench :initform nil :initarg :bench) 313 (profile :type list :accessor test-profile :initform nil :initarg :profile) 314 (args :type list :accessor test-args :initform nil :initarg :args) 315 (decl :type list :accessor test-decl :initform nil :initarg :decl) 316 (form :initarg :form :initform nil :type function-lambda-expression :accessor test-form) 317 (doc :initarg :doc :type string :accessor test-doc) 318 (lock :initarg :lock :type boolean :accessor test-lock-p) 319 (persist :initarg :persist :initform nil :type boolean :accessor test-persist-p) 320 (results :initarg :results :type (array test-result) :accessor test-results)) 321 (:documentation "Test class typically made with `deftest'.")) 323 (defmethod test-bench-p ((self test)) 324 (when (test-bench self) t)) 326 (defmethod get-bench-count ((self test)) 327 (when-let ((v (test-bench self))) 329 ((typep v 'fixnum) v) 330 ((eq v t) *default-bench-count*) 334 (defmethod initialize-instance ((self test) &key name) 335 ;; (debug! "building test" name) 340 (gensym *test-suffix*)))) 341 (setf (test-lock-p self) t) 342 ;; TODO 2023-09-21: we should count how many checks are in the :form 343 ;; slot and infer the array dimensions. 344 (setf (test-results self) (make-array 0 :element-type 'test-result)) 347 (defmethod print-object ((self test) stream) 348 (print-unreadable-object (self stream :type t :identity t) 349 (format stream "~A :fn ~A :args ~A :persist ~A" 353 (test-persist-p self)))) 355 ;; TODO 2023-09-01: use sxp? 356 ;; (defun validate-form (form)) 358 (defmethod push-result ((self test-result) (place test)) 359 (with-slots (results) place 360 (push self results))) 362 (defmethod pop-result ((self test)) 363 (pop (test-results self))) 365 (defmethod eval-test ((self test)) 366 `(progn ,@(test-form self))) 368 (defmethod compile-test ((self test) &key declare &allow-other-keys) 372 ,@(when declare `((declare ,declare))) 373 ,@(test-form self)))) 375 (defun fail! (form &optional fmt &rest args) 376 (let ((reason (and fmt (apply #'format nil fmt args)))) 377 (with-simple-restart (ignore-fail "Continue testing.") 378 (error 'test-failed :reason reason :form form)))) 380 (defmacro with-test-env (self &body body) 382 (setf (test-lock-p ,self) t) 383 (let* ((*testing* ,self) 388 (setf (test-lock-p ,self) bail)) 391 (defmethod do-test ((self test) &optional fx) 392 (declare (ignorable fx)) 394 (debug! "running test: " *testing*) 396 (if-let ((opt *compile-tests*)) 397 ;; RESEARCH 2023-08-31: with-compilation-unit? 400 (setq opt *default-test-opts*) 401 (setq opt (push *default-test-opts* opt))) 402 ;; TODO 2023-09-21: handle failures here 403 (funcall (compile-test self :declare opt)) 404 (setf r (make-test-result :pass (test-fn self)))) 407 (setf r (make-test-result :pass (test-name self))))))) 408 (if *catch-test-errors* 410 ((style-warning #'muffle-warning) 414 (setf r (make-test-result :fail c)) 415 (return-from bail r)))) 419 (defmacro bench (iter &body body) 420 `(loop for i from 1 to ,iter 423 (defmethod do-bench ((self test) &optional fx) 424 (declare (ignorable fx)) 427 (if-let ((opt *compile-tests*)) 429 (when (eq opt t) (setq opt *default-test-opts*)) 430 ;; TODO 2023-09-21: handle failures here 431 (let ((fn (compile-test self :declare opt))) 432 (bench (test-bench self) (funcall fn))) 433 (setf r (make-test-result :pass (test-fn self)))) 435 (bench (test-bench self) (eval-test self)) 436 (setf r (make-test-result :pass (test-name self))))))) 437 (if *catch-test-errors* 439 ((style-warning #'muffle-warning) 443 (setf r (make-test-result :fail c)) 444 (return-from bail r)))) 450 ;; Our fixtures are just closures - with a pandoric environment. You 451 ;; might call it a domain-specific object protocol. 453 ;; You can build fixtures inside a test or use the push-fixture 454 ;; method on a `test-suite' object. 456 (deftype fixture () 'form) 458 (declaim (inline %make-fixture-prototype)) 459 (defstruct (fixture-prototype (:constructor %make-fixture-prototype) 461 (kind :empty :type keyword) 462 (form nil :type form)) 464 (defun make-fixture-prototype (kind form) 465 (%make-fixture-prototype :kind kind :form form)) 467 (defmacro make-fixture (letargs &body ds) 468 (let ((letargs (let-binding-transform letargs))) 472 (defmacro with-fixture ((var fx) &body body) 477 (defclass test-suite (test-object) 478 ((tests :initarg :set :initform nil :type list :accessor tests 479 :documentation "test-suite tests") 480 (results :initarg :results :initform nil :type list :accessor test-results 481 :documentation "test-suite results") 482 (stream :initarg :stream :initform *standard-output* :type stream :accessor test-stream) 483 (fixtures :initarg :fixtures :initform nil :type list :accessor test-fixtures)) 484 (:documentation "A class for collections of related `test' objects.")) 486 (defmethod print-object ((self test-suite) stream) 487 (print-unreadable-object (self stream :type t :identity t) 488 (format stream "~A [~d+~d:~d:~d:~d]" 490 (count t (map-tests self (lambda (x) (not (test-bench-p x))))) 491 (count t (map-tests self #'test-bench-p)) 492 (count t (map-tests self #'test-lock-p)) 493 (count t (map-tests self #'test-persist-p)) 494 (length (test-results self))))) 496 ;; (defmethod reinitialize-instance ((self test-suite) &rest initargs &key &allow-other-keys)) 498 (deftype test-suite-designator () 499 "Either nil, a symbol, a string, or a `test-suite' object." 500 '(or null symbol string test-suite test keyword)) 502 (defmethod map-tests ((self test-suite) function) 503 (mapcar function (tests self))) 505 (defmethod push-test ((self test) (place test-suite)) 506 (push self (tests place))) 508 (defmethod pop-test ((self test-suite)) 511 (defmethod push-result ((self test-result) (place test-suite)) 512 (with-slots (results) place 513 (push self results))) 515 (defmethod pop-result ((self test-suite)) 516 (pop (test-results self))) 518 (defmethod find-test ((self test-suite) name &key (test #'test-name=)) 519 (declare (type (or string symbol) name) 520 (type function test)) 521 (find name (the list (tests self)) :test test)) 523 (defmethod do-test ((self test-suite) &optional test) 526 (do-test (find-test self (test-name test))) 527 (do-test (pop-test self))) 530 ;; HACK 2023-09-01: find better method of declaring failures from 531 ;; within the body of `deftest'. 532 (defmethod do-suite ((self test-suite) &key stream) 533 (when stream (setf (test-stream self) stream)) 534 (with-slots (name stream) self 535 (format stream "in suite ~x with ~A/~A tests:~%" 537 (count t (tests self) 538 :key (lambda (x) (or (test-lock-p x) (test-persist-p x)))) 539 (length (tests self))) 540 ;; loop over each test, calling `do-test' if locked or persistent 543 (when (or (test-lock-p x) (test-persist-p x)) 544 (let ((res (do-test x))) 545 (push-result res self) 546 (format stream "~@[~<~%~:;~:@(~S~) ~>~]~%" res))))) 547 ;; compare locked vs expected 548 (let ((locked (remove-if #'null (map-tests self (lambda (x) (when (test-lock-p x) x))))) 550 ;; collect if locked test not expected 551 (loop for r in (test-results self) 552 unless (test-pass-p r) 555 (format stream "~&No tests failed.~%") 557 ;; RESEARCH 2023-09-04: print fails ?? 558 (format stream "~&~A out of ~A ~ 559 total tests failed: ~ 563 (length (tests self)) 566 (format stream "~&~A unexpected failures: ~ 572 (finish-output stream) 573 ;; return values (PASS? LOCKED) 574 (values (not fails) locked)))) 577 (flet ((%test (val form) 580 (make-test-result :pass form) 581 (make-test-result :fail form)))) 584 (defmacro is (test &rest args) 587 (is (= 1 1) :test 100) ;=> #S(TEST-RESULT :TAG :PASS :FORM (= 1 1)) 588 If TEST returns a truthy value, return a PASS test-result, else return 589 a FAIL. The TEST is parameterized by ARGS which is a plist or nil. 591 If ARGS is nil, TEST is bound to to the RESULT slot of the test-result 592 and evaluated 'as-is'. 595 ARGS may contain the following keywords followed by a corresponding 604 All other values are treated as let bindings. 609 (push-result (funcall ,#'%test ,test ',test) *testing*) 610 (funcall ,#'%test ,test ',test)) 611 (macrolet ((,form (test) `(let ,,(group args 2) ,,test))) 612 ;; TODO 2023-09-21: does this work... 614 (push-result (funcall ,#'%test (,form ,test) ',test) *testing*) 615 (funcall ,#'%test (,form ,test) ',test))))))) 617 (defmacro signals (condition-spec &body body) 618 "Generates a passing TEST-RESULT if body signals a condition of type 619 CONDITION-SPEC. BODY is evaluated in a block named NIL, CONDITION-SPEC 621 (let ((block-name (gensym))) 622 (destructuring-bind (condition &optional reason-control &rest reason-args) 623 (ensure-list condition-spec) 625 (handler-bind ((,condition (lambda (c) 626 ;; ok, body threw condition 627 ;; TODO 2023-09-05: result collectors 628 ;; (add-result 'test-passed 629 ;; :test-expr ',condition) 630 (return-from ,block-name (make-test-result :pass ',body))))) 636 `(,reason-control ,@reason-args) 637 `("Failed to signal a ~S" ',condition))) 638 (return-from ,block-name nil))))) 641 (defmacro deftest (name props &body body) 642 "Build a test with NAME, parameterized by LAMBDA-LIST and with a test form of BODY." 643 (destructuring-bind (pr doc dec fn) 644 (multiple-value-bind (forms dec doc) 645 ;; parse body with docstring allowed 648 `(,props ',doc ',dec ',forms)) 649 ;; TODO 2023-09-21: parse plist 650 `(let ((obj (make-test 651 :name ',(format nil "~A" name) 652 ;; note: we could leave these unbound if we want, 653 ;; personal preference 655 ,@(when-let ((v (getf pr :persist))) `(:persist ,v)) 656 ,@(when-let ((v (getf pr :args))) `(:args ,v)) 657 ,@(when-let ((v (getf pr :bench))) `(:bench ,v)) 658 ,@(when-let ((v (getf pr :profile))) `(:profile ,v)) 659 ,@(when doc `(:doc ,doc)) 660 ,@(when dec `(:decl ,dec))))) 661 (push-test obj *test-suite*) 664 (defmacro defsuite (suite-name &rest props) 665 "Define a `test-suite' with provided keys. The object returned can be 666 enabled using the `in-suite' macro, similiar to the `defpackage' API." 667 (check-type suite-name (or symbol string)) 668 `(eval-when (:compile-toplevel :load-toplevel :execute) 669 (let ((obj (make-suite 670 :name (format nil "~A" ',suite-name) 671 ,@(when-let ((v (getf props :stream))) `(:stream ,v))))) 672 (setq *test-suite-list* (spush obj *test-suite-list* :test #'test-name=)) 675 (defmacro in-suite (name) 676 "Set `*test-suite*' to the `test-suite' referred to by symbol 677 NAME. Return the `test-suite'." 680 (setq *test-suite* (ensure-suite ,name)))) 683 (defmacro enable-coverage () 684 `(declaim (optimize store-coverage-data))) 686 (defun disable-coverage () 687 `(declaim (optimize (sb-cover:store-coverage-data 0)))) 689 (defmacro with-coverage (&body body) 695 (defun coverage-report () 696 "Generate a coverage report." 697 (sb-cover:report *coverage-directory*))