Refactor Java behavior into Clojure without changing semantics by combining characterization tests, fixture comparison, pure function tests, adapter tests, and property checks where they add value.
Refactoring during migration means changing structure while preserving externally visible behavior. The dangerous part is not translating Java syntax to Clojure. The dangerous part is accidentally changing edge cases, error behavior, ordering, numeric precision, null handling, or side effects while the team is focused on language differences.
Testing is the control system for that risk. A migrated Clojure function should not merely look cleaner than the Java method. It should produce the same results for the cases that matter, and it should make future changes easier to review.
Characterization test: A test that records what the current system does, including awkward or surprising behavior, before refactoring changes the implementation.
Before rewriting, capture the current Java behavior.
| Behavior area | Test examples |
|---|---|
| Empty or missing input | Empty list, null boundary value, missing optional field |
| Edge values | Zero quantity, negative amount, max/min numeric value, duplicate key |
| Ordering | Stable sort order, priority rules, first-match behavior |
| Error behavior | Exception type, error payload, rejection reason |
| Side effects | Number of writes, event payload, logging context, retry behavior |
Do not clean up behavior silently during characterization. If the current Java behavior is wrong, document it and decide whether the migration will preserve it first or intentionally fix it in a separate change.
Fixture comparison is often the most practical bridge for Java teams. The same input fixture feeds the old Java implementation and the new Clojure implementation.
1(defn equivalent-result? [java-fn clojure-fn fixture]
2 (= (java-fn fixture)
3 (clojure-fn fixture)))
In real code, normalize representation before comparison when Java and Clojure use different collection or numeric types. Be explicit about what is allowed to differ.
| Difference | Compare how |
|---|---|
| Java object vs Clojure map | Convert both to a canonical value map. |
BigDecimal scale differences |
Compare numeric value according to domain rules. |
| Ordering is irrelevant | Compare as sets or sorted canonical collections. |
| Exception vs rejection value | Decide whether boundary behavior or internal representation is being tested. |
| Timestamp or UUID | Inject deterministic values into both implementations. |
Equivalence tests should protect behavior, not force identical internal representation.
Once behavior is inside Clojure, test the pure core without Java adapters, databases, clocks, or message brokers.
1(ns billing.rules-test
2 (:require [clojure.test :refer [deftest is testing]]
3 [billing.rules :as rules]))
4
5(deftest enterprise-discount-test
6 (testing "enterprise customers receive the negotiated discount"
7 (is (= {:discount/rate 0.15M
8 :discount/reason :enterprise-tier}
9 (rules/discount-for {:customer/tier :enterprise
10 :cart/subtotal 500M})))))
This test is small because the function is small. That is one of the migration benefits: once side effects are at the edge, business rules become easier to exercise directly.
Adapter tests prove Java and Clojure agree about the boundary contract. They do not need to repeat every business rule covered by pure tests.
| Test layer | Purpose |
|---|---|
| Pure Clojure unit tests | Rule behavior over values. |
| Fixture comparison tests | Old Java behavior and new Clojure behavior match. |
| Adapter tests | Java callers can invoke the Clojure path and receive expected boundary types. |
| Integration tests | Real dependencies work where they must be included. |
| Production checks | Logs, metrics, and rollout controls show safe behavior under real traffic. |
Separating these layers keeps the suite fast and understandable. A single giant integration test cannot explain whether a failure came from data conversion, rule logic, classpath setup, or an external dependency.
Property-based tests are useful when the behavior has invariants across many inputs. They are not required for every migrated function.
Good properties:
Poor properties:
Use properties when they expose important invariants that examples alone might miss.
During review, ask:
These questions keep refactoring grounded in production behavior rather than aesthetics.