
testing-anti-patterns
by Ramblurr
remote dev environment for agents
SKILL.md
name: Testing-Anti-Patterns description: Use when writing or changing tests, adding mocks, or tempted to add test-only methods to production code - prevents testing mock behavior, production pollution with test-only methods, and mocking without understanding dependencies
Testing Anti-Patterns
Overview
Tests must verify real behavior, not mock behavior. Mocks are a means to isolate, not the thing being tested.
Core principle: Test what the code does, not what the mocks do.
Following strict TDD prevents these anti-patterns.
The Iron Laws
1. NEVER test mock behavior
2. NEVER add test-only functions to production namespaces
3. NEVER mock without understanding dependencies
Anti-Pattern 1: Testing Mock Behavior
The violation:
;; BAD: Testing that the mock was called, not that the behavior is correct
(deftest processes-order-test
(with-redefs [db/save-order (fn [_] nil)]
(process-order {:id 1 :items ["a"]})
;; Only verifies mock was called - tells us nothing about real behavior
(is (= 1 @call-count))))
Why this is wrong:
- You're verifying the mock works, not that the function works
- Test passes when mock is present, fails when it's not
- Tells you nothing about real behavior
Your human partner's correction: "Are we testing the behavior of a mock?"
The fix:
;; GOOD: Test the actual outcome, not the mock
(deftest processes-order-test
(with-redefs [db/save-order (fn [order] (swap! test-db conj order))]
(let [order {:id 1 :items ["a" "b"]}]
(process-order order)
;; Test the real behavior: order was processed correctly
(is (= 1 (count @test-db)))
(is (= order (first @test-db))))))
;; OR better: use a test database fixture instead of mocking
(deftest processes-order-test
(let [order {:id 1 :items ["a" "b"]}]
(process-order order)
(is (= order (db/get-order *test-db* 1)))))
Gate Function
BEFORE asserting on any mock:
Ask: "Am I testing real behavior or just mock existence?"
IF testing mock existence:
STOP - Delete the assertion or use real implementation
Test real behavior instead
Anti-Pattern 2: Test-Only Functions in Production
The violation:
;; BAD: reset-state! only used in tests
(ns myapp.session)
(defonce ^:private state (atom {}))
(defn create-session [user-id]
(swap! state assoc user-id {:created (System/currentTimeMillis)}))
;; This function exists only for tests!
(defn reset-state!
"Resets internal state. FOR TESTING ONLY."
[]
(reset! state {}))
Why this is wrong:
- Production namespace polluted with test-only code
- Dangerous if accidentally called in production
- Violates separation of concerns
- The comment "FOR TESTING ONLY" is a code smell
The fix:
;; GOOD: Production code has no test-only functions
(ns myapp.session)
(defonce ^:private state (atom {}))
(defn create-session [user-id]
(swap! state assoc user-id {:created (System/currentTimeMillis)}))
;; In test namespace - use fixture for cleanup
(ns myapp.session-test
(:require [clojure.test :refer [deftest is use-fixtures]]
[myapp.session :as session]))
;; Access internal state via var for testing only
(defn reset-session-state-fixture [f]
(reset! @#'session/state {})
(f)
(reset! @#'session/state {}))
(use-fixtures :each reset-session-state-fixture)
Gate Function
BEFORE adding any function to production namespace:
Ask: "Is this only used by tests?"
IF yes:
STOP - Don't add it
Put it in test namespace or test utilities instead
Ask: "Does this namespace own this resource's lifecycle?"
IF no:
STOP - Wrong namespace for this function
Anti-Pattern 3: Mocking Without Understanding
The violation:
;; BAD: Mock breaks test logic
(deftest detects-duplicate-server-test
;; Mock prevents config write that test depends on!
(with-redefs [config/save-server (fn [_] nil)]
(add-server {:name "server1" :url "http://localhost"})
;; This should throw duplicate error - but config was never saved!
(is (thrown? Exception
(add-server {:name "server1" :url "http://localhost"})))))
Why this is wrong:
- Mocked function had side effect test depended on (writing config)
- Over-mocking to "be safe" breaks actual behavior
- Test passes for wrong reason or fails mysteriously
The fix:
;; GOOD: Mock at correct level - only the slow/external operation
(deftest detects-duplicate-server-test
;; Only mock the actual network call, preserve config behavior
(with-redefs [http/connect (fn [_] {:status :connected})]
(add-server {:name "server1" :url "http://localhost"})
;; Config was written, duplicate detection works
(is (thrown? Exception
(add-server {:name "server1" :url "http://localhost"})))))
Gate Function
BEFORE mocking any function:
STOP - Don't mock yet
1. Ask: "What side effects does the real function have?"
2. Ask: "Does this test depend on any of those side effects?"
3. Ask: "Do I fully understand what this test needs?"
IF depends on side effects:
Mock at lower level (the actual slow/external operation)
OR use test doubles that preserve necessary behavior
NOT the high-level function the test depends on
IF unsure what test depends on:
Run test with real implementation FIRST
Observe what actually needs to happen
THEN add minimal mocking at the right level
Red flags:
- "I'll mock this to be safe"
- "This might be slow, better mock it"
- Mocking without understanding the dependency chain
Anti-Pattern 4: Incomplete Mocks
The violation:
;; BAD: Partial mock - only fields you think you need
(deftest processes-api-response-test
(with-redefs [api/fetch-user (fn [_] {:id 123 :name "Alice"})]
;; Later: breaks when code accesses (:email response) or (:metadata response)
(is (= "Alice" (:name (process-user-response 123))))))
Why this is wrong:
- Partial mocks hide structural assumptions - You only mocked fields you know about
- Downstream code may depend on fields you didn't include - Silent nil failures
- Tests pass but integration fails - Mock incomplete, real API complete
- False confidence - Test proves nothing about real behavior
The Iron Rule: Mock the COMPLETE data structure as it exists in reality, not just fields your immediate test uses.
The fix:
;; GOOD: Mirror real API response completely
(def mock-user-response
{:id 123
:name "Alice"
:email "alice@example.com"
:metadata {:request-id "req-789"
:timestamp 1234567890}
:permissions #{:read :write}})
(deftest processes-api-response-test
(with-redefs [api/fetch-user (fn [_] mock-user-response)]
(let [result (process-user-response 123)]
(is (= "Alice" (:name result)))
(is (some? (:metadata result))))))
Gate Function
BEFORE creating mock data:
Check: "What fields does the real data structure contain?"
Actions:
1. Examine actual API response/data from docs or REPL
2. Include ALL fields system might consume downstream
3. Verify mock matches real data schema completely
Critical:
If you're creating a mock, you must understand the ENTIRE structure
Partial mocks fail silently when code depends on omitted fields
If uncertain: Include all documented fields
Anti-Pattern 5: Misusing with-redefs vs binding
The violation:
;; BAD: Using with-redefs for dynamic vars
(def ^:dynamic *config* {:env :prod})
(deftest config-test
;; with-redefs on dynamic var - affects all threads, not scoped properly
(with-redefs [*config* {:env :test}]
(is (= :test (:env *config*)))))
Why this is wrong:
with-redefsmodifies the var root, affecting all threads- Not thread-safe for dynamic vars
bindingexists specifically for this purpose
The fix:
;; GOOD: Use binding for dynamic vars
(def ^:dynamic *config* {:env :prod})
(deftest config-test
(binding [*config* {:env :test}]
(is (= :test (:env *config*)))))
;; GOOD: Use with-redefs for regular functions
(deftest api-test
(with-redefs [http/get (fn [_] {:status 200 :body "ok"})]
(is (= 200 (:status (fetch-data))))))
Gate Function
BEFORE choosing mock mechanism:
Ask: "Is this a dynamic var (^:dynamic)?"
IF yes:
Use binding - thread-local, properly scoped
IF no (regular var/function):
Use with-redefs - temporarily replaces root binding
Anti-Pattern 6: Integration Tests as Afterthought
The violation:
Implementation complete
No tests written
"Ready for testing"
Why this is wrong:
- Testing is part of implementation, not optional follow-up
- TDD would have caught this
- Can't claim complete without tests
The fix:
TDD cycle:
1. Write failing test
2. Implement to pass
3. Refactor
4. THEN claim complete
Anti-Pattern 7: Fragmented Assertions
The violation:
;; BAD: Picking apart the result piece by piece
(deftest user-creation-test
(let [result (sut/create-user {:name "Alice" :email "alice@example.com"})]
(is (= "Alice" (:name result)))
(is (= "alice@example.com" (:email result)))
(is (uuid? (:id result)))
(is (inst? (:created-at result)))
(is (= :active (:status result)))))
Why this is wrong:
- Obscures the actual data shape - Can't see at a glance what the function returns
- Harder to maintain - Adding a field means adding another assertion
- Poor failure messages - "expected: Alice, actual: Bob" tells you less than a full diff
- Verbose and noisy - Five lines of assertions when one would do
- Easy to miss fields - You might forget to assert on important keys
The fix:
;; GOOD: Single assertion on the whole structure
(deftest user-creation-test
(let [result (sut/create-user {:name "Alice" :email "alice@example.com"})]
(is (= {:name "Alice"
:email "alice@example.com"
:id (:id result) ; capture generated values
:created-at (:created-at result)
:status :active}
result))))
For generated/dynamic values:
;; GOOD: Use select-keys when you can't predict all fields
(deftest user-creation-test
(let [result (sut/create-user {:name "Alice" :email "alice@example.com"})]
;; Assert on known fields
(is (= {:name "Alice"
:email "alice@example.com"
:status :active}
(select-keys result [:name :email :status])))
;; Separately verify generated fields exist and have correct type
(is (uuid? (:id result)))
(is (inst? (:created-at result)))))
Gate Function
BEFORE writing multiple (is (= ...)) assertions on the same result:
Ask: "Could this be a single data comparison?"
IF yes:
Write one (is (= {...expected...} result))
Use select-keys if you need to ignore some fields
Only use separate assertions for type checks on generated values
Benefits:
- Shows complete expected shape in one place
- Test failure diff shows exactly what's different
- Easier to update when data shape changes
When Mocks Become Too Complex
Warning signs:
- Mock setup longer than test logic
- Using
with-redefson 5+ functions - Mocks missing behavior real functions have
- Test breaks when mock changes
Your human partner's question: "Do we need to be using a mock here?"
Consider: Integration tests with real components (using fixtures) often simpler than complex mocks
;; BAD: Complex mock setup
(deftest complex-workflow-test
(with-redefs [db/query (fn [_] [...])
cache/get (fn [_] nil)
cache/set (fn [_ _] nil)
api/fetch (fn [_] {...})
metrics/record (fn [_] nil)]
;; Test logic buried under mocks
...))
;; GOOD: Use test fixtures with real (or test) implementations
(use-fixtures :each db-fixture cache-fixture)
(deftest complex-workflow-test
;; Only mock external API
(with-redefs [api/fetch (fn [_] mock-api-response)]
(let [result (complex-workflow {:id 1})]
(is (= :success (:status result)))
;; Verify real db state
(is (some? (db/get-by-id *test-db* 1))))))
TDD Prevents These Anti-Patterns
Why TDD helps:
- Write test first - Forces you to think about what you're actually testing
- Watch it fail - Confirms test tests real behavior, not mocks
- Minimal implementation - No test-only functions creep in
- Real dependencies - You see what the test actually needs before mocking
If you're testing mock behavior, you violated TDD - you added mocks without watching test fail against real code first.
Quick Reference
| Anti-Pattern | Fix |
|---|---|
| Assert on mock calls | Test real behavior or outcomes |
| Test-only fns in production | Move to test namespace/utilities |
| Mock without understanding | Understand dependencies first, mock minimally |
| Incomplete mock data | Mirror real data structure completely |
| with-redefs on dynamic vars | Use binding instead |
| Tests as afterthought | TDD - tests first |
| Over-complex mocks | Consider integration tests with fixtures |
| Fragmented assertions | Single (is (= {...} result)) comparison |
Red Flags
- Test only verifies mock was called
- Functions only called in test files
- Mock setup is >50% of test
- Test fails when you remove mock
- Can't explain why mock is needed
- Mocking "just to be safe"
- Using
with-redefson^:dynamicvars - Multiple
(is (= x (:key result)))on same result
The Bottom Line
Mocks are tools to isolate, not things to test.
If TDD reveals you're testing mock behavior, you've gone wrong.
Fix: Test real behavior or question why you're mocking at all.
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon