| name | fulcro-spec-tdd |
| description | How to structure applications for optimal testability in Clojure and Clojurescript, along with how to use guardrails and fulcro-spec to do the actual testing. You MUST use this skill when writing Clojure and Clojurescript if fulcro-spec is a dependency. |
You are an expert in software testing, and you prefer using Fulcro Spec when available. Your core philosophy is simple: testability is a design quality. Code that is easy to test is usually well-designed. Code that is hard to test usually needs refactoring. You will follow the prevailing testing strategy in a project that does not use fulcro-spec, but you will still try to follow the principles.
The Goals of This Approach are:
- You can achieve near-complete behavior coverage without combinatorial explosion
- Your tests will be fast, deterministic, and easier to understand
- Refactoring becomes safer and straightforward
- New developers can understand your code through the tests
- Bugs become rare, and when they occur, they're easy to locate
Procedure
The main procedure is to:
- Write a function
- Determine the behaviors of that function
- Write a test for EACH behavior, enumerating each one with strings in Fulcro Spec
- VERIFY each behavior is properly covered by temporarily breaking the behavior such that the specific test you just wrote fails
- SEAL the specification with the function's signature using
:coversmetadata
CRITICAL: A test is NOT complete until the signature is sealed. The :covers metadata is mandatory for all specifications.
Avoid cascading failures. If you run the targeted specification of a function you should be able to make micro-adjustments to the implementation that make individual behavioral tests fail.
What Makes This Different
Unlike approaches that focus on testing through the complexity (heavy mocking, complex test fixtures, slow integration tests), your approach designs complexity out of the code, making testing natural and simple.
Essential Foundation: Guardrails
IMPORTANT: You strongly prefer to use Guardrails for runtime validation. Guardrails is not optional unless it isn't a dependency of the project - it's essential for safe, reliable testing.
Why Guardrails Matter:
Without runtime validation, mocks can accept invalid arguments and return invalid values, creating tests that pass but would fail in production. Guardrails provides:
- Function contracts - Input/output validation via Malli schemas
- Safe mocking -
when-mocking!andprovided!validate all mock interactions - Transitive proof - If Test A passes and Test B (mocking A) passes, A and B compose correctly
- Refactoring safety - Type changes break tests immediately, not in production
Setup Required:
;; deps.edn
{:deps {com.fulcrologic/guardrails {:mvn/version "..."}}}
;; In your namespace
(ns your.namespace
(:require
[com.fulcrologic.guardrails.malli.core :refer [>defn >def => ? |]]))
;; In your test namespace
(ns your.namespace-test
(:require
[fulcro-spec.core :refer [specification behavior assertions => when-mocking! provided! when-mocking!! provided!!]]))
Enable in development:
clojure -J-Dguardrails.enabled=true -M:dev:test
NOTE: Guardrails are removed in production builds (zero runtime cost).
Testing vs. Development Mode
The guardrails configuration can be told to throw if it finds a problem. Throwing an exception in development mode when you're running a server is a terrible idea, because very often you just have a mistake in your (probably new) guardrails function specification. You don't want it to break the system, you just want to see a report (logs). So, when running a development REPL you should turn the throw option off. But, in tests you're making mocks/assertions and running code, and you want guardrails to prevent tests from passing. In that case you DO want guardrails to throw.
In development mode (REPL) you want guardrails to:
- be more less in checks (lower max calls per seconds) so that dev performance is fast
- warn when there is a type error, but NOT throw exceptions that interfere with operation
In test mode, you want it to:
- be more exhaustive in checks (higher max calls per seconds), but not so high as to make testing slow
- throw on type errors so that tests fail
If you are doing very heavy test-driven development, you simple need a test REPL with max calls per second to something like 20, and throw set to true.
IMPORTANT: For the Proof System (transitive coverage)
The proof system requires guardrails to be in :all mode. This mode:
- Enables runtime validation (catches type errors)
- Populates the externs registry (required for
transitive-callsused by signatures)
Set via JVM property:
-Dguardrails.mode=:all
Or in deps.edn alias:
:test {:jvm-opts ["-Dguardrails.enabled" "-Dguardrails.mode=:all"]}
Without :all mode, functions will show as having no callees (all appear as leaf functions), making the transitive proof chain incomplete.
Full test configuration:
{:throw? true ; true in tests, false for repl-driven dev
:guardrails/compact? true
:guardrails/trace? true
:guardrails/stack-trace :prune
:guardrails/mcps 20 ; 20+ in tests, maybe 10 in repl-driven dev
:expound {:show-valid-values? true
:print-specs? true}}
All examples below use guardrails. Learn to use them from the start - retrofitting validation later is much harder than building with it from day one.
Core Principles of Testable Design
Principle 1: Move Side Effects to the Edges
The single most important principle for testability is to separate pure logic from effects.
Malli Schema Definitions:
First, define reusable Malli schemas for types used throughout examples:
(ns your.namespace
(:require
[com.fulcrologic.guardrails.malli.core :refer [>defn >def => ? |]]))
;; Java type schemas
(>def :java.time.LocalDate [:fn #(instance? java.time.LocalDate %)])
(>def :java.time.Instant [:fn #(instance? java.time.Instant %)])
;; Domain schemas - use qualified keywords with simple namespaces
(>def :order/id pos-int?)
(>def :item/id pos-int?)
(>def :item/quantity pos-int?)
(>def :order/email [:re #".+@.+\..+"])
;; Use :number for numeric types - don't over-specialize
(>def :item/price :number)
(>def :item/stock :int)
(>def :order/status #{:success :failed})
(>def :order/failure-reason #{:insufficient-stock})
(>def :email/content
[:map
[:subject :string]
[:body :string]])
;; Maps use qualified keywords directly - no aliases!
(>def :domain/order
[:map
[:id :order/id]
[:item-id :item/id]
[:quantity :item/quantity]
[:customer-email :order/email]])
(>def :domain/inventory
[:map
[:stock :item/stock]
[:unit-price :item/price]])
(>def :domain/fulfillment-plan
[:map
[:status :order/status]
[:db-updates :vector]
[:email [:map
[:to :order/email]
[:content :email/content]]]])
Side effects are operations that interact with the outside world:
- Database queries and updates
- HTTP requests
- File I/O
- Getting the current time
- Generating random numbers
- Sending emails
- Logging (if it is a defined necessary behavior)
Pure functions are deterministic transformations of data:
- Input → Output, with no side effects
- Same inputs always produce same outputs
- Can be understood and tested in isolation
The Bad Pattern: Mixed Concerns
;; ANTI-PATTERN: Side effects mixed with business logic
(defn process-order! [order-id]
(let [order (db-query [:order order-id]) ;; Side effect: DB read
inventory (db-query [:inventory (:item-id order)]) ;; Side effect: DB read
price (* (:quantity order) (:unit-price inventory))] ;; Pure logic
(if (>= (:stock inventory) (:quantity order)) ;; Pure logic
(do
(db-update! [:inventory (:item-id order)] ;; Side effect: DB write
(- (:stock inventory) (:quantity order)))
(db-update! [:order order-id :status] :fulfilled) ;; Side effect: DB write
(send-email! (:customer-email order) ;; Side effect: Email
"Order Confirmed"
(str "Your order for " (:quantity order) " items..."))
{:status :success :total price})
(do
(send-email! (:customer-email order) ;; Side effect: Email
"Order Failed"
"Insufficient inventory")
{:status :failed :reason :insufficient-stock}))))
Problems with this approach:
- Cannot test business logic without database
- Cannot test email generation without side effects
- Cannot test price calculation independently
- Cannot test decision logic (stock check) independently
- Difficult to test failure paths
- Tests will be slow
- Tests will be non-deterministic
- Hard to understand the actual logic amidst the noise
The Good Pattern: Separated Concerns
;; =========================================================
;; PURE FUNCTIONS - Business Logic (No Side Effects)
;; =========================================================
(>defn calculate-order-total
"Pure: Calculate total price for an order.
BEHAVIOR: Multiplies quantity by unit price"
[quantity unit-price]
[:number :number => :number]
(* quantity unit-price))
(>defn sufficient-stock?
"Pure: Check if there's enough stock.
BEHAVIOR: Returns true if stock >= quantity"
[stock quantity]
[:int :int => :boolean]
(>= stock quantity))
(>defn calculate-remaining-stock
"Pure: Calculate stock after fulfillment.
BEHAVIOR: Subtracts quantity from stock"
[stock quantity]
[:int :int => :int]
(- stock quantity))
(>defn order-success-email
"Pure: Generate success email content.
BEHAVIOR: Returns map with subject and body"
[quantity total]
[:int :number => :email/content]
{:subject "Order Confirmed"
:body (str "Your order for " quantity " items has been confirmed. "
"Total: $" total)})
(>defn order-failure-email
"Pure: Generate failure email content.
BEHAVIOR: Returns map with subject and body"
[]
[=> :email/content]
{:subject "Order Failed"
:body "Insufficient inventory. Please try again later."})
(>defn order-fulfillment-plan
"Pure: Creates a plan for fulfilling an order.
This function contains ALL the business logic but performs NO side effects.
BEHAVIOR 1: If sufficient stock, returns success plan with updates
BEHAVIOR 2: If insufficient stock, returns failure plan with notification"
[order inventory]
[:domain/order :domain/inventory => :domain/fulfillment-plan]
(let [quantity (:quantity order)
stock (:stock inventory)
unit-price (:unit-price inventory)
total (calculate-order-total quantity unit-price)]
(if (sufficient-stock? stock quantity)
{:status :success
:total total
:db-updates [[:inventory (:item-id order)
(calculate-remaining-stock stock quantity)]
[:order (:id order) {:status :fulfilled :total total}]]
:email {:to (:customer-email order)
:content (order-success-email quantity total)}}
{:status :failed
:reason :insufficient-stock
:db-updates []
:email {:to (:customer-email order)
:content (order-failure-email)}})))
;; =========================================================
;; SIDE EFFECT INTERFACE - Injectable Dependencies
;; =========================================================
(>defn fetch-order
"Side effect: Database query"
[db order-id]
[:any :order/id => :domain/order]
(db-query db [:order order-id]))
(>defn fetch-inventory
"Side effect: Database query"
[db item-id]
[:any :item/id => :domain/inventory]
(db-query db [:inventory item-id]))
(>defn update-db!
"Side effect: Database update. Takes a connection/transaction, not a function."
[db key value]
[:any :any :any => :nil]
(db-update! db key value))
(>defn send-email!
"Side effect: Email sending. This IS the side effect - it sends the email."
[recipient content]
[:order/email :email/content => :nil]
(email-gateway/send! recipient (:subject content) (:body content)))
;; =========================================================
;; ORCHESTRATION - Side Effects at the Edge
;; =========================================================
(>defn process-order!
"Top-level function: Orchestrates side effects and pure logic.
Testable by mocking the dependency functions."
[order-id db]
[:order/id :any => [:map [:status :order/status]]]
(let [order (fetch-order db order-id)
inventory (fetch-inventory db (:item-id order))
plan (order-fulfillment-plan order inventory)] ;; PURE - testable alone
;; Execute the plan (side effects)
(doseq [[key value] (:db-updates plan)]
(update-db! db key value))
(send-email! (get-in plan [:email :to])
(get-in plan [:email :content]))
;; Return result
(select-keys plan [:status :total :reason])))
Benefits of this approach:
- All business logic is in pure functions - test with simple assertions
- Each decision point (sufficient stock check) is independently testable
- Email generation is testable without sending emails
- Price calculation is trivially testable
- The plan generation contains ALL logic in one pure function
- Side effects are localized and mockable
- Tests are fast, deterministic, and clear
- Easy to test both success and failure paths
Testing the Pure Approach
(specification "calculate-order-total"
(behavior "multiplies quantity by unit price"
(assertions
(calculate-order-total 5 10.0) => 50.0
(calculate-order-total 1 99.99) => 99.99)))
(specification "sufficient-stock?"
(behavior "returns true when stock >= quantity"
(assertions
(sufficient-stock? 10 5) => true
(sufficient-stock? 5 5) => true))
(behavior "returns false when stock < quantity"
(assertions
(sufficient-stock? 3 5) => false)))
(specification "order-fulfillment-plan"
(behavior "creates success plan when stock is sufficient"
(let [order {:id 123 :item-id 456 :quantity 5 :customer-email "user@example.com"}
inventory {:stock 10 :unit-price 10.0}
plan (order-fulfillment-plan order inventory)]
;; Breaking apart assertions with labels makes failures immediately comprehensible.
;; When a test fails, you see WHICH aspect failed, not just "expected X, got Y".
;; This is especially important for complex return values like plans.
(assertions
"status is success"
(:status plan) => :success
"total is calculated"
(:total plan) => 50.0
"includes inventory update"
(first (:db-updates plan)) => [:inventory 456 5]
"includes order status update"
(second (:db-updates plan)) => [:order 123 {:status :fulfilled :total 50.0}]
"includes success email"
(get-in plan [:email :content :subject]) => "Order Confirmed")))
(behavior "creates failure plan when stock is insufficient"
(let [order {:id 123 :item-id 456 :quantity 10 :customer-email "user@example.com"}
inventory {:stock 3 :unit-price 10.0}
plan (order-fulfillment-plan order inventory)]
(assertions
"status is failed"
(:status plan) => :failed
"reason is insufficient-stock"
(:reason plan) => :insufficient-stock
"no db updates"
(:db-updates plan) => []
"includes failure email"
(get-in plan [:email :content :subject]) => "Order Failed"))))
(specification "process-order!"
(behavior "orchestrates the process with mocked dependencies"
(when-mocking!
(fetch-order db order-id) => {:id 123 :item-id 456 :quantity 5 :customer-email "user@example.com"}
(fetch-inventory db item-id) => {:stock 10 :unit-price 10.0}
(update-db! db key value) => nil
(send-email! to content) => nil
(let [result (process-order! 123 :db)]
(assertions
"returns success status"
(:status result) => :success
"returns total"
(:total result) => 50.0)))))
Notice:
- 10 lines of pure function tests vs potentially hundreds of lines for integration tests
- No database setup - tests run in milliseconds
- Complete coverage - both success and failure paths tested
- Clear assertions - easy to understand what's being tested
- Only ONE mock test - for the orchestration function
Principle 2: Single Level of Abstraction
Each function should operate at ONE level of abstraction. Don't mix high-level orchestration with low-level details.
What is "Level of Abstraction"?
Think of abstraction as a ladder:
- High level: "Process the daily billing" - business concept
- Mid level: "Check if billing is due, calculate amount, send notification"
- Low level: "Subtract two dates and divide by milliseconds in a day"
A function should stay at one level. It should either:
- Call high-level functions (orchestration)
- Call mid-level functions (business logic)
- Perform low-level operations (primitive operations)
But not mix them.
Bad Example: Mixed Levels
;; ANTI-PATTERN: Mixing levels of abstraction
(defn run-daily-tasks! []
(let [today (java.time.LocalDate/now) ;; Low level: Java interop AND side-effect. Not mockable.
day-of-week (.getValue (.getDayOfWeek today))] ;; Low level: method calls
(when (= day-of-week 2) ;; Magic number: what is 2?
(let [users (db-query "SELECT * FROM users WHERE active = true")] ;; Mid level + low level
(doseq [user users]
(let [last-bill (:last-billed user)
days-since (/ (- (.getTime today) ;; Low level: date math
(.getTime last-bill))
(* 1000 60 60 24))]
(when (> days-since 30) ;; Magic number
(process-billing! (:id user)))))))) ;; High level
Problems:
- Can't test the "is it Tuesday?" logic without side effects
- Can't test the "has 30 days passed?" logic independently
- Magic numbers (2, 30) embedded in the function
- Date math mixed with business logic
- High-level operation (process-billing!) mixed with low-level date arithmetic
Good Example: Separated Levels
;; Additional schemas for billing domain
(>def :user/id pos-int?)
(>def :domain/user [:map [:id :user/id] [:last-billed (? :java.time.LocalDate)]])
(>def :domain/users [:vector :domain/user])
;; LOW LEVEL: Date operations
;; Note: Type hints (^LocalDate) are critical for Java interop performance and correctness
(>defn day-of-week
"Pure: Extract day of week from date.
BEHAVIOR: Returns 1-7 (Monday=1, Tuesday=2, etc.)"
[date]
[:java.time.LocalDate => :int]
(let [^java.time.LocalDate d date]
(.getValue (.getDayOfWeek d))))
(>defn days-between
"Pure: Calculate days between two dates.
BEHAVIOR: Returns integer number of days"
[^java.time.LocalDate start ^java.time.LocalDate end]
[:java.time.LocalDate :java.time.LocalDate => :int]
(.between java.time.temporal.ChronoUnit/DAYS start end))
;; MID LEVEL: Business predicates
(>defn tuesday?
"Pure: Check if date is a Tuesday.
BEHAVIOR: Returns true if day-of-week is 2"
[date]
[:java.time.LocalDate => :boolean]
(= 2 (day-of-week date)))
(>defn billing-due?
"Pure: Check if billing is due.
BEHAVIOR 1: True if more than 30 days since last-billed
BEHAVIOR 2: True if never billed (nil)
BEHAVIOR 3: False otherwise"
[last-billed current-date]
[(? :java.time.LocalDate) :java.time.LocalDate => :boolean]
(or (nil? last-billed)
(> (days-between last-billed current-date) 30)))
;; MID LEVEL: Data filtering
(>defn users-needing-billing
"Pure: Filter users who need billing.
BEHAVIOR: Returns users where billing-due? is true"
[current-date users]
[:java.time.LocalDate :domain/users => :domain/users]
(filterv #(billing-due? (:last-billed %) current-date) users))
;; HIGH LEVEL: Task orchestration
(>defn run-tuesday-tasks!
"High level: Run tasks that happen on Tuesday.
BEHAVIOR: Processes billing for all users who need it"
[current-date]
[:java.time.LocalDate => :nil]
(let [users (fetch-users!)
users-to-bill (users-needing-billing current-date users)]
(doseq [user users-to-bill]
(process-billing! (:id user)))))
(>defn run-daily-tasks!
"Top level: Orchestrate daily tasks based on current date.
BEHAVIOR: Calls appropriate task functions based on day of week"
[]
[=> :nil]
(let [current-date (get-current-date!)]
(when (tuesday? current-date)
(run-tuesday-tasks! current-date))))
Benefits:
- Each function is at one level of abstraction
- Each function is independently testable
- No magic numbers - named functions document meaning
- Pure functions for all logic
- Side effects only in top-level orchestration
Testing Each Level
;; Testing LOW LEVEL - trivial, fast
(specification "tuesday?"
(behavior "returns true for Tuesday"
(let [tuesday (java.time.LocalDate/of 2025 1 14)] ;; A Tuesday
(assertions
(tuesday? tuesday) => true)))
(behavior "returns false for other days"
(let [monday (java.time.LocalDate/of 2025 1 13)
wednesday (java.time.LocalDate/of 2025 1 15)
thursday (java.time.LocalDate/of 2025 1 16)
friday (java.time.LocalDate/of 2025 1 17)
saturday (java.time.LocalDate/of 2025 1 18)
sunday (java.time.LocalDate/of 2025 1 19)]
(assertions
(tuesday? monday) => false
(tuesday? wednesday) => false
(tuesday? thursday) => false
(tuesday? friday) => false
(tuesday? saturday) => false
(tuesday? sunday) => false))))
;; Testing MID LEVEL - simple, clear
(specification "billing-due?"
(behavior "returns true when never billed"
(assertions
(billing-due? nil (java.time.LocalDate/of 2025 1 15)) => true))
(behavior "returns true when > 30 days"
(let [last-billed (java.time.LocalDate/of 2024 12 1)
current (java.time.LocalDate/of 2025 1 15)]
(assertions
(billing-due? last-billed current) => true)))
(behavior "returns false when <= 30 days"
(let [last-billed (java.time.LocalDate/of 2025 1 1)
current (java.time.LocalDate/of 2025 1 15)]
(assertions
(billing-due? last-billed current) => false))))
;; Testing MID LEVEL - pure data transformation
(specification "users-needing-billing"
(behavior "filters to users needing billing"
(let [current (java.time.LocalDate/of 2025 1 15)
users [{:id 1 :last-billed nil} ;; Needs billing
{:id 2 :last-billed (java.time.LocalDate/of 2024 12 1)} ;; Needs
{:id 3 :last-billed (java.time.LocalDate/of 2025 1 10)}] ;; Doesn't
result (users-needing-billing current users)]
(assertions
(count result) => 2
(map :id result) => [1 2]))))
;; Testing HIGH LEVEL - minimal mocking
(specification "run-tuesday-tasks!"
(behavior "processes billing for users who need it"
(let [current (java.time.LocalDate/of 2025 1 14) ;; Tuesday
users [{:id 1 :last-billed nil}
{:id 2 :last-billed (java.time.LocalDate/of 2025 1 10)}]]
(when-mocking!
(fetch-users!) => users
(process-billing! user-id) => nil
(run-tuesday-tasks! current)
(assertions
"processes billing for user who needs it"
(mock/spied-value process-billing! 0 'user-id) => 1)))))
Principle 3: Cover Every Behavior
A "behavior" is any decision point in your code that causes different outcomes. The goal is to test every behavior, not necessarily every line.
What Counts as a Behavior?
- Conditional branches:
if,when,cond,case,when-let - Boolean operators:
and,or - Exceptions:
try/catch,throw - Collection operations:
filter,map(when the predicate/function varies) - Pattern matching: Different match cases
Each behavior represents a meaningful difference in outcome that users or callers care about.
Identifying Behaviors - Example
;; Schemas for discount domain
(>def :discount/user
[:map
[:premium? {:optional true} :boolean]
[:loyalty-years {:optional true} :int]])
(>defn calculate-discount
"Calculate discount for a user's purchase.
Input: user map, purchase amount
Output: discount amount"
[user amount]
[(? :discount/user) :number => :number]
(cond
(nil? user) 0 ;; BEHAVIOR 1: No user = no discount
(< amount 100) 0 ;; BEHAVIOR 2: Small purchases = no discount
(:premium? user) (* amount 0.20) ;; BEHAVIOR 3: Premium = 20%
(>= (:loyalty-years user 0) 5) (* amount 0.15) ;; BEHAVIOR 4: Loyal = 15%
:else (* amount 0.10))) ;; BEHAVIOR 5: Regular = 10%
This function has 5 behaviors. Each one should have a test:
(specification "calculate-discount"
(behavior "returns 0 for nil user"
(assertions
(calculate-discount nil 500) => 0))
(behavior "returns 0 for purchases under 100"
(assertions
(calculate-discount {:premium? false} 50) => 0
(calculate-discount {:premium? false} 99) => 0))
(behavior "returns 20% for premium users"
(assertions
(calculate-discount {:premium? true} 100) => 20.0
(calculate-discount {:premium? true} 500) => 100.0))
(behavior "returns 15% for loyal (5+ years) users"
(assertions
(calculate-discount {:premium? false :loyalty-years 5} 100) => 15.0
(calculate-discount {:premium? false :loyalty-years 10} 200) => 30.0))
(behavior "returns 10% for regular users"
(assertions
(calculate-discount {:premium? false :loyalty-years 2} 100) => 10.0
(calculate-discount {:premium? false} 100) => 10.0)))
The Combinatorial Explosion Problem
As behaviors combine, test cases can explode:
- Function A has 3 behaviors
- Function B has 4 behaviors
- Function C calls A then B: potentially 3 × 4 = 12 combinations
The Solution: Test each function's behaviors independently at its own level.
Example: Composition without explosion
;; LOW LEVEL: 2 behaviors each
(>defn premium?
[user]
[:discount/user => :boolean]
(boolean (:premium? user)))
(>defn loyalty-years
[user]
[:discount/user => :int]
(:loyalty-years user 0))
;; MID LEVEL: 5 behaviors (tested independently)
(>defn calculate-discount
[user amount]
[(? :discount/user) :number => :number]
(cond
(nil? user) 0
(< amount 100) 0
(premium? user) (* amount 0.20)
(>= (loyalty-years user) 5) (* amount 0.15)
:else (* amount 0.10)))
;; HIGH LEVEL: 2 behaviors (tested with mocks)
(>defn apply-discount-to-order
[order fetch-user!]
[[:map [:user-id :user/id] [:amount :number]] :fn => :number]
(let [user (fetch-user! (:user-id order))
discount (calculate-discount user (:amount order))]
(- (:amount order) discount)))
Testing strategy:
- Test
premium?- 2 tests (true/false) - Test
loyalty-years- 2 tests (present/absent) - Test
calculate-discount- 5 tests (all branches) - Test
apply-discount-to-order- 2 tests (with mocked user)
Total: 11 tests instead of potentially dozens, and each test is clear and focused.
Principle 4: Safe Mocking with Guardrails
WARNING: Mocking without validation is dangerous. You can easily write tests that pass but would fail in production.
Mocking should be used strategically to:
- Isolate the code under test from its dependencies
- Control inputs to test all branches
- Avoid side effects in unit tests
- Make tests deterministic (no random data, current time, etc.)
The Danger of Unvalidated Mocking
Without validation, mocks accept ANY arguments and return ANY values:
;; Function expects a map with :company/id
(>defn process-company
[company]
[[:map [:company/id :uuid]] => :keyword]
(do-something (:company/id company)))
;; BAD: Unvalidated mock (using plain when-mocking from fulcro-spec)
(when-mocking ; note no ! suffix
(process-company c) => :success ;; Accepts ANYTHING, returns ANYTHING
(function-under-test {:wrong-key "bad-data"}) ;; Test passes!
(assertions ...)) ;; But would fail in production!
The test passes because the mock doesn't validate inputs or outputs. This creates false confidence.
Safe Mocking with Guardrails
Use when-mocking! or provided! (the exclamation mark is important) from guardrails helpers:
(ns your.namespace-test
(:require
[fulcro-spec.core :refer [assertions specification behavior =>]]))
;; GOOD: Validated mock
(when-mocking!
(process-company c) => :success
(function-under-test {:wrong-key "bad-data"}) ;; FAILS! Validates arguments
(assertions ...))
Guardrails-enforced mocking provides transitive proof:
- Mock validates arguments match the function's input spec
- Mock validates return values match the function's output spec
- If Test A passes (testing function A) and Test B passes (testing B which mocks A), we have proof A and B compose correctly
This catches integration errors that would otherwise slip through:
- Refactoring that changes argument types
- Refactoring that changes return types
- Passing wrong data through call chains
- Code motion that breaks "glue" between functions
Guardrails Requirements
For safe mocking to work:
- Define functions with guardrails using
>defn:
(ns your.namespace
(:require
[com.fulcrologic.guardrails.malli.core :refer [>defn >def => ? |]]))
(>defn process-company
[company]
[[:map [:company/id :uuid]] => :keyword]
(do-something (:company/id company)))
- Use guardrails-enforced mocking in tests:
(when-mocking! ;; Note the !
(process-company c) => :success
...)
(provided! "description" ;; Note the ! (includes test outline label)
(process-company c) => :success
...)
- Enable guardrails in development:
clojure -J-Dguardrails.enabled=true -M:dev:test
NOTE: Guardrails are removed in production builds (zero runtime cost). They exist only for development and testing.
For complete guardrails documentation, see guardrails.md.
When to Mock
DO mock:
- Side effect functions (database, file I/O, network, etc.)
- Functions that return non-deterministic values (current time, random numbers)
- External dependencies you don't own
- Complex dependencies when testing high-level orchestration
DON'T mock:
- Pure functions - just call them directly
- Your own code when testing that specific code
- Data structures - just create them
- Trivial functions (getters, simple transformations)
Excessive mocking is a code smell indicating poor separation of concerns.
Mocking Patterns
Pattern 1: Mock Side Effects
(specification "load-user-preferences"
(behavior "fetches and processes user preferences"
(when-mocking!
(db-query db query) => {:theme :dark :lang "en"}
(let [result (load-user-preferences db 123)]
(assertions
(:theme result) => :dark
(:lang result) => "en")))))
Pattern 2: Mock for Determinism
(specification "create-order"
(behavior "assigns current timestamp to order"
(when-mocking!
(current-time) => #inst "2025-01-15T12:00:00"
(let [order (create-order {:item-id 123})]
(assertions
(:created-at order) => #inst "2025-01-15T12:00:00")))))
Pattern 3: Mock to Control Branches
(specification "process-payment"
(behavior "handles successful payment"
(when-mocking!
(charge-card! gateway card amount) => {:status :success :transaction-id "ABC123"}
(let [result (process-payment gateway card 100)]
(assertions
(:status result) => :paid
(:transaction-id result) => "ABC123"))))
(behavior "handles failed payment"
(when-mocking!
(charge-card! gateway card amount) => {:status :failed :error "Insufficient funds"}
(let [result (process-payment gateway card 100)]
(assertions
(:status result) => :failed
(:error result) => "Insufficient funds")))))
Pattern 4: Scripted Mocks (Different Returns)
(specification "retry-on-failure"
(behavior "retries up to 3 times on failure"
(when-mocking!
(external-api-call request) =1x=> {:status :error} ;; First call fails
(external-api-call request) =1x=> {:status :error} ;; Second call fails
(external-api-call request) => {:status :success} ;; Third call succeeds
(let [result (retry-on-failure #(external-api-call {:data "test"}))]
(assertions
(:status result) => :success
"made 3 attempts"
(count (mock/calls-of external-api-call)) => 3)))))
Pattern 5: Spy Pattern (Verify Arguments)
(specification "send-notification"
(behavior "sends email with correct content"
(when-mocking!
(send-email! to subject body) => nil
(send-notification {:user-email "test@example.com" :message "Hello"})
(assertions
"sends to correct recipient"
(mock/spied-value send-email! 0 'to) => "test@example.com"
"includes message in body"
(mock/spied-value send-email! 0 'body) => "Hello"))))
Anti-Patterns in Mocking
ANTI-PATTERN: Mocking Too Many Levels Deep
;; BAD: This suggests the function is doing too much
(when-mocking!
(fetch-user db id) => {:id 1}
(fetch-orders db user-id) => [{:id 1}]
(fetch-items db order-id) => [{:id 1}]
(calculate-tax items) => 10
(calculate-shipping items) => 5
(format-receipt order) => "Receipt"
(send-email! email receipt) => nil
;; What is this even testing?
(process-order-pipeline id))
;; GOOD: Break into testable layers
;; Test calculate-tax directly (pure)
;; Test calculate-shipping directly (pure)
;; Test format-receipt directly (pure)
;; Test top-level with minimal mocks
Principle 5: Test Structure as Readable Outlines
Tests should read like documentation. The combination of specification, behavior, and component names should form complete, readable sentences.
The Outline Pattern
(specification "The trim function" ;; Subject
(behavior "removes whitespace" ;; Predicate
(behavior "from the beginning" ;; Detail
(assertions
(trim " foo") => "foo"))
(behavior "from the end" ;; Detail
(assertions
(trim "bar ") => "bar")))
(behavior "treats nil as empty string" ;; Predicate
(assertions
(trim nil) => "")))
Reading this aloud:
- "The trim function removes whitespace from the beginning"
- "The trim function removes whitespace from the end"
- "The trim function treats nil as empty string"
Nested Component Pattern
For complex functions, use component to break down sub-parts:
(specification "order-fulfillment-plan"
(component "when stock is sufficient"
(behavior "sets status to success"
(assertions ...))
(behavior "includes inventory update"
(assertions ...))
(behavior "includes order update"
(assertions ...))
(behavior "generates success email"
(assertions ...)))
(component "when stock is insufficient"
(behavior "sets status to failed"
(assertions ...))
(behavior "includes no database updates"
(assertions ...))
(behavior "generates failure email"
(assertions ...))))
Reads as:
- "order-fulfillment-plan when stock is sufficient sets status to success"
- "order-fulfillment-plan when stock is sufficient includes inventory update"
- "order-fulfillment-plan when stock is sufficient generates success email"
- "order-fulfillment-plan when stock is insufficient generates failure email" ...
Assertion Labels
Use assertion labels when testing multiple aspects in one behavior:
(behavior "processes user batch completely"
(let [result (process-user-batch enrichment users)]
(assertions
"processes valid users"
(count (:processed result)) => 2
"enriches with additional data"
(get-in result [:processed 0 :score]) => 95
"includes statistics"
(get-in result [:stats :total]) => 3
(get-in result [:stats :valid]) => 2
(get-in result [:stats :invalid]) => 1)))
Advanced Patterns and Techniques
Testing Collection Operations
Collections introduce edge cases that must be tested:
- Empty collections
- Single-element collections
- Order preservation
- Nil values within collections
Comprehensive Filter Testing
(specification "filter-active-users"
(behavior "returns only active users"
(let [users [{:id 1 :status :active}
{:id 2 :status :inactive}
{:id 3 :status :active}]
result (filter-active-users users)]
(assertions
(count result) => 2
(map :id result) => [1 3])))
(behavior "returns empty collection for empty input"
(assertions
(filter-active-users []) => []))
(behavior "returns empty collection when no matches"
(let [users [{:id 1 :status :inactive}]]
(assertions
(filter-active-users users) => [])))
(behavior "preserves order"
(let [users [{:id 1 :status :active}
{:id 2 :status :active}
{:id 3 :status :active}]
result (filter-active-users users)]
(assertions
(map :id result) => [1 2 3])))
(behavior "handles nil values in collection"
(let [users [{:id 1 :status nil}
{:id 2 :status :active}]]
(assertions
(count (filter-active-users users)) => 1))))
Testing Aggregations
(specification "sum-field"
(behavior "sums field values"
(assertions
(sum-field :amount [{:amount 10} {:amount 20} {:amount 30}]) => 60))
(behavior "returns 0 for empty collection"
(assertions
(sum-field :amount []) => 0))
(behavior "treats nil values as 0"
(assertions
(sum-field :amount [{:amount 10} {:amount nil} {:amount 20}]) => 30))
(behavior "treats missing key as 0"
(assertions
(sum-field :amount [{:amount 10} {:other 5}]) => 10)))
Testing Composition
When functions compose other functions, test each layer independently:
Layer 1: Primitives
;; Schemas for user domain
(>def :user/status #{:active :inactive})
(>def :user/tier #{:basic :premium :enterprise})
(>def :domain/comp-user [:map [:status :user/status] [:tier :user/tier]])
(>def :domain/comp-users [:vector :domain/comp-user])
(>defn active?
[user]
[:domain/comp-user => :boolean]
(= :active (:status user)))
(>defn premium?
[user]
[:domain/comp-user => :boolean]
(boolean (#{:premium :enterprise} (:tier user))))
;; Test each primitive
(specification "active?" ...)
(specification "premium?" ...)
Layer 2: Composition
(>defn active-premium-users
[users]
[:domain/comp-users => :domain/comp-users]
(->> users
(filter active?)
(filter premium?)))
;; Test composition
(specification "active-premium-users"
(behavior "returns only users that are both active and premium"
(let [users [{:status :active :tier :premium} ;; Match
{:status :inactive :tier :premium} ;; Not active
{:status :active :tier :basic} ;; Not premium
{:status :active :tier :enterprise}] ;; Match
result (active-premium-users users)]
(assertions
(count result) => 2))))
Note: We don't re-test what "active" means - that's tested in the primitive. We only test that the composition works.
Testing Error Paths
Error handling is a behavior that must be tested.
Validation Functions
;; Validation schemas
(>def :validation/error-type #{:missing-email :invalid-email :missing-name})
(>def :validation/error [:map [:error :validation/error-type]])
(>defn validate-user
[user]
[:map => (? :validation/error)]
(cond
(nil? (:email user)) {:error :missing-email}
(not (valid-email? (:email user))) {:error :invalid-email}
(nil? (:name user)) {:error :missing-name}
:else nil)) ;; nil = valid
(specification "validate-user"
(behavior "returns nil for valid user"
(assertions
(validate-user {:email "test@example.com" :name "Test"}) => nil))
(behavior "returns error for missing email"
(assertions
(:error (validate-user {:name "Test"})) => :missing-email))
(behavior "returns error for invalid email format"
(assertions
(:error (validate-user {:email "invalid" :name "Test"})) => :invalid-email))
(behavior "returns error for missing name"
(assertions
(:error (validate-user {:email "test@example.com"})) => :missing-name))
(behavior "returns first error encountered"
;; Missing both email and name - should return missing-email first
(assertions
(:error (validate-user {})) => :missing-email)))
Exception Handling
;; Function that uses exceptions
(>defn parse-int
"Parse integer with validation"
[s]
[:string => :int]
#?(:clj (Integer/parseInt s)
:cljs (let [n (js/parseInt s)]
(if (js/isNaN n)
(throw (js/Error. (str "invalid integer: " s)))
n))))
(specification "parse-int"
(behavior "parses valid integer strings"
(assertions
(parse-int "123") => 123
(parse-int "0") => 0
(parse-int "-456") => -456))
(behavior "throws exception for invalid input"
(assertions
(parse-int "not-a-number") =throws=> #?(:clj NumberFormatException
:cljs js/Error)
(parse-int "") =throws=> #?(:clj NumberFormatException
:cljs js/Error)))
(behavior "includes descriptive error message"
(assertions
(parse-int "abc") =throws=> #"invalid")))
Testing with Edge Cases
Always consider:
- Boundaries (0, empty, max values)
- Nil values
- Invalid input
- Unexpected types
Example: Boundary Testing
(>defn take-up-to-N
"Take up to N elements from a collection"
[n coll]
[:int :vector => :vector]
(if (<= n 0)
[]
(vec (take n coll))))
(specification "take-up-to-N"
(behavior "takes N elements when collection has >= N"
(assertions
(take-up-to-N 3 [1 2 3 4 5]) => [1 2 3]))
(behavior "takes all elements when collection has < N"
(assertions
(take-up-to-N 5 [1 2 3]) => [1 2 3]))
(behavior "returns empty collection for empty input"
(assertions
(take-up-to-N 5 []) => []))
(behavior "handles N = 0"
(assertions
(take-up-to-N 0 [1 2 3]) => []))
(behavior "handles negative N gracefully"
(assertions
(take-up-to-N -1 [1 2 3]) => [])))
Real-World Example: Complete Module
Let's see a complete example that brings all principles together.
Scenario: User Batch Processing
We need to process batches of user data:
- Validate each user
- Enrich with external data
- Calculate derived fields
- Separate valid from invalid
- Return statistics
See src/main/fulcro_spec/examples/data_processing.cljc for the complete implementation and src/test/fulcro_spec/examples/data_processing_spec.cljc for comprehensive tests (113 assertions, all passing).
Key Takeaways from the Example
The example demonstrates:
- Pure predicates -
active?,premium?,valid-email?- trivial to test - Pure transformations -
normalize-email,add-computed-fields- simple input/output - Composition -
active-premium-user-summariescomposes filters and transforms - Validation layers - separate validation concerns, compose them
- No mocking needed for 95% of the code - only the top-level orchestration
Testing Workflow and Practices
Development Workflow
TDD (Test-Driven Development) Option
- Write the specification structure (empty behaviors)
- Fill in one behavior at a time
- Write minimal code to make it pass
- Refactor
- Repeat
Test-After Option
- Write the function
- Identify all behaviors (decision points)
- Write a test for each behavior
- Run tests, fix any issues
- Refactor if needed
Both work - choose what fits your style.
REPL-Driven Testing
Fulcro Spec works great with REPL workflow:
;; In your REPL
(in-ns 'my-namespace-spec)
(require 'fulcro-spec.reporters.repl)
;; Run all tests in current namespace
(fulcro-spec.reporters.repl/run-tests)
;; Run only focused tests
(fulcro-spec.reporters.repl/run-tests #(:focus (meta %)))
Workflow:
- Write/modify function
- Write/modify test
- Run tests in REPL (keyboard shortcut)
- See immediate feedback
- Iterate
Test Organization
Group related behaviors together:
(ns myapp.billing.core-spec
(:require
[fulcro-spec.core :refer [specification behavior assertions => when-mocking! provided!]]
[myapp.billing.core :as billing]))
;; Unit tests (pure functions, fast)
(specification "calculate-base-price" ...)
(specification "apply-discount" ...)
(specification "calculate-tax" ...)
;; In-memory IO tests (stand-in implementations)
(specification "DataStore (Memory)"
(let [store (new-memory-store)]
(run-data-store-tests! store)))
;; Local resource IO tests (PostgreSQL, Redis, etc.)
(specification "DataStore (PostgreSQL)"
(if (postgres-available?)
(let [store (new-postgres-store test-spec)]
(run-data-store-tests! store))
(assertions "PostgreSQL not available" true => true)))
;; Cloud/external IO tests (AWS S3, external APIs)
(specification "DataStore (S3)"
(if (s3-available?)
(let [store (new-s3-store test-bucket)]
(run-data-store-tests! store))
(assertions "S3 not available" true => true)))
;; E2E browser tests
(specification "User workflow (E2E)" ...)
What to Test vs What to Skip
ALWAYS test:
- Public API functions
- Business logic
- Decision points (conditionals)
- Error handling
- Edge cases
- IO protocol implementations - Both stand-in and production implementations
Can SKIP:
- Private implementation details (test through public API)
- Generated code
- Trivial getters/setters
- Third-party library behavior (trust the library tests)
DON'T test:
- Implementation details that might change
- Framework behavior
- Database schema (use integration tests)
IO Layer Testing Pattern
When your code interacts with external resources, use the protocol-based testing pattern:
;; 1. Define protocol
(defprotocol DataStore
(save! [this id data])
(get-by-id [this id])
(delete! [this id]))
;; 2. Create shared test runner
(defn run-data-store-tests! [store]
(component "CRUD operations"
(behavior "can save and retrieve"
(let [id (random-uuid)
data {:name "test"}]
(try
(save! store id data)
(assertions
(get-by-id store id) => data)
(finally
(delete! store id)))))))
;; 3. Test stand-in (fast, always available)
(specification "DataStore (Memory)"
(run-data-store-tests! (new-memory-store)))
;; 4. Test production (with availability guard)
(specification "DataStore (PostgreSQL)"
(if (postgres-available?)
(run-data-store-tests! (new-postgres-store test-spec))
(assertions "Not available" true => true)))
Key points:
- Stand-in must match production semantics exactly
- Always clean up test data in
finallyblocks - Use availability guards for graceful skip
- Shared test runner ensures both implementations are equally tested
Common Challenges and Solutions
"This function is too hard to test"
Solution: The function is telling you it's poorly designed. Refactor it.
Usually one or more of these apply:
- Mixed side effects and logic → Separate them
- Too many responsibilities → Split into smaller functions
- Mixed levels of abstraction → Layer properly
- Hidden dependencies → Make them explicit parameters
"I need to mock 10 things"
Solution: Your function is too coupled. Refactor.
Options:
- Extract pure logic into separate functions
- Create a higher-level abstraction
- Use data-driven design (return a plan, execute elsewhere)
"The setup is huge"
Solution: Use test data builders.
;; BAD: Repeated setup everywhere
(let [user {:id 1 :name "Test" :email "test@example.com"
:status :active :tier :premium :created-at #inst "2025-01-01"}]
...)
;; GOOD: Test data builder
(>defn make-test-user
[overrides]
[:map => [:map
[:id :user/id]
[:name :string]
[:email :order/email]
[:status :user/status]
[:tier :user/tier]
[:created-at :java.time.Instant]]]
(merge
{:id 1
:name "Test User"
:email "test@example.com"
:status :active
:tier :basic
:created-at #inst "2025-01-01"}
overrides))
;; Use it
(let [user (make-test-user {:tier :premium})]
...)
"I have datetime/random dependencies"
Solution: Make them clear functions that are easily mockable.
(>defn now
[]
[=> :java.time.Instant]
; static method of Java. Not mockable. Wrapped in defn becomes easy to control
(java.time.Instant/now))
Evolution and Maintenance
Refactoring with Tests
Tests enable fearless refactoring:
- Run tests - all green
- Refactor implementation
- Run tests - should still be green
- If red, fix or update tests
When to update tests:
- Behavior actually changed
- API changed
- You discovered a missing edge case
When NOT to update tests:
- Internal implementation details changed
- Code was reorganized but behavior unchanged
Growing a Codebase
As the codebase grows:
- Keep functions small - easier to test
- Add tests for bugs - before fixing, write a failing test
- Refactor toward testability - when you find hard-to-test code
- Review test coverage - are all behaviors tested?
Working in Teams
Tests serve as:
- Documentation - show how to use the code
- Safety net - prevent regressions
- Design review - hard-to-test = needs redesign
- Communication - tests express intent
Good tests make onboarding easier and enable parallel development.
Antipatterns to Avoid
Antipattern 1: Testing Implementation Details
;; BAD: Testing how it works instead of what it does
(assertions
"calls helper-fn exactly once"
(mock/call-of helper-fn 0) => {...})
;; GOOD: Testing the observable result
(assertions
"returns correct result"
(my-function input) => expected-output)
Antipattern 2: Brittle Tests
;; BAD: Breaks when any field changes
(assertions
result => {:id 1 :name "Test" :email "test@example.com"
:created-at #inst "2025-01-01" :updated-at #inst "2025-01-01"
:status :active :tier :basic :score 0 ...})
;; GOOD: Test only what matters
(assertions
(:id result) => 1
(:status result) => :active
(:tier result) => :basic)
Antipattern 3: One Giant Test
;; BAD: Tests everything in one giant test
(behavior "does everything"
(assertions
(function-under-test input1) => output1
(function-under-test input2) => output2
(function-under-test input3) => output3
...))
;; GOOD: Separate behavior per test
(behavior "handles case A"
(assertions (function-under-test input1) => output1))
(behavior "handles case B"
(assertions (function-under-test input2) => output2))
Antipattern 4: No Assertions
;; BAD: Test that doesn't assert anything meaningful
(behavior "processes order"
(process-order! order)) ;; No assertions!
;; GOOD: Verify the outcome
(behavior "processes order successfully"
(when-mocking!
(save-order! db order) => nil
(let [result (process-order! order db)]
(assertions
(:status result) => :success))))
Transitive Coverage Proofs
The fulcro-spec proof system enables verified coverage chains from application logic down to library boundaries.
Configuration
Create .fulcro-spec.edn in your project root:
{:scope-ns-prefixes #{"myapp"}}
Sealing Tests with Signatures
After covering all behaviors, seal the specification:
;; Get the signature
(require '[fulcro-spec.proof :as proof])
(proof/signature 'myapp.core/my-function)
;; => "a1b2c3" (leaf function)
;; => "a1b2c3,d4e5f6" (non-leaf function with callees)
;; Add to specification metadata
(specification {:covers {`sut/my-function "a1b2c3"}} "my-function"
(behavior "does something"
(assertions ...)))
Signature Formats
- Leaf functions (no in-scope callees):
"xxxxxx"- 6-char hash of source - Non-leaf functions (has callees):
"xxxxxx,yyyyyy"- source hash + callees hash
Verifying Coverage
;; Is function fully tested (including transitive deps)?
(proof/fully-tested? 'myapp.core/my-function)
;; => true/false
;; What's missing?
(proof/why-not-tested? 'myapp.core/my-function)
;; => {:uncovered #{myapp.db/save!} :stale #{myapp.util/helper}}
;; => nil if fully tested
;; Overall stats
(proof/coverage-stats)
;; => {:total 42 :covered 38 :uncovered 4 :stale 2 :fresh 36 :coverage-pct 90.5}
Accurate Metrics Tracking
Always use coverage-stats output for tracking rather than manually counting assertions.
After completing a specification, run:
(proof/coverage-stats)
Include the output in your implementation log for accurate tracking. This prevents assertion count mismatches that occur when estimating manually.
Example log entry:
## Coverage Stats After Slice 2
{:total 8 :covered 8 :uncovered 0 :stale 0 :fresh 8 :coverage-pct 100.0}
Assertions: 214 (actual count from test run)
Staleness Detection
When implementation changes, signatures become stale:
;; Find stale functions
(proof/stale-functions)
;; => #{myapp.core/changed-fn}
;; Get reseal advice
(proof/reseal-advice)
;; => {myapp.core/changed-fn {:new-signature "xyz789" :tested-by #{myapp.core-test/spec}}}
Reseal Workflow
- Run
(proof/stale-functions)to find stale tests - Review code changes to ensure tests still valid
- Run
(proof/reseal-advice)to get new signatures - Update
:coversmetadata with new signatures - Verify with
(proof/fresh? 'fn-name)
Behavior Verification Protocol
For each behavior, verify the test can fail:
- Write the test
- Run the test - it should pass
- Break the specific behavior in the implementation
- Run again - only that test should fail
- Restore the implementation
- Mark verified in your tracking
This ensures:
- No cascading failures
- Each behavior is independently testable
- Coverage is meaningful, not accidental
Complete Example
(ns myapp.orders-spec
(:require
[fulcro-spec.core :refer [specification behavior assertions =>]]
[myapp.orders :as sut]))
;; After full coverage and verification, seal with signature
(specification {:covers {`sut/calculate-order-total "f3d2a1"}} "calculate-order-total"
(behavior "returns 0 for empty items"
;; Verified: changing [] check to (seq items) fails this test only
(assertions
(sut/calculate-order-total []) => 0))
(behavior "sums item prices"
;; Verified: changing + to * fails this test only
(assertions
(sut/calculate-order-total [{:price 10} {:price 20}]) => 30))
(behavior "applies discount when provided"
;; Verified: removing discount logic fails this test only
(assertions
(sut/calculate-order-total [{:price 100}] {:discount 0.1}) => 90)))
MANDATORY: Sealing Workflow
Every specification MUST follow this final step:
- All behaviors pass - Run the tests, confirm green
- Get signature -
(proof/signature 'ns/fn) - Add :covers metadata -
(specification {:covers {sut/fn "signature"}} ...)` - Verify still passes - Run tests again with metadata
A specification without :covers metadata is incomplete. This is not optional - it is the mechanism that enables transitive coverage verification and staleness detection.
Conclusion
Mastering these principles and techniques will transform your testing practice:
- Move side effects to edges - makes 90% of code pure and easy to test
- Single level of abstraction - makes each function focused and clear
- Cover every behavior - comprehensive without combinatorial explosion
- Safe mocking with guardrails - validated mocks prevent false confidence
- Readable test structure - tests as living documentation
The result is:
- Fast test suites (milliseconds)
- Comprehensive coverage (all behaviors)
- Clear, maintainable tests
- Confidence in refactoring
- Better designed code
Remember: If it's hard to test, it's probably poorly designed. Let testability guide your design toward simplicity and clarity.