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.
|
Important
|
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 analyzeThis 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.ednwith 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 kaochaCheck cache status:
clojure -M:cli statusShows:
-
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-testThis command:
-
Updates
.test-filter-success.ednwith verified symbol hashes -
Should only be run after tests pass
-
Creates the baseline for future test selection
Initialize the success cache by marking all symbols in the current analysis as verified:
# Mark all symbols in the graph as verified
clojure -M:cli mark-all-verifiedThis command:
-
Marks ALL symbols from the analysis cache as verified
-
Useful for initializing test-filter on an existing large codebase
-
Requires that you run
analyzefirst -
After this, only new changes will trigger tests
-
Creates the initial baseline without running any tests
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
;; Alternative: Initialize on existing codebase (skip testing)
(def graph (tf/analyze! :paths ["src/main" "src/test"]))
(tf/mark-all-verified! graph) ; Mark everything as verified
;; => 141 (returns count of verified symbols)# Run only affected tests with Kaocha
clojure -M:cli select -o kaocha | xargs clojure -M:kaochaFor large projects where you want to start using test-filter immediately:
# 1. Analyze the entire codebase
clojure -M:cli analyze
# 2. Mark everything as verified (creates initial baseline)
clojure -M:cli mark-all-verified
# 3. Now only new changes will trigger tests
clojure -M:cli select -v
# => No tests need to be run.
# 4. Make a change to any file
# ... edit file ...
# 5. Analyze and select - only affected tests will run
clojure -M:cli analyze
clojure -M:cli select -v
# => Only tests affected by your change# 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|
Note
|
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 that are difficult to track with static analysis. Test Filter provides flexible options for handling them.
Test Filter identifies integration tests in two ways:
-
Namespace Pattern: Any test in a namespace containing
.integration.as a segment -
Explicit Metadata: Tests marked with
:integration truemetadata
;; Method 1: Namespace pattern (automatic detection)
(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))))))
;; Method 2: Explicit metadata (any namespace)
(deftest ^{:integration true} test-full-workflow
(let [sys (system/start)]
;; Integration test in a regular namespace
(is (= :success (run-full-workflow sys)))))Integration tests behave differently based on their metadata:
-
Conservative Mode (default): Run the test whenever uncertain about dependencies
-
Used when test is marked as integration but has no
:test-targets -
Safest option: ensures integration tests run when needed
-
May run more often than strictly necessary
-
-
Targeted Mode: Run only when specific symbols change
-
Used when test has
:test-targetsmetadata -
Precise control over when integration tests run
-
Reduces unnecessary test execution
-
The :test-targets (or singular :test-target) metadata allows you to specify exactly which symbols an integration test depends on.
The test will only run if one of those target symbols changes.
IMPORTANT: The :test-targets metadata works independently of actual function calls in your test code.
You don’t need to call the target functions for the metadata to work - Test Filter uses the metadata alone to determine dependencies.
This is powerful for integration tests where dependencies might be:
-
Dynamic or indirect (loaded at runtime)
-
Hidden behind macros or protocols
-
External system interactions (databases, APIs)
-
Not statically analyzable by clj-kondo
Example: A test can have {:test-targets my.app/process-payment} without ever calling process-payment directly, and it will still run when process-payment changes.
The metadata accepts multiple formats:
;; Single symbol (fully-qualified)
(deftest ^{:test-target my.app/process-order} test-order-processing
(is (= :processed (:status (process-order {:id 123})))))
;; Single symbol (syntax-quoted with alias)
(ns my-app.integration.orders-test
(:require [my.app :as app]))
(deftest ^{:test-targets `app/process-order} test-order-processing
(is (= :processed (:status (app/process-order {:id 123})))))
;; Multiple symbols as a set
(deftest ^{:test-targets #{my.app/send-notification
my.app/handle-refund}}
test-notification-and-refund
(is (= :sent (send-notification "customer-1" "message")))
(is (= :refunded (handle-refund 456 25.50))))
;; Multiple symbols as a vector (normalized to set)
(deftest ^{:test-targets [my.app/foo my.app/bar]} test-both
(is (= :ok (foo)))
(is (= :ok (bar))))The specification macro also accepts metadata:
(ns my-app.integration.payment-test
(:require [my.app :as app]
[fulcro-spec.core :refer [specification assertions]]))
;; Single target
(specification {:test-targets `app/validate-payment}
"Payment Validation"
(assertions
"validates valid payment"
(app/validate-payment {:card "1234" :amount 50}) => true))
;; Multiple targets
(specification {:test-targets #{my.app/process-order
my.app/send-confirmation}}
"Order Processing Flow"
(assertions
"processes and confirms"
(app/process-order {:id 123}) => {:status :processed}
(app/send-confirmation 123) => {:sent true}))
;; Singular form also works
(specification {:test-target my.app/validate-payment}
"Payment Validation - Singular"
(assertions
(app/validate-payment {:card "1234" :amount 50}) => true))Test Filter uses this logic to determine when integration tests run:
-
Has
:test-targetsor:test-targetmetadata?-
YES → Run only if one of the target symbols changed
-
NO → Continue to step 2
-
-
Is marked
:integration?or in.integration.namespace?-
YES → Run conservatively (always run when any code changes)
-
NO → Use normal transitive dependency analysis
-
;; Example 1: Metadata-only targeting (no function calls required)
(ns my-app.integration.payment-test
(:require [clojure.test :refer [deftest is]]
[my-app.payment :as payment]))
(deftest ^{:test-targets #{my.app.payment/process-payment
my.app.payment/validate-card}}
test-payment-flow-end-to-end
;; This test doesn't directly call process-payment or validate-card
;; They're called indirectly through the full system
;; But it will run when either of them changes
(is (= :success (start-system-and-test-payment))))
;; Example 2: Conservative integration test (always runs)
(ns my-app.integration.full-system-test
(:require [clojure.test :refer [deftest is]]))
(deftest test-entire-system
;; No :test-targets specified
;; Runs whenever ANY change is detected
(is (= :success (start-and-test-full-system))))
;; Example 3: Targeted integration test (runs only when order functions change)
(ns my-app.integration.orders-test
(:require [clojure.test :refer [deftest is]]
[my.app.orders :as orders]))
(deftest ^{:test-targets #{my.app.orders/create-order
my.app.orders/validate-order}}
test-order-workflow
;; Only runs if create-order or validate-order changed
(is (= :valid (orders/validate-order {:id 1})))
(is (= :created (orders/create-order {:id 1}))))
;; Example 4: Mix of conservative and targeted
(ns my-app.integration.mixed-test
(:require [clojure.test :refer [deftest is]]
[my.app.core :as core]))
;; This one always runs (conservative)
(deftest test-critical-path
(is (= :ok (core/critical-operation))))
;; This one only runs when payment-fn changes
(deftest ^{:test-targets my.app.payment/process-payment}
test-payment-integration
(is (= :processed (core/process-payment {:amount 100}))))-
Start Conservative: Use namespace pattern or
:integrationmetadata initially -
Add Targets Gradually: As you understand dependencies, add
:test-targetsto reduce test time -
Be Explicit: Prefer fully-qualified symbols in
:test-targetsfor clarity -
Document Intent: Use comments to explain why specific targets were chosen
-
Regular Review: Periodically verify that targeted tests still cover the right dependencies
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
:cljside of CLJC files -
Ignores pure
.cljsfiles -
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.readerto parse code as data structures -
Strip docstrings: Removes documentation strings from function definitions
-
Normalize formatting: Uses
pr-strto 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}};; .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)
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.mdandSTATUS.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