Clojure 以「REPL 驱动开发」著称——但这不代表它不需要系统化的测试工程。恰恰相反,Clojure 的不可变与纯函数特性让测试变得异常干净:没有 mock 泛滥、没有复杂的状态清理,测试代码几乎就是「输入 → 断言输出」。本文从测试框架、测试替身、外部依赖、覆盖率到 CI 门禁,搭建 Clojure 项目的完整质量体系,并与 /clojure-property-testing/ 的属性测试形成互补。
1. 测试框架选型
1.1 三个主流选择
| 框架 | 风格 | 特点 |
|---|---|---|
| clojure.test | 官方、deftest/is | 最普及、工具集成好 |
| kaocha | 测试运行器 | 并行、CLI 友好、可扩展 |
| expectations | 期望式断言 | 语法更简洁、错误信息友好 |
推荐组合:
clojure.test写用例 +kaocha作为运行器(支持并行、watch、CI 友好输出)。若团队喜欢更「声明式」的断言,再引入 expectations。
2. clojure.test 基础
2.1 单元测试
(ns order.core-test
(:require [clojure.test :refer [deftest is testing]]
[order.core :as core]))
(deftest discount-test
(testing "VIP 用户折扣"
(is (= 90 (core/apply-discount 100 {:tier :vip})))
(is (= 100 (core/apply-discount 100 {:tier :normal}))))
(testing "折扣不叠加到负数"
(is (>= (core/apply-discount 10 {:tier :vip}) 0))))
(deftest edge-cases
(is (nil? (core/apply-discount nil {})) "空订单返回 nil"))
2.2 测试运行
# clojure.test 内置运行
clojure -M:test
# kaocha 运行(支持 watch / fail-fast / 并行)
clojure -M:test -m kaocha.runner
clojure -M:test -m kaocha.runner --watch
clojure -M:test -m kaocha.runner --parallel
3. REPL 驱动测试工作流
REPL 驱动与测试并不矛盾——正确姿势是先写测试定义行为,再在 REPL 里让测试变绿:
1. 在 REPL 里写一个失败的测试(红)
2. 用 REPL 求值逐步实现(绿)
3. 重构时重复运行测试(重构)
;; 在 REPL 里交互式测试单个函数
(require '[order.core :as core])
(is (= 90 (core/apply-discount 100 {:tier :vip}))) ; REPL 直接断言
(run-tests 'order.core-test) ; 或跑整个命名空间
4. 测试替身与依赖注入
4.1 Clojure 为什么 mock 少
纯函数「输入 → 输出」,几乎不需要 mock。真正要处理的是副作用依赖(DB、HTTP、时钟),Clojure 惯用「依赖注入 + 动态绑定」:
;; 通过参数注入依赖(最干净)
(defn process-order [{:keys [db notifier clock]} order]
(let [now ((:clock) ())
saved (db/save! db (assoc order :paid_at now))]
(notifier/notify! notifier saved)
saved))
;; 测试时注入假实现
(process-order {:db fake-db
:notifier fake-notifier
:clock (constantly #inst "2026-09-28")}
order)
4.2 with-redefs 处理全局依赖
;; 兜底手段:临时重定义全局函数
(defn fetch-rate [] (http/get "https://api.example.com/rate"))
(deftest rate-test
(with-redefs [fetch-rate (fn [] {:status 200 :body 7.2})]
(is (= 7.2 (core/convert-amount 100)))))
判断:优先「参数注入」,
with-redefs只用于遗留代码的测试缝——依赖注入让测试意图更清晰。
5. 外部依赖测试:Testcontainers
5.1 真库集成测试
数据库、消息队列等外部依赖,用 Testcontainers 起真实环境:
(require '[clj-test-containers.core :as tc])
(def pg
(tc/create {:image-name "postgres:16"
:exposed-port 5432}))
(tc/start! pg)
;; 拿到真实连接串,指向 Testcontainers 数据库
(def db-conn (-> pg tc/host (str ":" (tc/port pg))))
(deftest db-integration-test
(let [conn (jdbc/get-datasource {:jdbcUrl (str "jdbc:postgresql://" db-conn "/test")})]
(jdbc/execute! conn ["create table if not exists orders (...)"])
(is (= 1 (count (core/query-orders conn))))))
(tc/stop! pg)
5.2 何时用真环境
| 依赖 | 单元测试 | 集成测试 |
|---|---|---|
| 纯函数 | ✅ 完全 mock 掉 | — |
| 数据库 | 内存库/H2 | ✅ Testcontainers 真库 |
| 消息队列 | — | ✅ Testcontainers Kafka |
| 外部 HTTP | mock 客户端 | 契约测试 / 本地 stub |
6. 覆盖率与质量门禁
6.1 覆盖率
;; cloverage 生成覆盖率报告
clojure -M:coverage
;; 结果:整体覆盖率 + 按命名空间
| namespace | % forms | % lines |
|--------------|---------|---------|
| order.core | 91.2 | 89.5 |
| order.db | 78.3 | 75.0 |
要点:覆盖率是「测试是否覆盖代码」的度量,不是「质量」的度量。优先看关键路径与分支覆盖,而不是追求 100% 行覆盖。
6.2 质量门禁
# CI 中强制门禁:覆盖率不达标 → 构建失败
# kaocha + cloverage 组合
- run: clojure -M:coverage
env:
COVERAGE_THRESHOLD_LINE: "80"
7. CI 流水线集成
7.1 标准 CI 阶段
lint(clj-kondo)→ 单元测试(kaocha)→ 覆盖率(cloverage)→ 集成测试(Testcontainers)→ 构建 uberjar → 部署
7.2 GitHub Actions 示例
name: clojure-ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
- uses: DeLaGuardo/setup-clojure@12
with: { tools-deps: '1.11.1' }
- run: clojure -M:lint
- run: clojure -M:test
- run: clojure -M:coverage
8. 测试金字塔的 Clojure 落地
/\ 端到端(少量,Testcontainers + 真实服务)
/ \
/ \
/ 集成 \ ← 跨组件(DB + Kafka 真环境)
/---------\
/ 单元测试 \ ← 大量(纯函数,秒级)
/_____________\
| 层级 | 数量 | 速度 | 依赖 |
|---|---|---|---|
| 单元 | 多 | 秒级 | 无(纯函数) |
| 集成 | 中 | 分钟级 | Testcontainers |
| 端到端 | 少 | 慢 | 完整环境 |
9. 常见陷阱
| 陷阱 | 现象 | 规避 |
|---|---|---|
| 测试依赖顺序 | 单独跑失败 | 每个测试自给自足 |
| 时间依赖断言 | 跨午夜失败 | 注入时钟(constantly) |
| 覆盖率虚荣 | 高覆盖仍漏关键 | 关注关键路径分支 |
| REPL 状态残留 | 测试结果漂移 | 每次测试重置全局 atom |
| 不测错误路径 | 只测 happy path | 显式测异常与边界 |
10. 总结
Clojure 测试工程的独特优势是纯函数让单元测试几乎零成本:依赖注入 + 参数化让 mock 最小化,REPL 让「红绿重构」循环顺畅,Testcontainers 让集成测试接近生产。落地组合拳:clojure.test 写用例 + kaocha 运行 + cloverage 覆盖门禁 + clj-kondo lint + CI 流水线;再用 /clojure-property-testing/ 的属性测试覆盖「无限边界」,用 /clojure-data-pipeline/ 的幂等思路保障数据侧。质量工程在 Clojure 里不是负担,而是函数式范式的自然产物。
延伸阅读
- Clojure 生成式测试实战 — 属性测试与不变式
- Clojure spec 与测试 — spec 与 fdef 的测试集成
- Clojure 数据管道与流处理 — 管道侧的质量保障
- 软件测试专题 — 软件测试工程专题全景
- GitHub Actions 专题 — CI/CD 流水线实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。