一、為什么“單元測 ES”這么別扭?
測試 ES 代碼時,最直覺的做法是連真集群做集成測試(Docker 起個 ES),但:
- 啟動 & 數據裝填慢,不利于并行;
- 網絡/磁盤抖動影響穩定性;
- 很多用例其實只想驗證我寫的邏輯,不是驗證 ES 自己。
單元測試更適合快速回歸。思路是:把客戶端的 HTTP 層換成 mock,其余組件照常運行。這就是官方 mock 庫 @elastic/elasticsearch-mock
的用武之地。
二、官方 JS 客戶端的內部構件(理解這一張圖,mock 不會走偏)
- API layer:所有可調用的 ES API。
- Transport:請求的準備、重試、sniff 等策略。
- ConnectionPool:管理多個節點。
- Serializer:JSON/Ndjson 序列化。
- Connection:真正發 HTTP 的地方。
最佳 mock 點:Connection。
我們只替換 Connection,其它(API、Transport、池、序列化)保持真實行為,既快又貼近真實調用路徑。
三、安裝與最小示例
npm i -D @elastic/elasticsearch-mock
npm i @elastic/elasticsearch
// test/info.mock.test.js —— 最小可運行示例(Node >= 16)
const { Client } = require('@elastic/elasticsearch')
const Mock = require('@elastic/elasticsearch-mock')const mock = new Mock()
const client = new Client({cloud: { id: '<cloud-id>' },auth: { apiKey: 'base64EncodedKey' },// 關鍵:用 mock 替換 ConnectionConnection: mock.getConnection()
})// 定義一個最簡單的路由:GET /
mock.add({ method: 'GET', path: '/' }, () => ({ status: 'ok' }));(async () => {const res = await client.info()console.log(res) // => { status: 'ok' }
})()
要點
mock.getConnection()
給出一個“假 HTTP”連接對象;- 之后所有請求都不會真正出網,速度與穩定性拉滿。
四、匹配策略:寬松 vs 嚴格
同一路徑,你可以按需要定義多條 mock,越具體的匹配優先生效。
// 寬松:只看 method + path
mock.add({method: 'POST',path: '/indexName/_search'
}, () => ({hits: { total: { value: 1, relation: 'eq' }, hits: [{ _source: { baz: 'faz' } }] }
}))// 嚴格:連 body 也要完全匹配(深度相等)
mock.add({method: 'POST',path: '/indexName/_search',body: { query: { match: { foo: 'bar' } } }
}, () => ({hits: { total: { value: 0, relation: 'eq' }, hits: [] }
}))
規則:更具體(帶 body 的)覆蓋更寬松的。這樣你能同時覆蓋“默認搜索”和“特定查詢”的兩種分支。
五、動態路徑與通配
// 動態段:/:index/_count
mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))await client.count({ index: 'foo' }) // { count: 42 }
await client.count({ index: 'bar' }) // { count: 42 }
// 也支持通配符(如需要匹配一批相近路徑)
六、讓你的代碼“經得起風浪”
編寫“隨機失敗/間歇性 500”的用例,檢驗重試和容錯是否健壯。
const { Client, errors } = require('@elastic/elasticsearch')mock.add({ method: 'GET', path: '/:index/_count' }, () => {if (Math.random() > 0.8) {// 方式 A(簡單):直接拋 JS Error(Transport 會當作失敗)const err = new Error('boom')err.statusCode = 500throw err// 方式 B(更貼近客戶端):拋客戶端的 ResponseError(不同版本構造略有差異)// throw new errors.ResponseError({ body: { error: 'fail' }, statusCode: 500 })}return { count: 42 }
})
提示:不同版本的
ResponseError
構造方式可能略有差異;如果不確定,拋普通 Error + 設置 statusCode 也能覆蓋你的重試/分支邏輯。
七、在 AVA 里寫測試(官方示例里的同款框架)
npm i -D ava
// test/search.ava.test.js
import test from 'ava'
import { Client } from '@elastic/elasticsearch'
import Mock from '@elastic/elasticsearch-mock'test('search: 默認與特定查詢兩條分支', async t => {const mock = new Mock()const client = new Client({ node: 'http://unit.test', Connection: mock.getConnection() })// 寬松分支mock.add({ method: 'POST', path: '/indexName/_search' }, () => ({hits: { total: { value: 1, relation: 'eq' }, hits: [{ _source: { baz: 'faz' } }] }}))// 嚴格分支(匹配 body)mock.add({method: 'POST',path: '/indexName/_search',body: { query: { match: { foo: 'bar' } } }}, () => ({ hits: { total: { value: 0, relation: 'eq' }, hits: [] } }))// 默認搜索const a = await client.search({ index: 'indexName', query: { match_all: {} } })t.is(a.hits.hits[0]._source.baz, 'faz')// 特定查詢const b = await client.search({ index: 'indexName', query: { match: { foo: 'bar' } } })t.is(b.hits.total.value, 0)
})
package.json
加腳本:
{"type": "module","scripts": { "test": "ava" }
}
八、在 Jest 里寫測試(更常用)
npm i -D jest @types/jest
(TS 需要再裝 ts-jest)
// test/count.jest.test.js
const { Client } = require('@elastic/elasticsearch')
const Mock = require('@elastic/elasticsearch-mock')describe('count API', () => {test('動態路徑 & 固定返回', async () => {const mock = new Mock()const client = new Client({ node: 'http://unit.test', Connection: mock.getConnection() })mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))await expect(client.count({ index: 'alpha' })).resolves.toEqual({ count: 42 })await expect(client.count({ index: 'beta' })).resolves.toEqual({ count: 42 })})
})
package.json
:
{"scripts": { "test": "jest" }
}
九、TypeScript 友好寫法
// test/info.ts
import { Client } from '@elastic/elasticsearch'
import Mock from '@elastic/elasticsearch-mock'const mock = new Mock()
const client = new Client({node: 'http://unit.test',Connection: mock.getConnection()
})mock.add({ method: 'GET', path: '/' }, () => ({ status: 'ok' }))export async function getInfo() {return client.info()
}
tsconfig.json
:確保 "moduleResolution": "node", "esModuleInterop": true
,Jest 用 ts-jest
即可。
十、進階手法
1) 校驗“我的代碼發出了期望的請求”
mock 的處理函數里可以檢查入參(如 body 中的 query/分頁條件),從而斷言業務層是否正確組織了請求。
mock.add({ method: 'POST', path: '/goods/_search' }, (params) => {// params.body 就是請求體if (params?.body?.size !== 10) throw new Error('page size must be 10')return { hits: { total: { value: 0, relation: 'eq' }, hits: [] } }
})
2) 順序響應(模擬滾動/重試)
同一路由注冊多次,按注冊順序命中,便于模擬“第一次失敗、第二次成功”的重試邏輯。
mock.add({ method: 'GET', path: '/:index/_count' }, () => { throw Object.assign(new Error('500'), { statusCode: 500 }) })
mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))
3) 與真實集成測試的分層配合
- 單元測試:mock Connection,覆蓋邊界條件/重試/錯誤處理分支。
- 集成測試:Docker 起一個真 ES(或 Testcontainers),驗證 mapping、腳本字段、聚合等“ES 自身語義”。
十一、最佳實踐與避坑清單
- 每個測試用例新建一個 mock 實例:避免跨用例狀態污染。
- 優先寫“寬松”匹配,再補“嚴格”匹配:覆蓋默認路徑后,針對關鍵分支加嚴格體檢。
- 特意寫失敗用例:5xx、超時、斷線,確保重試/回退策略真的在跑。
- 控制隨機性:用假隨機或 seed 固定,避免“隨機失敗”導致測試不穩定。
- 特征:ES 版本差異:個別客戶端版本對錯誤對象/響應包裝略有差異;若你要斷言錯誤類型,建議使用客戶端自帶的
errors.*
(或直接斷言statusCode
/name
/message
)。
十二、參考項目骨架(可抄)
your-project/
├─ src/
│ └─ search.js
├─ test/
│ ├─ info.mock.test.js
│ ├─ search.ava.test.js
│ └─ count.jest.test.js
├─ package.json
└─ tsconfig.json (若用 TS)
package.json
(混合 AVA/Jest 也沒問題):
{"type": "module","scripts": {"test": "jest && ava"},"devDependencies": {"@elastic/elasticsearch-mock": "^x.y.z","ava": "^x.y.z","jest": "^x.y.z"},"dependencies": {"@elastic/elasticsearch": "^x.y.z"}
}
十三、結語
- 把 Connection 換成 mock,你的測試就從“重集成”回到“輕單元”,速度與穩定性雙贏;
- 寬松 + 嚴格匹配、動態路徑/通配、失敗注入,能覆蓋絕大多數線上分支;
- 單測用 mock,回歸再配一小撮 Docker 集成測試做端到端兜底,是性價比最高的組合。
如果你愿意,我可以把上面 AVA/Jest/TS 的樣例整理成一個 examples/
目錄(含 package.json
、腳手架與說明),你直接 npm test
就能跑。需要我打包一下嗎?