1. Analyze: generates hash "abc123" for `foo/bar` (docstring + formatting) 2. User reformats docstring 3. Analyze: generates hash "abc123" for `foo/bar` (still same - normalized) 4. Select Tests: current hash = success hash → NO tests needed
Test Filter is an intelligent test selection tool for Clojure projects that dramatically reduces test execution time by running only tests affected by your code changes. Instead of running your entire test suite on every change, Test Filter analyzes your source code dependencies and git history to determine the minimum set of tests that need to run.
This project source and documentation was completely written by Claude Code. It wrote more than I wanted and I’ve not had time to verify the documentation. Feel free to play with it, but I make ZERO guarantees that anything works as advertised. |
Dependency-aware: Builds a complete symbol dependency graph using static analysis
Content-hash based change detection: Uses SHA256 hashes of normalized code to detect semantic changes
Formatting-immune: Ignores whitespace, indentation, and docstring changes - only logic changes trigger tests
Git-integrated: Tracks file changes between revisions to identify affected code
Intelligent caching: Incrementally updates analysis with content hashes, avoiding full re-scans
Multiple test frameworks: Supports clojure.test
, fulcro-spec
, and custom test macros
Integration test handling: Special handling for integration tests with broad dependencies
CLJC support: Properly handles Clojure Common files with reader conditionals
Flexible output: Multiple formats for integration with various test runners
Test Filter uses a two-cache architecture to track which tests need to run:
Analysis Cache (.test-filter-cache.edn
): Ephemeral snapshot of current codebase
Contains dependency graph and current content hashes
Completely overwritten on each analyze!
command
Only used to communicate between CLI steps
Success Cache (.test-filter-success.edn
): Persistent baseline of verified code
Contains content hashes of successfully tested symbols
Only updated when you explicitly mark tests as verified
Persists across sessions and builds
Analyze: Build dependency graph and generate current content hashes
Parses all source files with clj-kondo
Creates SHA256 hashes of normalized function bodies (ignoring docstrings/formatting)
Writes snapshot to analysis cache
Select Tests: Compare current state vs. verified baseline
Loads current hashes from analysis cache
Loads verified hashes from success cache
Identifies symbols where current hash ≠ verified hash
Walks dependency graph to find affected tests
Run Tests: Execute the selected tests (user action)
Mark Verified: Record that tests passed
Updates success cache with hashes from current analysis
Only done after tests successfully pass
Creates new baseline for future comparisons
1. Analyze: generates hash "abc123" for `foo/bar` (docstring + formatting) 2. User reformats docstring 3. Analyze: generates hash "abc123" for `foo/bar` (still same - normalized) 4. Select Tests: current hash = success hash → NO tests needed
1. Success cache has: foo/bar → "abc123" (verified) 2. User changes `foo/bar` logic: (* x 2) becomes (* x 3) 3. Analyze: generates new hash "def456" for `foo/bar` 4. Select Tests: current hash ≠ success hash → change detected 5. Walk Graph: find tests depending on `foo/bar` - `baz/qux` uses `foo/bar` - `app/handler` uses `baz/qux` - `app-test/handler-test` tests `app/handler` 6. Return Selection: {:tests [app-test/handler-test] :changed-symbols #{foo/bar} :changed-hashes {foo/bar "def456"}} 7. User runs tests, they pass 8. Mark Verified: updates success cache with foo/bar → "def456"
Add to your deps.edn
:
{:deps {com.fulcrologic/test-filter {:mvn/version "1.0.0" }}
;; optional
:aliases
{:cli {:main-opts ["-m" "com.fulcrologic.test-filter.cli"]}}}
Analyze the current codebase and update the analysis cache:
clojure -M:cli analyze
This command:
Runs clj-kondo analysis on your source code
Builds a complete symbol dependency graph
Generates content hashes for all symbols
Overwrites .test-filter-cache.edn
with current state
NOTE: Does NOT update the success cache - that’s done with mark-verified
Find tests affected by changes:
# Basic usage
clojure -M:cli select
# With verbose output showing statistics
clojure -M:cli select -v
# Get all tests (ignore changes)
clojure -M:cli select --all
# Fully-qualified test vars (default)
clojure -M:cli select -o vars
# Test namespaces only
clojure -M:cli select -o namespaces
# Kaocha command-line format
clojure -M:cli select -o kaocha
Check cache status:
clojure -M:cli status
Shows:
Whether analysis cache exists
Whether success cache exists
Cache file sizes and timestamps
Number of verified symbols (with -v flag)
Mark tests as successfully verified (updates success cache):
# Mark all selected tests as verified
clojure -M:cli mark-verified
# Mark specific tests as verified
clojure -M:cli mark-verified -t my.app.core-test/foo-test
This command:
Updates .test-filter-success.edn
with verified symbol hashes
Should only be run after tests pass
Creates the baseline for future test selection
Invalidate the caches:
# Clear analysis cache only
clojure -M:cli clear
# Clear both analysis and success caches
clojure -M:cli clear --all
Use Test Filter from the REPL or your code:
(require '[com.fulcrologic.test-filter.core :as tf])
;; 1. Analyze the codebase (generates current state)
(tf/analyze! :paths ["src/main" "src/test"])
;; 2. Select tests based on changes
(def selection (tf/select-tests :verbose true))
;; The selection contains everything needed for verification
selection
;; => {:tests [my.app-test/foo-test my.app-test/bar-test]
;; :changed-symbols #{my.app/foo my.app/bar}
;; :changed-hashes {my.app/foo "abc123..." my.app/bar "def456..."}
;; :graph {...}
;; :stats {...}}
;; 3. Show affected tests
(tf/print-tests (:tests selection) :format :namespaces)
;; 4. Run the tests (external - use your test runner)
;; ... run tests ...
;; 5. Mark verified after tests pass
(tf/mark-verified! selection) ; Mark all selected tests
;; or
(tf/mark-verified! selection [my.app-test/foo-test]) ; Mark subset
# Run only affected tests with Kaocha
clojure -M:cli select -o kaocha | xargs clojure -M:kaocha
# Get affected test namespaces
TESTS=$(clojure -M:cli select -o namespaces)
# Run with test-runner
if [ -n "$TESTS" ]; then
clojure -M:test -n $TESTS
fi
# 1. Analyze current codebase
clojure -M:cli analyze
# 2. Make code changes
# ... edit files ...
# 3. Analyze again to capture changes
clojure -M:cli analyze
# 4. Select affected tests
clojure -M:cli select -v
# 5. Run only affected tests
clojure -M:cli select -o kaocha | xargs clojure -M:kaocha
# 6. If tests pass, mark as verified
clojure -M:cli mark-verified
# 7. Continue development
# ... edit more files ...
# 8. Next iteration - analyze and select again
clojure -M:cli analyze
clojure -M:cli select -v
# ... only new changes will trigger tests ...
#!/bin/bash
# In your CI pipeline
# Analyze current PR branch
clojure -M:cli analyze
# Select affected tests (compares against success cache from main)
TESTS=$(clojure -M:cli select -o namespaces)
if [ -n "$TESTS" ]; then
echo "Running affected tests: $TESTS"
clojure -M:kaocha --focus $TESTS
# If tests pass, update success cache
if [ $? -eq 0 ]; then
clojure -M:cli mark-verified
# Commit updated success cache to track verified state
git add .test-filter-success.edn
git commit -m "Update verified test baseline"
fi
else
echo "No tests affected by changes"
fi
The .test-filter-success.edn file should be committed to your repository to track the verified baseline across CI runs. The .test-filter-cache.edn file is ephemeral and should be in .gitignore .
|
Test Filter supports test frameworks that use macros instead of deftest
:
(ns my-app.spec-test
(:require [fulcro-spec.core :refer [specification assertions]]))
(specification "User registration"
(assertions
"creates a new user"
(register-user {:name "Alice"}) => {:id 1 :name "Alice"}))
Detected test frameworks:
fulcro-spec.core/specification
Custom macros (configurable)
Integration tests often have broad dependencies. Test Filter detects them by namespace pattern (.integration.
) and applies special handling:
(ns my-app.integration.api-test
(:require [clojure.test :refer [deftest is]]
[my-app.system :as system]))
(deftest test-user-api
(let [sys (system/start)]
;; Integration test
(is (= 200 (:status (api-call sys))))))
Options for integration tests:
Conservative mode (default): Run integration tests when uncertain about dependencies
Metadata targeting: Specify exact dependencies with :test-targets
metadata
Configuration file: External configuration for complex cases
Test Filter properly handles Clojure Common (.cljc
) files with reader conditionals:
(ns my-app.utils
#?(:clj (:import [java.nio.file Paths])))
(defn normalize-path [path]
#?(:clj (-> (Paths/get path (into-array String []))
(.normalize)
(.toString))
:cljs (.normalize js/path path)))
Analyzes only the :clj
side of CLJC files
Ignores pure .cljs
files
Tracks dependencies correctly across platforms
Test Filter uses a sophisticated approach to detect which code changes actually require testing:
Parse with EDN reader: Uses clojure.tools.reader
to parse code as data structures
Strip docstrings: Removes documentation strings from function definitions
Normalize formatting: Uses pr-str
to get consistent formatting regardless of whitespace/indentation
Generate SHA256 hash: Creates a unique hash representing the function’s logic
Cache hashes: Stores hashes alongside the dependency graph
Compare on change: When files change, re-analyze and compare new hashes to cached ones
Identify real changes: Only symbols with different hashes are considered "changed"
This means:
✓ Adding/changing docstrings doesn’t trigger tests
✓ Reformatting code doesn’t trigger tests
✓ Reordering functions doesn’t trigger tests
✓ Adding comments doesn’t trigger tests
✓ Only actual logic changes trigger the appropriate tests
Component | Description |
---|---|
Analyzer ( | Uses clj-kondo to extract var definitions, namespace definitions, and usage relationships |
Graph ( | Builds directed dependency graph using Loom library; provides traversal operations |
Content ( | Extracts function bodies, normalizes them (strips docstrings/whitespace), and generates SHA256 hashes for semantic comparison |
Git ( | Wraps git commands to detect which files changed between revisions |
Cache ( | Persists graph and content hashes to EDN format; handles incremental updates and cache invalidation |
Core ( | Main test selection algorithm; coordinates all components |
CLI ( | Command-line interface with multiple output formats |
{:symbol 'my.ns/foo
:type :var
:file "src/my/ns.clj"
:line 42
:end-line 47
:defined-by 'defn
:metadata {:private false
:macro false
:test false}}
{:from 'my.ns/foo
:to 'other.ns/bar
:context 'my.ns/foo}
;; .test-filter-cache.edn (ephemeral, not committed to git)
{:analyzed-at "2025-01-09T10:30:00Z"
:paths ["src/main" "src/test"]
:nodes {symbol -> node-data}
:edges [{:from :to :context}]
:files {"src/my/ns.clj" {:symbols [...]}}
:content-hashes {my.ns/foo "sha256..."
my.ns/bar "sha256..."}}
;; .test-filter-success.edn (committed to git)
{my.ns/foo "sha256-of-verified-version..."
my.ns/bar "sha256-of-verified-version..."
my.ns-test/foo-test "sha256-of-test-when-it-passed..."}
;; Returned by select-tests
{:tests [my.ns-test/foo-test my.ns-test/bar-test]
:changed-symbols #{my.ns/foo my.ns/baz}
:changed-hashes {my.ns/foo "new-sha256..."
my.ns/baz "new-sha256..."}
:trace {my.ns-test/foo-test {my.ns/foo [my.ns-test/foo-test my.ns/foo]}}
:graph {...} ; Full dependency graph
:stats {:total-tests 12
:selected-tests 2
:changed-symbols 2
:selection-rate "16.7%"}}
All planned phases (1-9) are complete:
Foundation and project setup
clj-kondo integration
Graph operations with Loom
Git integration and change detection
Cache persistence and incremental updates
Test selection algorithm
Command-line interface
Real-world testing and bug fixes
Macro-based test detection (fulcro-spec)
Integration test handling
CLJC file support
Content-hash based change detection (ignores formatting/docstrings)
Testing scope: Needs validation on larger codebases (>100k LOC)
Dynamic requires: Conservative handling (assumes dependency)
Circular dependencies: Not yet optimized
ClojureScript: Not supported (by design, focuses on CLJ/CLJC)
Support for test.check generative tests
Parallel test execution planning
Coverage-based refinement
Watch mode for continuous testing
Configuration file for custom patterns
Contributions are welcome! Please:
Fork the repository
Create a feature branch
Add tests for new functionality
Ensure all tests pass
Submit a pull request
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For issues, questions, or suggestions:
Open an issue on GitHub
Check existing documentation in PLAN.md
and STATUS.md
Review code examples in namespace docstrings
Built with:
clj-kondo - Static analysis
Loom - Graph algorithms
tools.reader - EDN parsing for content hashing
Clojure - The language that makes this possible
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
Ctrl+k | Jump to recent docs |
← | Move to previous article |
→ | Move to next article |
Ctrl+/ | Jump to the search field |