summaryrefslogtreecommitdiff
path: root/modules/test-runner.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/test-runner.el')
-rw-r--r--modules/test-runner.el331
1 files changed, 231 insertions, 100 deletions
diff --git a/modules/test-runner.el b/modules/test-runner.el
index b4c40820..125a8d20 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -2,26 +2,75 @@
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Provides utilities for running ERT tests with focus/unfocus workflow
+
+;; This module provides a powerful ERT test runner with focus/unfocus workflow
+;; for efficient test-driven development in Emacs Lisp projects.
+;;
+;; PURPOSE:
+;;
+;; When working on large Emacs Lisp projects with many test files, you often
+;; want to focus on running just the tests relevant to your current work without
+;; waiting for the entire suite to run. This module provides a smart test runner
+;; that supports both running all tests and focusing on specific test files.
+;;
+;; WORKFLOW:
+;;
+;; 1. Run all tests initially to establish baseline (C-; t R)
+;; 2. Add test files to focus while working on a feature (C-; t a)
+;; 3. Run focused tests repeatedly as you develop (C-; t r)
+;; 4. Add more test files as needed (C-; t b from within test buffer)
+;; 5. View your focused test list at any time (C-; t v)
+;; 6. Clear focus and run all tests before finishing (C-; t c, then C-; t R)
+;;
+;; PROJECT INTEGRATION:
;;
-;; Tests should be located in the Projectile project test directories,
-;; typically "test" or "tests" under the project root.
-;; Falls back to =~/.emacs.d/tests= if not in a Projectile project.
+;; - Automatically discovers test directories in Projectile projects
+;; (looks for "test" or "tests" under project root)
+;; - Falls back to ~/.emacs.d/tests if not in a Projectile project
+;; - Test files must match pattern: test-*.el
;;
-;; The default mode is to load and run all tests.
+;; SPECIAL BEHAVIORS:
;;
-;; To focus on running a specific set of test files:
-;; - Toggle the mode to "focus" mode
-;; - Add specific test files to the list of tests in "focus"
-;; - Running tests (smartly) will now just run those tests
+;; - Smart test running: Automatically runs all or focused tests based on mode
+;; - Test extraction: Discovers test names via regex to run specific tests
+;; - At-point execution: Run individual test at cursor position (C-; t .)
+;; - Error handling: Continues loading tests even if individual files fail
;;
-;; Don't forget to run all tests again in default mode at least once before finishing.
+;; KEYBINDINGS:
+;;
+;; C-; t L Load all test files
+;; C-; t R Run all tests (full suite)
+;; C-; t r Run tests smartly (all or focused based on mode)
+;; C-; t . Run test at point
+;; C-; t a Add test file to focus (with completion)
+;; C-; t b Add current buffer's test file to focus
+;; C-; t c Clear all focused test files
+;; C-; t v View list of focused test files
+;; C-; t t Toggle mode between 'all and 'focused
+;;
+;; RECOMMENDED USAGE:
+;;
+;; While implementing a feature:
+;; - Add the main test file for the feature you're working on
+;; - Add any related test files that might be affected
+;; - Use C-; t r to repeatedly run just those focused tests
+;; - This provides fast feedback during development
+;;
+;; Before committing:
+;; - Clear the focus with C-; t c
+;; - Run the full suite with C-; t R to ensure nothing broke
+;; - Verify all tests pass before pushing changes
;;
;;; Code:
(require 'ert)
(require 'cl-lib)
+;;; External Variables and Functions
+
+(defvar cj/custom-keymap) ; Defined in init.el
+(declare-function projectile-project-root "projectile" ())
+
;;; Variables
(defvar cj/test-global-directory nil
@@ -35,19 +84,19 @@ Each element is a filename (without path) to run.")
(defvar cj/test-mode 'all
"Current test execution mode.
-Either 'all (run all tests) or 'focused (run only focused tests).")
+Either \\='all (run all tests) or \\='focused (run only focused tests).")
(defvar cj/test-last-results nil
"Results from the last test run.")
;;; Core Functions
-;;;###autoload
(defun cj/test--get-test-directory ()
"Return the test directory path for the current project.
-If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root.
-Falls back to =cj/test-global-directory= if not found or not in a project."
+If in a Projectile project, prefers \\='test or \\='tests directory
+inside the project root. Falls back to `cj/test-global-directory'
+if not found or not in a project."
(require 'projectile)
(let ((project-root (ignore-errors (projectile-project-root))))
(if (not (and project-root (file-directory-p project-root)))
@@ -60,15 +109,32 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
((file-directory-p tests-dir) tests-dir)
(t cj/test-global-directory))))))
-;;;###autoload
(defun cj/test--get-test-files ()
- "Return a list of test file names (without path) in the appropriate test directory."
+ "Return list of test file names (without path) in test directory."
(let ((dir (cj/test--get-test-directory)))
(when (file-directory-p dir)
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
-;;;###autoload
+(defun cj/test--do-load-files (_dir files)
+ "Load test FILES from DIR.
+Returns: (cons \\='success loaded-count) on success,
+ (cons \\='error (list failed-files errors)) on errors."
+ (let ((loaded-count 0)
+ (errors '()))
+ (dolist (file files)
+ (condition-case err
+ (progn
+ (load-file file)
+ (setq loaded-count (1+ loaded-count)))
+ (error
+ (push (cons (file-name-nondirectory file)
+ (error-message-string err))
+ errors))))
+ (if (null errors)
+ (cons 'success loaded-count)
+ (cons 'error (list loaded-count (nreverse errors))))))
+
(defun cj/test-load-all ()
"Load all test files from the appropriate test directory."
(interactive)
@@ -76,21 +142,27 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((dir (cj/test--get-test-directory)))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
- (let ((test-files (directory-files dir t "^test-.*\\.el$"))
- (loaded-count 0))
- (dolist (file test-files)
- (condition-case err
- (progn
- (load-file file)
- (setq loaded-count (1+ loaded-count))
- (message "Loaded test file: %s" (file-name-nondirectory file)))
- (error
- (message "Error loading %s: %s"
- (file-name-nondirectory file)
- (error-message-string err)))))
- (message "Loaded %d test file(s)" loaded-count))))
-
-;;;###autoload
+ (let ((test-files (directory-files dir t "^test-.*\\.el$")))
+ (pcase (cj/test--do-load-files dir test-files)
+ (`(success . ,count)
+ (message "Loaded %d test file(s)" count))
+ (`(error ,count ,errors)
+ (dolist (err errors)
+ (message "Error loading %s: %s" (car err) (cdr err)))
+ (message "Loaded %d test file(s) with %d error(s)" count (length errors)))))))
+
+(defun cj/test--do-focus-add (filename available-files focused-files)
+ "Add FILENAME to focused test files.
+AVAILABLE-FILES is the list of all available test files.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='already-focused if file is already focused,
+ \\='not-available if file is not in available-files."
+ (cond
+ ((not (member filename available-files)) 'not-available)
+ ((member filename focused-files) 'already-focused)
+ (t 'success)))
+
(defun cj/test-focus-add ()
"Select test file(s) to add to the focused list."
(interactive)
@@ -109,30 +181,64 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
unfocused-files
nil t)
(user-error "All test files are already focused"))))
- (push selected cj/test-focused-files)
- (message "Added to focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
+ (pcase (cj/test--do-focus-add selected available-files cj/test-focused-files)
+ ('success
+ (push selected cj/test-focused-files)
+ (message "Added to focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('already-focused
+ (message "Already focused: %s" selected))
+ ('not-available
+ (user-error "File not available: %s" selected)))))))
+
+(defun cj/test--do-focus-add-file (filepath testdir focused-files)
+ "Validate and add FILEPATH to focused list.
+TESTDIR is the test directory path.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='no-file if filepath is nil,
+ \\='not-in-testdir if file is not inside test directory,
+ \\='already-focused if file is already focused.
+Second value is the relative filename if successful."
+ (cond
+ ((null filepath) (cons 'no-file nil))
+ ((not (string-prefix-p (file-truename testdir) (file-truename filepath)))
+ (cons 'not-in-testdir nil))
+ (t
+ (let ((relative (file-relative-name filepath testdir)))
+ (if (member relative focused-files)
+ (cons 'already-focused relative)
+ (cons 'success relative))))))
-;;;###autoload
(defun cj/test-focus-add-this-buffer-file ()
"Add the current buffer's file to the focused test list."
(interactive)
(let ((file (buffer-file-name))
(dir (cj/test--get-test-directory)))
- (unless file
- (user-error "Current buffer is not visiting a file"))
- (unless (string-prefix-p (file-truename dir) (file-truename file))
- (user-error "File is not inside the test directory: %s" dir))
- (let ((relative (file-relative-name file dir)))
- (if (member relative cj/test-focused-files)
- (message "Already focused: %s" relative)
- (push relative cj/test-focused-files)
- (message "Added to focus: %s" relative)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
-
-;;;###autoload
+ (pcase (cj/test--do-focus-add-file file dir cj/test-focused-files)
+ (`(no-file . ,_)
+ (user-error "Current buffer is not visiting a file"))
+ (`(not-in-testdir . ,_)
+ (user-error "File is not inside the test directory: %s" dir))
+ (`(already-focused . ,relative)
+ (message "Already focused: %s" relative))
+ (`(success . ,relative)
+ (push relative cj/test-focused-files)
+ (message "Added to focus: %s" relative)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+(defun cj/test--do-focus-remove (filename focused-files)
+ "Remove FILENAME from FOCUSED-FILES.
+Returns: \\='success if removed successfully,
+ \\='empty-list if focused-files is empty,
+ \\='not-found if filename is not in focused-files."
+ (cond
+ ((null focused-files) 'empty-list)
+ ((not (member filename focused-files)) 'not-found)
+ (t 'success)))
+
(defun cj/test-focus-remove ()
"Remove a test file from the focused list."
(interactive)
@@ -141,13 +247,18 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((selected (completing-read "Remove from focus: "
cj/test-focused-files
nil t)))
- (setq cj/test-focused-files
- (delete selected cj/test-focused-files))
- (message "Removed from focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused)))))
+ (pcase (cj/test--do-focus-remove selected cj/test-focused-files)
+ ('success
+ (setq cj/test-focused-files
+ (delete selected cj/test-focused-files))
+ (message "Removed from focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('not-found
+ (message "File not in focused list: %s" selected))
+ ('empty-list
+ (user-error "No focused files to remove"))))))
-;;;###autoload
(defun cj/test-focus-clear ()
"Clear all focused test files."
(interactive)
@@ -168,73 +279,82 @@ Returns a list of test name symbols defined in the file."
(push (match-string 1) test-names)))
test-names))
-;;;###autoload
+(defun cj/test--do-get-focused-tests (focused-files test-dir)
+ "Get test names from FOCUSED-FILES in TEST-DIR.
+Returns: (cons \\='success (list test-names loaded-count)) if successful,
+ (cons \\='no-tests nil) if no tests found,
+ (cons \\='empty-list nil) if focused-files is empty."
+ (if (null focused-files)
+ (cons 'empty-list nil)
+ (let ((all-test-names '())
+ (loaded-count 0))
+ (dolist (file focused-files)
+ (let ((full-path (expand-file-name file test-dir)))
+ (when (file-exists-p full-path)
+ (load-file full-path)
+ (setq loaded-count (1+ loaded-count))
+ (let ((test-names (cj/test--extract-test-names full-path)))
+ (setq all-test-names (append all-test-names test-names))))))
+ (if (null all-test-names)
+ (cons 'no-tests nil)
+ (cons 'success (list all-test-names loaded-count))))))
+
(defun cj/test-run-focused ()
"Run only the focused test files."
(interactive)
- (if (null cj/test-focused-files)
- (user-error "No focused files set. Use =cj/test-focus-add' first")
- (let ((all-test-names '())
- (loaded-count 0)
- (dir (cj/test--get-test-directory)))
- ;; Load the focused files and collect their test names
- (dolist (file cj/test-focused-files)
- (let ((full-path (expand-file-name file dir)))
- (when (file-exists-p full-path)
- (load-file full-path)
- (setq loaded-count (1+ loaded-count))
- ;; Extract test names from this file
- (let ((test-names (cj/test--extract-test-names full-path)))
- (setq all-test-names (append all-test-names test-names))))))
- (if (null all-test-names)
- (message "No tests found in focused files")
- ;; Build a regexp that matches any of our test names
- (let ((pattern (regexp-opt all-test-names)))
- (message "Running %d test(s) from %d focused file(s)"
- (length all-test-names) loaded-count)
- ;; Run only the tests we found
- (ert (concat "^" pattern "$")))))))
+ (let ((dir (cj/test--get-test-directory)))
+ (pcase (cj/test--do-get-focused-tests cj/test-focused-files dir)
+ (`(empty-list . ,_)
+ (user-error "No focused files set. Use =cj/test-focus-add' first"))
+ (`(no-tests . ,_)
+ (message "No tests found in focused files"))
+ (`(success ,test-names ,loaded-count)
+ (let ((pattern (regexp-opt test-names)))
+ (message "Running %d test(s) from %d focused file(s)"
+ (length test-names) loaded-count)
+ (ert (concat "^" pattern "$")))))))
(defun cj/test--ensure-test-dir-in-load-path ()
- "Ensure the directory returned by cj/test--get-test-directory is in `load-path`."
+ "Ensure test directory is in `load-path'."
(let ((dir (cj/test--get-test-directory)))
(when (and dir (file-directory-p dir))
(add-to-list 'load-path dir))))
-;;;###autoload
+(defun cj/test--extract-test-at-pos ()
+ "Extract test name at current position.
+Returns: test name symbol if found, nil otherwise."
+ (save-excursion
+ (beginning-of-defun)
+ (condition-case nil
+ (let ((form (read (current-buffer))))
+ (when (and (listp form)
+ (eq (car form) 'ert-deftest)
+ (symbolp (cadr form)))
+ (cadr form)))
+ (error nil))))
+
(defun cj/run-test-at-point ()
"Run the ERT test at point.
If point is inside an `ert-deftest` definition, run that test only.
Otherwise, message that no test is found."
(interactive)
- (let ((original-point (point)))
- (save-excursion
- (beginning-of-defun)
- (condition-case nil
- (let ((form (read (current-buffer))))
- (if (and (listp form)
- (eq (car form) 'ert-deftest)
- (symbolp (cadr form)))
- (ert (cadr form))
- (message "Not in an ERT test method.")))
- (error (message "No ERT test methods found at point."))))
- (goto-char original-point)))
-
-;;;###autoload
+ (let ((test-name (cj/test--extract-test-at-pos)))
+ (if test-name
+ (ert test-name)
+ (message "Not in an ERT test method."))))
+
(defun cj/test-run-all ()
"Load and run all tests."
(interactive)
(cj/test-load-all)
(ert t))
-;;;###autoload
(defun cj/test-toggle-mode ()
- "Toggle between 'all and 'focused test execution modes."
+ "Toggle between \\='all and \\='focused test execution modes."
(interactive)
(setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all))
(message "Test mode: %s" cj/test-mode))
-;;;###autoload
(defun cj/test-view-focused ()
"Display test files in focus."
(interactive)
@@ -243,7 +363,6 @@ Otherwise, message that no test is found."
(message "Focused files: %s"
(mapconcat 'identity cj/test-focused-files ", "))))
-;;;###autoload
(defun cj/test-run-smart ()
"Run tests based on current mode (all or focused)."
(interactive)
@@ -265,8 +384,20 @@ Otherwise, message that no test is found."
"t" #'cj/test-toggle-mode)
(keymap-set cj/custom-keymap "t" cj/testrunner-map)
+
+;; which-key integration
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; t" "test runner menu"))
+ (which-key-add-key-based-replacements
+ "C-; t" "test runner menu"
+ "C-; t L" "load all tests"
+ "C-; t R" "run all tests"
+ "C-; t r" "run smart"
+ "C-; t ." "run test at point"
+ "C-; t a" "add to focus"
+ "C-; t b" "add buffer to focus"
+ "C-; t c" "clear focus"
+ "C-; t v" "view focused"
+ "C-; t t" "toggle mode"))
(provide 'test-runner)
;;; test-runner.el ends here