changelog shortlog graph tags branches changeset files revisions annotate raw help

Mercurial > core / lisp/lib/rt/pkg.lisp

changeset 282: da580c7fe954
parent: 5f782d361e08
child: 597f34d43df7
author: Richard Westhaver <ellis@rwest.io>
date: Wed, 17 Apr 2024 22:53:44 -0400
permissions: -rw-r--r--
description: upgrades
1 ;;; rt.lisp --- regression testing
2 
3 ;; Regression Testing framework. inspired by PCL, the original CMUCL
4 ;; code, and the SBCL port.
5 
6 ;;; Commentary:
7 
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
11 
12 ;; This package is intended to provide a modernized Lisp testing
13 ;; library with features found in some of the test frameworks listed
14 ;; below.
15 
16 ;; - :it.bese.fiveam https://github.com/lispci/fiveam
17 ;; - :try https://github.com/melisgl/try
18 ;; - :rove https://github.com/fukamachi/rove
19 
20 ;;; TODO:
21 #|
22 
23 - [ ] benchmark support: do-bench, test-count,
24 
25 - [ ] fixtures api
26 
27 - [ ] profiling
28 |#
29 ;;; Code:
30 #+x86-64
31 (eval-when (:compile-toplevel :load-toplevel :execute)
32  (require 'sb-sprof)
33  (require 'sb-cover))
34 
35 (defpackage :rt
36  (:use
37  :cl :std :sxp :log
38  :sb-aprof #+x86-64 :sb-sprof)
39  (:export
40  :test-error
41  :*test-opts*
42  :*compile-tests*
43  :*catch-test-errors*
44  :*test-suffix*
45  :*default-test-suite-name*
46  :*test-suite*
47  :*test-suite-list*
48  ;; TODO 2023-09-04: :*test-profiler-list* not yet
49  :*testing*
50  :test-suite-designator
51  :check-suite-designator
52  :make-test
53  :make-suite
54  :test-name=
55  :do-test
56  :do-tests
57  :reset-tests
58  :continue-testing
59  :with-test-env
60  :%test-bail
61  :%test-result
62  :make-test-result
63  :ensure-suite
64  :test-fixture
65  :fixture-prototype
66  :make-fixture-prototype
67  :make-fixture
68  :with-fixture
69  :test-result
70  :test-fn
71  :test-pass-p
72  :test-fail-p
73  :test-skip-p
74  :test-failed
75  :fail!
76  :is
77  :signals
78  :deftest
79  :defsuite
80  :in-suite
81  :eval-test
82  :compile-test
83  :locked-tests
84  :push-test
85  :pop-test
86  :delete-test
87  :find-test
88  :find-suite
89  :do-suite
90  :test-object
91  :test
92  :test-fixture
93  :test-suite
94  :test-name
95  :tests
96  :test-form
97  :test-results))
98 
99 (defpackage :rt/bench
100  (:nicknames :bench)
101  (:use :cl :std :log :rt)
102  (:export
103  :*bench-count*
104  :defbench
105  :do-bench))
106 
107 (uiop:define-package :rt/cover
108  (:nicknames :cover)
109  (:use :cl :std :log :rt :sb-cover)
110  (:reexport :sb-cover)
111  (:reexport :sb-sprof)
112  (:export
113  :with-coverage :start-coverage :stop-coverage
114  :*coverage-directory*
115  :cover-report))
116 
117 (defpackage :rt/tracing
118  (:nicknames :tracing)
119  (:use :cl :std :log :rt)
120  (:export
121  :start-tracing
122  :stop-tracing
123  :with-tracing
124  :save-report
125  ;; Extra utility
126  :package-symbols-except))
127 
128 (defpackage :rt/flamegraph
129  (:nicknames :flamegraph)
130  (:use :cl :std :log :rt :sb-sprof)
131  (:export :save-flamegraph))
132 
133 (in-package :rt)
134 (in-readtable :std)
135 
136 ;;; Vars
137 (defvar *test-opts* '(optimize sb-c::instrument-consing))
138 (defvar *compile-tests* t
139  "When nil do not compile tests. With a value of t, tests are compiled
140 with default optimizations else the value is used to configure
141 compiler optimizations.")
142 (defvar *catch-test-errors* t "When non-nil, cause errors in a test to be caught.")
143 (defvar *test-suffix* "-test" "A suffix to append to every `test' defined with `deftest'.")
144 (defvar *test-suite-list* nil "List of available `test-suite' objects.")
145 (defvar *test-suite* nil "A 'test-suite-designator' which identifies the current `test-suite'.")
146 (eval-when (:compile-toplevel :load-toplevel :execute)
147  (defvar *default-test-suite-name* "default"))
148 (declaim (type (or stream boolean string) *test-input*))
149 (defvar *test-input* nil "When non-nil, specifies an input stream or buffer for `*testing*'.")
150 (defvar *testing* nil "Testing state var.")
151 
152 ;;; Utils
153 (eval-when (:compile-toplevel :load-toplevel :execute)
154  (defun make-test (&rest slots)
155  (apply #'make-instance 'test slots))
156  (defun make-suite (&rest slots)
157  (apply #'make-instance 'test-suite slots)))
158 
159 ;; TODO 2023-09-04: optimize
160 ;;(declaim (inline do-tests))
161 (defun do-tests (&optional (suite *test-suite*) force (output *standard-output*))
162  (if (pathnamep output)
163  (with-open-file (stream output :direction :output)
164  (do-suite (ensure-suite suite) :stream stream :force force))
165  (do-suite (ensure-suite suite) :stream output :force force)))
166 
167 (defvar *test-output-mutex* (sb-thread:make-mutex :name "tests-output"))
168 
169 ;; TODO
170 (defun do-tests-concurrently (&optional (suite *test-suite*) force (output *standard-output*))
171  (declare (ignore suite force))
172  (sb-thread:with-mutex (*test-output-mutex*)
173  (let ((stream (make-synonym-stream output)))
174  (let ((*standard-output* stream)
175  (*error-output* stream))
176  (nyi!)))))
177 
178 (defun reset-tests ()
179  (setq *testing* nil
180  *test-suite* nil
181  *test-suite-list* nil
182  *test-input* nil))
183 
184 ;; this assumes that *test-suite* is re-initialized correctly to the
185 ;; correct test-suite object.
186 (defun continue-testing ()
187  (if-let ((test *testing*))
188  (throw '%in-test test)
189  (do-suite *test-suite*)))
190 
191 ;; NOTE 2023-09-01: `pushnew' does not return an indication of whether
192 ;; place is changed - it returns place. This is functionally sound but
193 ;; means that if we want to do something else in the event that place
194 ;; is unchanged, we run into some friction,
195 ;; https://stackoverflow.com/questions/56228832/adapting-common-lisp-pushnew-to-return-success-failure
196 (defun spush (item lst &key (test #'equal))
197  "Substituting `push'"
198  (declare (type function test))
199  (cond
200  ((null lst) (push item lst))
201  ((list lst)
202  (if-let ((found (member item lst
203  :test test)))
204  (progn
205  (rplaca found item)
206  lst)
207  (push item lst)))
208  #|(or nil '(t (cons item lst)))|#))
209 
210 ;; FIX 2023-08-31: spush, replace with `add-test' method.
211 ;; (declaim (inline normalize-test-name))
212 (defun normalize-test-name (a)
213  "Return the normalized `test-suite-designator' of A."
214  (etypecase a
215  (string (string-upcase a))
216  (symbol (symbol-name a))
217  (test-object (normalize-test-name (test-name a)))
218  (t (format nil "~A" a))))
219 
220 (defun test-name= (a b)
221  "Return t if A and B are similar `test-suite-designator's."
222  (let ((a (normalize-test-name a))
223  (b (normalize-test-name b)))
224  (string= a b)))
225 
226 ;; (declaim (inline assert-suite ensure-suite))
227 (defun ensure-suite (name)
228  (if-let ((ok (member name *test-suite-list* :test #'test-name=)))
229  (car ok)
230  (when (or (eq name t) (null name)) (make-suite :name *default-test-suite-name*))))
231 
232 (defun check-suite-designator (suite) (check-type suite test-suite-designator))
233 
234 (defun assert-suite (name)
235  (check-suite-designator name)
236  (assert (ensure-suite name)))
237 
238 (declaim (inline test-opt-key-p test-opt-valid-p))
239 (defun test-opt-key-p (k)
240  "Test if K is a `test-opt-key'."
241  (member k '(:profile :save :stream)))
242 
243 (defun test-opt-valid-p (f)
244  "Test if F is a valid `test-opt' form. If so, return F else nil."
245  (when (test-opt-key-p (car f))
246  f))
247 
248 ;;; Conditions
249 (define-condition test-failed (error)
250  ((reason :accessor fail-reason :initarg :reason :initform "unknown")
251  (name :accessor fail-name :initarg :name)
252  (form :accessor fail-form :initarg :form))
253  (:documentation "Signaled when a test fails.")
254  (:report (lambda (c s)
255  (format s "The following expression failed: ~S~%~A."
256  (fail-form c)
257  (fail-reason c)))))
258 
259 ;;; Protocol
260 (defgeneric eval-test (self)
261  (:documentation "Eval a `test'."))
262 
263 (defgeneric compile-test (self &key &allow-other-keys)
264  (:documentation "Compile a `test'."))
265 
266 (defgeneric locked-tests (self)
267  (:documentation "Return a list of locked tests in `test-suite' object SELF."))
268 
269 (defgeneric push-test (self place)
270  (:documentation
271  "Push `test' SELF to the value of slot ':tests' in `test-suite' object PLACE."))
272 
273 (defgeneric pop-test (self)
274  (:documentation
275  "Pop the first `test' from the slot-value of ':tests' in `test-suite' object SELF."))
276 
277 (defgeneric push-result (self place)
278  (:documentation
279  "Push object SELF to the value of slot ':results' in object PLACE."))
280 
281 (defgeneric pop-result (self)
282  (:documentation
283  "Pop the first `test-result' from the slot-value of ':tests' from object SELF."))
284 
285 (defgeneric push-fixture (self place)
286  (:documentation
287  "Push object SELF to the value of slot ':results' in object PLACE."))
288 
289 (defgeneric delete-test (self &key &allow-other-keys)
290  (:documentation "Delete `test' object specified by `test-object' SELF and optional keys."))
291 
292 (defgeneric find-test (self name &key &allow-other-keys)
293  (:documentation "Find `test' object specified by name and optional keys."))
294 
295 (defgeneric do-test (self &optional test)
296  (:documentation
297  "Run `test' SELF, printing results to `*standard-output*'. The second
298 argument is an optional fixture.
299 
300 SELF can also be a `test-suite', in which case the TESTS slot is
301 queried for the value of TEST. If TEST is not provided, pops the car
302 from TESTS."))
303 
304 (defgeneric do-suite (self &key &allow-other-keys)
305  (:documentation
306  "Perform actions on `test-suite' object SELF with optional keys."))
307 
308 ;;;; Results
309 (deftype result-tag ()
310  '(or (member :pass :fail :skip) null))
311 
312 (declaim (inline %make-test-result))
313 (defstruct (test-result (:constructor %make-test-result)
314  (:conc-name tr-))
315  (tag nil :type result-tag :read-only t)
316  (form nil :type form))
317 
318 (defun make-test-result (tag &optional form)
319  (%make-test-result :tag tag :form form))
320 
321 (defmethod test-pass-p ((res test-result))
322  (when (eq :pass (tr-tag res)) t))
323 
324 (defmethod test-fail-p ((res test-result))
325  (when (eq :fail (tr-tag res)) t))
326 
327 (defmethod test-skip-p ((res test-result))
328  (when (eq :skip (tr-tag res)) t))
329 
330 (defmethod print-object ((self test-result) stream)
331  (print-unreadable-object (self stream)
332  (format stream "~A ~A"
333  (tr-tag self)
334  (tr-form self))))
335 
336 ;;; Objects
337 (defclass test-object ()
338  ((name :initarg :name :initform (required-argument) :type string :accessor test-name)
339  #+nil (cached :initarg :cache :allocation :class :accessor test-cached-p :type boolean))
340  (:documentation "Super class for all test-related objects."))
341 
342 (defmethod print-object ((self test-object) stream)
343  "test"
344  (print-unreadable-object (self stream :type t :identity t)
345  (format stream "~A"
346  (test-name self))))
347 
348 ;;;; Tests
349 ;; HACK 2023-08-31: inherit sxp?
350 
351 (defclass test (test-object)
352  ((fn :type symbol :accessor test-fn)
353  ;; (bench :type (or boolean fixnum) :accessor test-bench :initform nil :initarg :bench)
354  (profile :type list :accessor test-profile :initform nil :initarg :profile)
355  (args :type list :accessor test-args :initform nil :initarg :args)
356  (decl :type list :accessor test-decl :initform nil :initarg :decl)
357  (form :initarg :form :initform nil :type function-lambda-expression :accessor test-form)
358  (doc :initarg :doc :type string :accessor test-doc)
359  (lock :initarg :lock :type boolean :accessor test-lock-p)
360  (persist :initarg :persist :initform nil :type boolean :accessor test-persist-p)
361  (results :initarg :results :type (array test-result) :accessor test-results))
362  (:documentation "Test class typically made with `deftest'."))
363 
364 (defmethod initialize-instance ((self test) &key name)
365  ;; (debug! "building test" name)
366  (setf (test-fn self)
367  (make-symbol
368  (format nil "~A~A"
369  name
370  (gensym *test-suffix*))))
371  (setf (test-lock-p self) t)
372  ;; TODO 2023-09-21: we should count how many checks are in the :form
373  ;; slot and infer the array dimensions.
374  (setf (test-results self) (make-array 0 :element-type 'test-result))
375  (call-next-method))
376 
377 (defmethod print-object ((self test) stream)
378  (print-unreadable-object (self stream :type t :identity t)
379  (format stream "~A :fn ~A :args ~A :persist ~A"
380  (test-name self)
381  (test-fn self)
382  (test-args self)
383  (test-persist-p self))))
384 
385 (defmethod push-result ((self test-result) (place test))
386  (with-slots (results) place
387  (push self results)))
388 
389 (defmethod pop-result ((self test))
390  (pop (test-results self)))
391 
392 (defmethod eval-test ((self test))
393  (eval `(progn ,@(test-form self))))
394 
395 (defmethod compile-test ((self test) &key declare &allow-other-keys)
396  (compile
397  (test-fn self)
398  `(lambda ()
399  ,(when declare `(declare ,declare))
400  ,@(test-form self))))
401 
402 (defun fail! (form &optional fmt &rest args)
403  (let ((reason (and fmt (apply #'format nil fmt args))))
404  (with-simple-restart (ignore-fail "Continue testing.")
405  (error 'test-failed :reason reason :form form))))
406 
407 (defmacro with-test-env (self &body body)
408  `(catch '%in-test
409  (setf (test-lock-p ,self) t)
410  (let* ((*testing* ,self)
411  (%test-bail nil)
412  %test-result)
413  (block %test-bail
414  ,@body
415  (setf (test-lock-p ,self) %test-bail))
416  %test-result)))
417 
418 (defmethod do-test ((self test) &optional fx)
419  (declare (ignorable fx))
420  (with-test-env self
421  (debug! "running test: " *testing*)
422  (flet ((%do ()
423  (if-let ((opt *compile-tests*))
424  ;; RESEARCH 2023-08-31: with-compilation-unit?
425  (progn
426  (if (eq opt t)
427  (setq opt *test-opts*)
428  (setq opt (push *test-opts* opt)))
429  ;; TODO 2023-09-21: handle failures here
430  (ignore-some-conditions (style-warning) (funcall (compile-test self :declare opt)))
431  (setf %test-result (make-test-result :pass (test-fn self))))
432  (progn
433  (ignore-some-conditions (style-warning) (eval-test self))
434  (setf %test-result (make-test-result :pass (test-name self)))))))
435  (if *catch-test-errors*
436  (handler-bind
437  ((style-warning #'muffle-warning)
438  (error
439  #'(lambda (c)
440  (setf %test-bail t)
441  (setf %test-result (make-test-result :fail c))
442  (return-from %test-bail %test-result))))
443  (%do))
444  (%do)))))
445 
446 ;;;; Fixtures
447 
448 ;; Our fixtures are just closures - with a pandoric environment. You
449 ;; might call it a domain-specific object protocol.
450 
451 ;; You can build fixtures inside a test or use the push-fixture
452 ;; method on a `test-suite' object.
453 
454 (deftype fixture () 'form)
455 
456 (declaim (inline %make-fixture-prototype))
457 (defstruct (fixture-prototype (:constructor %make-fixture-prototype)
458  (:conc-name fxp))
459  (kind :empty :type keyword)
460  (form nil :type form))
461 
462 (defun make-fixture-prototype (kind form)
463  (%make-fixture-prototype :kind kind :form form))
464 
465 (defmacro make-fixture (letargs &body ds)
466  (let ((letargs (let-binding-transform letargs)))
467  `(let (,@letargs)
468  (dlambda ,@ds))))
469 
470 (defmacro with-fixture ((var fx) &body body)
471  `(let ((,var ,fx))
472  ,@body))
473 
474 ;;;; Suites
475 (defclass test-suite (test-object)
476  ((tests :initarg :set :initform nil :type list :accessor tests
477  :documentation "test-suite tests")
478  (results :initarg :results :initform nil :type list :accessor test-results
479  :documentation "test-suite results")
480  (stream :initarg :stream :initform *standard-output* :type stream :accessor test-stream)
481  (fixtures :initarg :fixtures :initform nil :type list :accessor test-fixtures))
482  (:documentation "A class for collections of related `test' objects."))
483 
484 (defmethod print-object ((self test-suite) stream)
485  (print-unreadable-object (self stream :type t :identity t)
486  (format stream "~A [~d:~d:~d:~d]"
487  (test-name self)
488  (length (tests self))
489  (count t (map-tests self #'test-lock-p))
490  (count t (map-tests self #'test-persist-p))
491  (length (test-results self)))))
492 
493 ;; (defmethod reinitialize-instance ((self test-suite) &rest initargs &key &allow-other-keys))
494 
495 (deftype test-suite-designator ()
496  "Either nil, a symbol, a string, or a `test-suite' object."
497  '(or null symbol string test-suite keyword))
498 
499 (defun find-suite (name)
500  (declare (test-suite-designator name))
501  (find name *test-suite-list* :test #'test-name=))
502 
503 (defmethod map-tests ((self test-suite) function)
504  (mapcar function (tests self)))
505 
506 (defmethod push-test ((self test) (place test-suite))
507  (push self (tests place)))
508 
509 (defmethod pop-test ((self test-suite))
510  (pop (tests self)))
511 
512 (defmethod push-result ((self test-result) (place test-suite))
513  (with-slots (results) place
514  (push self results)))
515 
516 (defmethod pop-result ((self test-suite))
517  (pop (test-results self)))
518 
519 (defmethod find-test ((self test-suite) name &key (test #'test-name=))
520  (declare (type (or string symbol) name)
521  (type function test))
522  (find name (the list (tests self)) :test test))
523 
524 (defmethod do-test ((self test-suite) &optional test)
525  (push-result
526  (if test
527  (do-test
528  (etypecase test
529  (test test)
530  (string (find-test self test))
531  (symbol (find-test self (symbol-name test)))))
532  (do-test (pop-test self)))
533  self))
534 
535 (defmethod do-test ((self simple-string) &optional test)
536  (let ((suite (find-suite self)))
537  (do-test suite test)))
538 
539 (defmethod do-test ((self symbol) &optional test)
540  (do-test (symbol-name self) test))
541 
542 ;; HACK 2023-09-01: find better method of declaring failures from
543 ;; within the body of `deftest'.
544 (defmethod do-suite ((self test-suite) &key stream force)
545  (when stream (setf (test-stream self) stream))
546  (with-slots (name stream) self
547  (format stream "in suite ~x with ~A/~A tests:~%"
548  name
549  (count t (tests self)
550  :key (lambda (x) (or (test-lock-p x) (test-persist-p x))))
551  (length (tests self)))
552  ;; loop over each test, calling `do-test'. if locked or
553  ;; persistent, test is performed. if FORCE is non-nil all tests
554  ;; are performed.
555  (map-tests self
556  (lambda (x)
557  (when (or force (test-lock-p x) (test-persist-p x))
558  (let ((res (do-test x)))
559  (push-result res self)
560  (format stream "~@[~<~%~:;~:@(~S~) ~>~]~%" res)))))
561  ;; compare locked vs expected
562  (let ((locked (remove-if #'null (map-tests self (lambda (x) (when (test-lock-p x) x)))))
563  (fails
564  ;; collect if locked test not expected
565  (loop for r in (test-results self)
566  unless (test-pass-p r)
567  collect r)))
568  (if (null locked)
569  (format stream "~&No tests failed.~%")
570  (progn
571  ;; RESEARCH 2023-09-04: print fails ??
572  (format stream "~&~A out of ~A ~
573  total tests failed: ~
574  ~:@(~{~<~% ~1:;~S~>~
575  ~^, ~}~)."
576  (length locked)
577  (length (tests self))
578  locked)
579  (unless (null fails)
580  (format stream "~&~A unexpected failures: ~
581  ~:@(~{~<~% ~1:;~S~>~
582  ~^, ~}~)."
583  (length fails)
584  fails))))
585  ;; close stream
586  (finish-output stream)
587  ;; return values (PASS? LOCKED)
588  (values (not fails) locked))))
589 
590 (defmethod do-suite ((self string) &key stream)
591  (do-suite (ensure-suite self) :stream stream))
592 
593 (defmethod do-suite ((self symbol) &key stream)
594  (do-suite (ensure-suite self) :stream stream))
595 
596 (defmethod do-suite ((self null) &key stream)
597  (do-suite *test-suite* :stream stream))
598 
599 ;;; Checks
600 (flet ((%test (val form)
601  (let ((r
602  (if val
603  (make-test-result :pass form)
604  (make-test-result :fail form))))
605  (debug! r)
606  r)))
607  (defmacro is (test &rest args)
608  "The DWIM Check.
609 
610 (is (= 1 1)) ;=> #S(TEST-RESULT :TAG :PASS :FORM (= 1 1))
611 If TEST returns a truthy value, return a PASS test-result, else return
612 a FAIL. The TEST is parameterized by ARGS which is a plist or nil.
613 
614 If ARGS is nil, TEST is bound to to the RESULT slot of the test-result
615 and evaluated 'as-is'.
616 
617 (nyi!)
618 ARGS may contain the following keywords followed by a corresponding
619 value:
620 
621 :EXPECTED
622 
623 :TIMEOUT
624 
625 :THEN
626 
627 All other values are treated as let bindings.
628 "
629  (with-gensyms (form)
630  `(if ,(null args)
631  (if *testing*
632  (push-result (funcall ,#'%test ,test ',test) *testing*)
633  (funcall ,#'%test ,test ',test))
634  (macrolet ((,form (test) `(let ,,(group args 2) ,,test)))
635  ;; TODO 2023-09-21: does this work...
636  (if *testing*
637  (push-result (funcall ,#'%test (,form ,test) ',test) *testing*)
638  (funcall ,#'%test (,form ,test) ',test)))))))
639 
640 (defmacro signals (condition-spec &body body)
641  "Generates a passing TEST-RESULT if body signals a condition of type
642 CONDITION-SPEC. BODY is evaluated in a block named NIL, CONDITION-SPEC
643 is not evaluated."
644  (let ((block-name (gensym)))
645  (destructuring-bind (condition &optional reason-control &rest reason-args)
646  (ensure-list condition-spec)
647  `(block ,block-name
648  (handler-bind ((,condition (lambda (c)
649  ;; ok, body threw condition
650  ;; TODO 2023-09-05: result collectors
651  ;; (add-result 'test-passed
652  ;; :test-expr ',condition)
653  (return-from ,block-name (make-test-result :pass ',body)))))
654  (block nil
655  ,@body))
656  (fail!
657  ',condition
658  ,@(if reason-control
659  `(,reason-control ,@reason-args)
660  `("Failed to signal a ~S" ',condition)))
661  (return-from ,block-name nil)))))
662 
663 ;;; Macros
664 (defmacro deftest (name props &body body)
665  "Build a test with NAME, parameterized by PROPS and with a test form of BODY.
666 
667 PROPS is a plist which currently accepts the following parameters:
668 
669 :PERSIST - re-run this test even if it passes
670 
671 :ARGS - nyi
672 
673 :PROFILE - enable profiling of this test
674 
675 :DISABLED - don't push this test to the current *TEST-SUITE*
676 
677 BODY is parsed with SB-INT:PARSE-BODY and will fill in documentation
678 and declarations for the test body.
679 "
680  (destructuring-bind (pr doc dec fn)
681  (multiple-value-bind (forms dec doc)
682  ;; parse body with docstring allowed
683  (sb-int:parse-body (or body) t)
684  `(,props ',doc ',dec ',forms))
685  ;; TODO 2023-09-21: parse plist
686  `(let ((obj (make-test
687  :name ,(format nil "~A" name)
688  ;; note: we could leave these unbound if we want,
689  ;; personal preference
690  :form ,fn
691  ,@(when-let ((v (getf pr :persist))) `(:persist ,v))
692  ,@(when-let ((v (getf pr :args))) `(:args ,v))
693  ;; ,@(when-let ((v (getf pr :bench))) `(:bench ,v))
694  ,@(when-let ((v (getf pr :profile))) `(:profile ,v))
695  ,@(when doc `(:doc ,doc))
696  ,@(when dec `(:decl ,dec)))))
697  ,(unless (getf pr :disabled) '(push-test obj *test-suite*))
698  obj)))
699 
700 (defmacro defsuite (suite-name &rest props)
701  "Define a TEST-SUITE with provided keys. The object returned can be
702 enabled using the IN-SUITE macro, similiar to the DEFPACKAGE API."
703  (check-type suite-name (or symbol string))
704  `(eval-when (:compile-toplevel :load-toplevel :execute)
705  (let ((obj (make-suite
706  :name (format nil "~A" ',suite-name)
707  ,@(when-let ((v (getf props :stream))) `(:stream ,v)))))
708  (setq *test-suite-list* (spush obj *test-suite-list* :test #'test-name=))
709  obj)))
710 
711 (defmacro in-suite (name)
712  "Set *TEST-SUITE* to the TEST-SUITE object referred to by symbol
713 NAME. Return the object."
714  (assert-suite name)
715  `(progn
716  (setq *test-suite* (ensure-suite ,name))))