Browse Learn Clojure Foundations as a Java Developer

Refactor Java Behavior and Prove It with Tests

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.

Start With Characterization Tests

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.

Compare Java And Clojure From Fixtures

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.

Test The Pure Core Directly

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.

Test The Adapter Separately

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.

Use Property Checks Selectively

Property-based tests are useful when the behavior has invariants across many inputs. They are not required for every migrated function.

Good properties:

  • totals never become negative after valid discounts
  • normalization is idempotent
  • sorting preserves all input IDs
  • serialization round trips through the Java boundary
  • applying a validation rule twice returns the same result

Poor properties:

  • vague claims such as “it should work for all inputs”
  • generated data that does not resemble the domain
  • properties that duplicate one example test with random noise

Use properties when they expose important invariants that examples alone might miss.

Preserve Review Discipline

During review, ask:

  • Did the change alter behavior intentionally or accidentally?
  • Are representation differences normalized before comparison?
  • Are side effects isolated enough to test the pure core?
  • Does the adapter test reflect real Java caller expectations?
  • Are edge cases from production or bug history included?
  • Is rollback still possible if the migrated path fails?

These questions keep refactoring grounded in production behavior rather than aesthetics.

Practice

  1. Write characterization tests for one Java method before translating it.
  2. Build a fixture comparison between the Java and Clojure implementations.
  3. Extract the pure Clojure rule and test it directly.
  4. Add one adapter test that proves Java can call the migrated path.
  5. Identify one property worth checking, or explain why example tests are enough.

Key Takeaways

  • Refactoring must preserve external behavior unless a behavior change is explicit.
  • Characterization tests capture current semantics before the rewrite.
  • Fixture comparison lets Java and Clojure implementations prove equivalence.
  • Pure function tests, adapter tests, and integration tests answer different questions.
  • Property checks help when the domain has meaningful invariants across many inputs.

Quiz: Refactoring And Testing Migration Code

### What is the purpose of a characterization test? - [x] To capture current behavior before refactoring changes implementation structure. - [ ] To prove the new code is shorter. - [ ] To delete old Java tests. - [ ] To replace adapter tests. > **Explanation:** Characterization tests preserve knowledge of current behavior, including edge cases. ### Why normalize Java and Clojure results before comparison? - [x] The two implementations may represent equivalent behavior with different types or shapes. - [ ] Clojure cannot compare values. - [ ] Java objects are always incorrect. - [ ] It avoids testing behavior. > **Explanation:** Equivalence is about behavior, not necessarily identical internal representation. ### What should pure Clojure unit tests avoid? - [x] Databases, clocks, queues, and Java adapter concerns. - [ ] Small value inputs. - [ ] Direct rule assertions. - [ ] Domain examples. > **Explanation:** Pure tests should exercise business logic without external side effects. ### What does an adapter test prove? - [x] Java callers can invoke the Clojure path and receive the expected boundary contract. - [ ] Every business rule has been tested. - [ ] Production rollout is complete. - [ ] Property tests are unnecessary. > **Explanation:** Adapter tests focus on the cross-language boundary. ### When are property-based tests most useful? - [x] When the domain has invariants that should hold across many inputs. - [ ] For every function regardless of behavior. - [ ] Only when no example tests exist. - [ ] To avoid writing fixtures. > **Explanation:** Property checks complement examples when broad invariants matter.
Revised on Saturday, May 23, 2026