mod 테스트 (Test a mod)
기준일: 2026-10-23
공식 기준: Test a mod
개요
mod용 자동 테스트를 작성하고 셸에서 claude plugin test로 실행할 수 있습니다. 테스트는 hook이 처리하는 이벤트를 발생시키고 hook이 한 일을 확인하므로, 문제가 세션에 닿기 전에 잡을 수 있습니다. 첫 예제는 mod 만들기의 mod를 테스트합니다.
핵심 개념
| 개념 | 설명 |
|---|---|
| 테스트 키트 | claude-code/testing 모듈의 테스트 라이브러리. test, expect, mock, tier를 export |
테스트의 $ |
Claude Code 역할. 메서드마다 같은 이름의 이벤트를 발생시켜 mod의 hook에 통과시킴. hook이 받는 mods API와 다름 |
on |
stub(Claude Code 대신 답하는 hook)을 등록 |
| stub | 모델·저장소·도구가 돌지 않는 테스트에서 mod가 기대하는 응답을 대신 제공 |
$.ui.mount |
render site를 mod의 ui.render hook으로 그리고 press·input·select·find 핸들을 반환 |
상세
테스트 작성 (Write a test)
테스트는 mod를 로드하고, Claude Code처럼 이벤트를 hook에 보내고, hook이 한 일을 확인합니다. 세션, 로그인, 네트워크가 필요 없습니다. 셸에서 claude plugin test로 실행하며, 각 테스트 파일은 테스트 키트를 import합니다.
- 테스트 파일 이름은
first-mod.test.ts처럼.test.ts로 끝나야 하며 플러그인 디렉터리 어디에 둬도 됩니다. - 모든 테스트 파일에는
test()가 하나 이상 있어야 합니다. 없으면declares no test(): nothing ran으로 실행이 실패합니다. - 테스트 파일은 mod 자체 파일과 옆의
.ts헬퍼를 import할 수 있어, 게임 규칙 같은 일반 함수는 키트 없이 단위 테스트할 수 있습니다.
다음 테스트는 도구 호출 두 개를 발생시키고 mod 만들기의 /tally 명령을 실행해 응답이 둘 다 세는지 확인합니다. 첫 줄은 Claude Code 대신 도구 호출에 답하는 stub입니다. first-mod/tests/first-mod.test.ts로 저장합니다.
// first-mod/tests/first-mod.test.ts
import { expect, test } from 'claude-code/testing'
test('/tally reports the tool calls the mod has seen', async ($, on) => {
// Answer each tool call in Claude Code's place, so no tool runs
on('tool.call', () => ({ result: 'ok' }))
// Fire two tool calls, which the mod's tool.call hook counts
await $.tool.call({ tool: 'Bash', command: 'ls' })
await $.tool.call({ tool: 'Read', file_path: 'README.md' })
// Run /tally and check the text its hook returns
const answer = await $.command.run({ command: 'tally', args: '' })
expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})
first-mod 디렉터리에서 실행합니다.
claude plugin test
출력은 각 테스트와 통과 여부를 보여 줍니다(시간은 실행마다 다름).
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [22.87ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.19s]
- 각
$.tool.call은 mod의tool.callhook을 거쳐 카운트를 1 올리고 stub으로 넘어갔습니다.ls는 실행되지 않았고 파일도 읽히지 않았습니다. $.command.run은 mod의command.runhook으로 갔고,answer는 그 hook이 반환한 객체입니다.- 테스트가 실패하면 상태 코드 1로 종료하므로 CI에서 쓸 수 있습니다.
- 그 셸에서 내 mod가 로드될 수 없으면
claude plugin test: hooks modules are turned off로 시작하는 줄과 이유를 출력하고 상태 코드 1로 종료합니다.
Claude Code가 답할 내용 stub하기 (Stub what Claude Code would answer)
테스트에서는 모델, 저장소, 도구가 돌지 않으므로 mod가 Claude Code의 답을 기대하는 곳마다 테스트가 stub으로 답을 제공합니다. 테스트 함수는 이를 위해 두 인자를 받습니다.
$: Claude Code 역할을 하는 테스트 자신의$. hook이 받는 mods API가 아닙니다. 각 메서드는 같은 이름의 이벤트를 발생시켜 mod의 hook에 보내고 결과로 resolve합니다.$.tool.call({ tool: 'Bash', command: 'ls' })는tool.call을 발생시킵니다.$.command.run,$.prompt.submit,$.session.start,$.turn.complete도 같고,$.classic.Stop등$.classic메서드는 settings hook 이벤트를 발생시킵니다.ui.close같은 mods API 호출은 테스트가 직접 발생시킬 수 없으므로, pane을 닫는 버튼을 누르는 식으로 mod를 통해 일으킵니다.on: stub을 등록합니다. mods API 호출용 stub은$.없이 이름을 붙입니다.store.get으로 등록한 stub은 mod의$.store.get에 답합니다. mod가$.model.complete나$.store.get을 호출하면 stub이 답을 제공합니다.
다음 예제는 모델 호출을 stub합니다. hook은 grader라는 mod에 속하며, 문장을 모델에 보내고 응답이 PASS로 시작하는지 보고하는 /grade 명령을 처리합니다. 파일에는 테스트 대상 hook만 있으므로 mod에는 mod 만들기처럼 plugin.json과 hooks.json도 필요합니다. 세션에서 /grade를 입력하려면 명령 등록도 해야 합니다.
// grader/hooks/register.js
export function register(on) {
on('command.run', { command: 'grade' }, async ($, e) => {
// e.args is the text typed after /grade
const reply = await $.model.complete({
model: 'haiku',
system: 'Grade the sentence. Start your reply with PASS or FAIL.',
prompt: e.args,
})
const passed = reply.isAnswered && reply.text.startsWith('PASS')
return { text: passed ? 'Passed' : 'Try again' }
})
}
이 테스트는 모델 호출을 stub해 통과 응답일 때 hook의 동작을 확인합니다.
// grader/tests/grader.test.ts
import { expect, test } from 'claude-code/testing'
test('a passing grade is reported', async ($, on) => {
// Answer the mod's $.model.complete call with a fixed reply, so no model runs
on('model.complete', () => ({
value: {
isAnswered: true,
text: 'PASS\nNice sentence.',
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
},
}))
// Run /grade, which makes the mod call the model
const answer = await $.command.run({ command: 'grade', args: 'The cat sat on the mat.' })
expect(answer.text).toBe('Passed')
})
hook의 reply가 value 아래 객체이고 그 text가 PASS로 시작하므로 테스트가 통과합니다. 다른 분기를 확인하려면 FAIL로 시작하는 text를 반환하는 stub으로 두 번째 테스트를 추가하고 Try again을 기대하세요.
stub 반환 형태:
- mods API 호출용 stub은
value필드를 가진 객체를 반환하며, 그 값이 mod에서 호출이 resolve하는 값입니다.{ value: 7 }이면$.store.get이7로 resolve합니다. turn.step이나tool.call같은 Claude Code 이벤트용 stub은 그 이벤트 자체의 결과(예:{ result: 'ok' })를 반환합니다.$.session.send와$.prompt.fill도 아래 표처럼 이벤트의 결과를 받습니다.
다음 오류는 stub이 잘못됐거나 없다는 뜻입니다. 실패한 테스트 출력에는 the engine reported:로 시작하는 블록이 있고 각 오류가 거기 나옵니다.
returned neither { value } nor { deny }: mods API 호출용 stub이 bare value를 반환해 테스트가 실패no implementation for+ 이름: mod가 그 호출을 했는데 답하는 stub이 없음
키트는 clock, store, 환경 변수, 대화에 추가된 행용 기성 mock도 export합니다.
mock.clock(on):$.clock에 답하고, 테스트가 앞으로 돌리는 mock clock을 반환합니다. 타이머 테스트가 기다리지 않아도 됩니다.mock.store(on, { count: 7 }): 그 항목들로 시작하는 store로$.store에 답합니다. 아무것도 반환하지 않으므로, mod가 저장한 것을 확인하려면 아래 그림 테스트처럼storestub 두 개를 직접 작성하세요.mock.env(on, { CI: 'true' }): 그 변수들로$.env.get에 답합니다.mock.session(on): mock session을 반환하며, 그appended()메서드는 mod가$.session.append로 추가한 행을 오래된 순으로 나열합니다. Claude Code v2.1.293 이상 필요합니다.
테스트 키트 규칙 (Follow the test kit's rules)
테스트 키트에는 자체 규칙이 있고, 어기면 처음 테스트를 쓰는 사람이 가장 먼저 만나는 오류가 납니다.
모든 stub은 테스트가
$를 처음 호출하기 전에 등록합니다. 그 뒤에on을 호출하면on("ui.render") after the test first called $같은 오류가 발생합니다.session.start는 저절로 실행되지 않습니다. 각 테스트는 module이 새로 로드되고 hook이 하나도 호출되지 않은 상태로 시작하므로 module 수준 변수는 초기값입니다. hook이session.start가 설정한 것에 의존하면 먼저 발생시키세요.// Answer the event after your hook passes it on with next(e) on('session.start', () => ({ cwd: '/work' })) // Answer the $.command.register call your hook makes on('command.register', () => ({ value: undefined })) // Fire the event, which runs your session.start hook await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })두 번째 stub은 튜토리얼의
session.starthook이 하는$.command.register호출에 답합니다. 없으면 그 호출이no implementation for command.register로 reject되고 키트가 hook을 건너뛰어, 호출 뒤의 hook 코드가 실행되지 않습니다. 그 시점에 테스트가 실패하지는 않으며, 건너뛴 hook은 나중 검사가 실패할 때만the engine reported:아래에 나옵니다.next(e)를 반환하는 hook에는 답할 stub이 필요합니다.ui.renderhook이 예를 들어 Claude가 idle일 때 아무것도 그리지 않으려고next(e)를 반환하면, 아래 그림 테스트의 mount가no implementation for ui.render로 실패합니다. 요소를 plain data로 반환하는 stub을 등록하세요.// Stands for what Claude Code would draw at the site on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))stub을 등록하면 mount가 성공하고, hook이
next(e)를 반환했을 때ui.find({ type: 'Text' })가 그 요소를 반환합니다.turn.stepstub은 async generator이며, 테스트가 stream을 끝까지 읽어야 결과를 얻습니다.on('turn.step', async function* ($, e) { // Each yield is one piece of the model's streamed reply yield { kind: 'text', index: 0, text: 'ok' } // The return value is the result of the whole request return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null } }) // Fire one request to the model, which runs your turn.step hook const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 }) // Read every piece until the stream says it's done let step = await stream.next() while (step.done !== true) step = await stream.next() const result = step.value루프가 끝나면
result는 stub이 반환한 객체이며, 내turn.stephook이 바꿀 기회를 거친 뒤의 값입니다. 여기서result.answer는'ok'입니다.도구 호출은 도구 이름과 인자를 필드로 넣어 발생시킵니다. 예:
await $.tool.call({ tool: 'Bash', command: 'ls' }). 그리고{ result }를 반환하는tool.callstub을 등록합니다.
stub이 반환할 값 찾기 (Look up what a stub returns)
테스트에서 mod가 하는 모든 mods API 호출에는 Claude Code 대신 답하는 stub이 필요합니다. 예외는 키트가 직접 답하는 $.ui.invalidate, $.state, $.session.append 호출뿐입니다. $.clock 호출에는 mock.clock(on)을 쓰세요. 그렇지 않으면 mod의 $.clock.now()가 no implementation for clock.now로 실패합니다.
자주 쓰는 것을 정리한 표입니다. 첫 열은 mod가 하는 호출 또는 next(e)로 넘기는 이벤트, 둘째 열은 그 이름으로 on에 넘길 함수입니다. 예를 들어 $.store.get 행은 on('store.get', ($, e) => ({ value: saved.get(e.key) }))가 됩니다. stub의 '...'는 직접 채울 텍스트입니다.
| mod가 호출하거나 넘기는 것 | Stub |
|---|---|
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set |
() => ({ value: undefined }). ui.toast와 ui.log의 텍스트는 e.text |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path는 절대 경로로 오므로 endsWith로 비교 |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
질문이 AskUserQuestion 도구 호출로 도착하므로 tool.call stub: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). mod가 다른 도구 호출도 넘긴다면 먼저 e.tool을 확인 |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv는 인자 목록, e.init에는 cwd와 timeoutMs |
| 실패해야 하는 mods API 호출 | () => ({ deny: 'the reason' }). mod에서 호출이 reject됨. throw하는 stub은 대신 건너뜀 |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' }). $.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null })로 발생시킴 |
prompt.submit |
($, e) => ({ text: e.text }) |
prompt.fill |
() => ({ isFilled: true }) |
$.prompt.read |
() => ({ value: { text: '...', cursor: 0 } }) |
$.ui.copy |
() => ({ value: { isCopied: true } }) |
$.ui.notify |
() => ({ value: { isSent: true, channel: 'terminal_bell' } }). 텍스트는 e.text, 제목은 e.title |
$.session.messages |
() => ({ value: [{ role: 'assistant', text: '...', toolUses: [] }] }) |
$.session.id, $.agent.list |
() => ({ value: 'abc123' }), () => ({ value: [] }) |
session.send |
() => ({ isDelivered: true }). mod가 { sessionId }를 넘겨도 e.to는 문자열로 옴 |
session.receive |
($, e) => ({ text: e.text }). $.session.receive({ origin: { kind: 'peer-send-message' }, text })로 발생시킴 |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect의 assertion은toBe,toEqual,toMatch,toMatchObject,toContain,toBeDefined,toBeUndefined,toThrow이며, 모두 앞에.not을 붙일 수 있습니다.- async generator가 아닌 일반 함수로
on에 넘긴 stub이나 hook 안에서expect가 실패하면 테스트가 실패합니다. 엔진은 그 hook을 건너뛰고, 실패 출력에in the test's store.set hook처럼 이름이 나옵니다.
타이머 테스트 (Test a timer)
타이머로 작업하는 mod는 테스트가 제어하는 clock이 필요합니다. 그래야 기다리지 않고 시간을 앞으로 돌릴 수 있습니다. const clock = mock.clock(on)은 0에서 시작해 테스트가 움직일 때만 움직이는 mock clock을 반환합니다. 다른 시각에서 시작하려면 mock.clock(on, { now: 5000 })처럼 밀리초로 넘깁니다.
| 메서드 | 동작 |
|---|---|
await clock.advance(1000) |
그 밀리초만큼 시간을 앞당기고 만기된 타이머를 실행 |
await clock.set(5000) |
advance처럼 그 값까지 시간을 앞당김 |
clock.now() |
현재 시각 반환. mod의 $.clock.now()가 resolve하는 값 |
await clock.settle() |
연쇄된 0ms $.clock.after 호출처럼 이미 만기된 타이머를 시간 이동 없이 실행 |
await clock.sleep(2000) |
stub 안에서 쓰면 테스트가 그만큼 시간을 진행시킨 뒤에야 stub이 답함. 느린 모델이나 프로세스를 흉내 낼 때 사용 |
다음 hook은 countdown이라는 mod에 속하며, 초 단위 숫자를 받아 1초 $.clock.every 타이머를 시작하고 0이 되면 toast를 띄우는 /countdown 명령을 처리합니다. grader처럼 파일에는 테스트 대상 hook만 있고 명령 등록은 하지 않습니다.
// countdown/hooks/register.js
export function register(on) {
on('command.run', { command: 'countdown' }, async ($, e) => {
// e.args is the text typed after /countdown
let left = Number(e.args)
const timer = $.clock.every(1000, () => {
left -= 1
if (left === 0) {
timer.cancel()
$.ui.toast('Time is up')
}
})
// Print nothing in the transcript
return {}
})
}
이 테스트는 /countdown 3을 실행하고 mock clock을 움직여, 3초를 기다리지 않고 3초 동안의 동작을 확인합니다.
// countdown/tests/countdown.test.ts
import { expect, mock, test } from 'claude-code/testing'
test('the countdown ends with a toast', async ($, on) => {
// Answer every $.clock call from a clock the test controls
const clock = mock.clock(on)
// Collect the text of each toast the mod shows
const toasts: string[] = []
on('ui.toast', ($, e) => {
toasts.push(e.text)
return { value: undefined }
})
await $.command.run({ command: 'countdown', args: '3' })
// After two seconds the timer has fired twice, and no toast is due
await clock.advance(2000)
expect(toasts).toEqual([])
// The third second brings the count to zero
await clock.advance(1000)
expect(toasts).toEqual(['Time is up'])
})
첫 expect는 toast가 일찍 나오지 않음을, 둘째는 한 번 나옴을 보여 줍니다. 각 advance는 만기된 타이머가 실행된 뒤 resolve하므로 다음 줄의 검사가 그 효과를 봅니다.
그림 테스트 (Test a drawing)
테스트는 mod의 render site를 그리고, 그려진 요소를 누르고, 입력하고, 찾을 수 있습니다. $.ui.mount는 mod의 ui.render hook으로 site를 그리고 각 동작용 메서드를 가진 핸들을 반환합니다. 한 테스트에서 여러 앱을 다루려면 surface를 그릴 앱으로 설정합니다. 다음 테스트는 Build a pane with tabs의 pane을 열고, 탭을 바꾸고, 버튼을 누르고, 터미널과 Desktop 앱에서 카운트를 확인합니다.
// hello-tabs/tests/hello-tabs.test.ts
import { expect, test } from 'claude-code/testing'
// What Claude Code passes to a ui.render hook for this pane, apart from the app
const PANE = {
plugin: 'hello-tabs',
component: 'Pane',
requestId: 'hello-tabs',
viewport: { columns: 100, rows: 30 },
props: {
title: 'Hello tabs',
isFocused: true,
bodyColumns: 60,
placement: 'inline',
scroll: { offset: 0, bodyRows: 10 },
view: {},
},
} as const
test('the second tab counts presses and saves the count', async ($, on) => {
// Stub $.store with a Map, so the test can read what the mod saved
const saved = new Map<string, unknown>()
on('store.get', ($, e) => ({ value: saved.get(e.key) }))
on('store.set', ($, e) => {
saved.set(e.key, e.value)
return { value: undefined }
})
// Draw the pane once for each app
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
// Press the buttons by the key the mod gave them
await ui.press({ key: 'tab-two' })
await ui.press({ key: 'more' })
// The second tab's count line is in the drawing
expect(await ui.find({ type: 'Text', text: /^Count: \d+$/ })).toBeDefined()
await ui.unmount()
}
// One press in each app makes two
expect(saved.get('count')).toBe(2)
})
hello-tabs 디렉터리에서 claude plugin test를 실행합니다. 두 앱 모두 카운트 줄을 그리고 mod가 2를 저장하면 통과합니다. 두 mount가 같은 로드된 module을 쓰므로 카운트가 첫 앱에서 둘째 앱으로 이어집니다.
$.ui.mount가 반환하는 핸들의 메서드는 요소에 준 key로 대상을 지정합니다.
| 메서드 | 동작 |
|---|---|
press({ key: 'more' }) |
그 key의 Button을 누름 |
input({ key: 'new-note', text: 'buy milk' }) |
그 key의 Input에 텍스트를 입력하고 Enter. 제출 없이 입력만 하려면 kind: 'change' 추가 |
select({ key: 'size', value: 'large' }) |
그 key의 Select에서 그 value의 옵션을 고름 |
find({ key: 'more' }) 또는 find({ type: 'Text', text: 'Count: 2' }) |
처음 일치하는 요소를 { type, props, children }로 반환, 없으면 undefined. text는 문자열 또는 정규식 |
unmount() |
그림을 제거 |
- 각 메서드는 핸들러가 끝난 뒤 resolve하므로 다음 줄에서 결과를 확인할 수 있습니다.
props는 Claude Code가 그 site에 넘길 값으로 설정하세요. 각 site의 props는 render sites 표에, 타입은 내 빌드의 types에 있습니다.- 그림 테스트는 hook이 반환한 트리와 그 앱에서의 유효성을 확인할 뿐, 앱이 실제로 어떻게 칠하는지는 확인하지 않습니다. 새 레이아웃은 실제 세션에서도 보세요.
/clear 뒤의 그림 테스트 (Test a drawing after /clear)
각 테스트는 모든 $.state 값이 기본값인 상태로 시작하며, 이는 /clear가 남기는 상태와 같습니다. 그다음 mod의 동작을 테스트하려면 session.start를 건너뛰고, source: 'clear'로 classic.SessionStart를 발생시킨 뒤 mod가 그리는 것을 확인합니다.
이 테스트는 Load a saved value again after /clear의 module을 확인합니다. PANE이 정의된 위 파일에 추가합니다. 그 파일의 첫 테스트는 Save from more than one session의 버튼처럼 버튼이 카운트를 저장한다고 기대합니다.
// hello-tabs/tests/hello-tabs.test.ts
test('the saved count comes back after /clear', async ($, on) => {
// The store already holds a count of 7
on('store.get', () => ({ value: 7 }))
// Answer the event after your hook passes it on with next(e)
on('classic.SessionStart', () => ({}))
// Fire the event that follows /clear, which runs your hook
await $.classic.SessionStart({ source: 'clear' })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: 'tab-two' })
// The pane shows the stored count, not the default of 0
expect(await ui.find({ type: 'Text', text: 'Count: 7' })).toBeDefined()
})
classic.SessionStart hook이 pane을 그리기 전에 저장된 7을 $.state로 복사했으면 통과합니다. module에 그 hook이 없으면 pane이 Count: 0을 그리고, find가 undefined를 반환해 toBeDefined에서 실패합니다.
정책 mod 테스트 (Test a policy mod)
조직이 prependPlugins에 넣은 mod는 다른 mod를 로드 전에 거부할 수 있습니다. 이를 테스트하려면 내 mod의 tier를 정하고, 허용하거나 거부할 두 번째 mod를 테스트에 줍니다.
tier: 테스트 파일 맨 위에서tier('prepend')처럼 한 번 호출해 내 mod를prepend,append,builtin으로 로드합니다(mod 실행 순서의 위치). 없으면user로 로드됩니다.plugins: 테스트 본문 앞에 옵션 객체를test에 넘깁니다. 그plugins배열에는name과register함수를 가진 인라인 mod를 넣습니다.user가 아닌 곳에 로드하려면 그 mod에tier를 추가합니다.
이 테스트 파일은 admin 페이지의 정책 mod를 먼저 로드하고, 프로세스를 시작하는 mod는 거부하고 시작하지 않는 mod는 허용하는지 확인합니다.
// acme-guard/tests/guard.test.ts
import { expect, test, tier } from 'claude-code/testing'
// Load the mod under test ahead of every other mod
tier('prepend')
// A second mod whose code calls $.process.run, which the policy blocks
const runner = {
name: 'runner',
register(on) {
on('tool.call', async ($, e, next) => {
await $.process.run(['ls'])
return { result: 'runner answered' }
})
},
}
// A second mod that calls nothing the policy blocks
const reader = {
name: 'reader',
register(on) {
on('tool.call', async ($, e, next) => {
return { result: 'reader answered' }
})
},
}
test('refuses a mod that starts a process', { plugins: [runner] }, async ($, on) => {
on('tool.call', () => ({ result: 'claude code answered' }))
let message = ''
try {
// The first call on $ loads the mods, so the refusal is thrown here
await $.tool.call({ tool: 'Bash', command: 'ls' })
} catch (error) {
message = error.message
}
expect(message).toBe('runner: refused by acme-guard: Acme policy: mods may not call process.run')
})
test('admits a mod that starts no process', { plugins: [reader] }, async ($, on) => {
on('tool.call', () => ({ result: 'claude code answered' }))
const out = await $.tool.call({ tool: 'Bash', command: 'ls' })
// The answer comes from reader, which shows that it loaded
expect(out).toEqual({ result: 'reader answered' })
})
acme-guard 디렉터리에서 claude plugin test를 실행하면 admin 페이지에 나온 정책 mod로 두 테스트 모두 통과합니다.
- 키트는 테스트가
$를 처음 호출할 때 모든 mod를 로드합니다. 내 mod가 하나를 거부하면 그 호출이 throw하고, 메시지에는 거부된 mod, 거부한 mod, 내 reason이 들어갑니다. - 둘째 테스트에서는 아무것도 거부되지 않으므로
reader가 stub보다 먼저 도구 호출에 답합니다.
체크리스트
- 테스트 파일 이름이
.test.ts로 끝나고test()가 하나 이상 있다. - 모든 stub을
$의 첫 호출 전에 등록했다. - mods API 호출용 stub은
{ value }(또는{ deny })를, 이벤트용 stub은 그 이벤트의 결과를 반환한다. - hook이
session.start에 의존하면 테스트에서 먼저 발생시키고command.register등 그 안의 호출도 stub했다. -
next(e)를 반환하는ui.renderhook용 stub을 등록했다. -
$.clock을 쓰는 mod는mock.clock(on)으로 시간을 제어한다. - 그림 테스트 결과와 별개로 실제 세션에서도 레이아웃을 확인했다.
- CI에서
claude plugin test의 종료 코드 1을 실패로 처리한다.
다음 단계
기준일
이 문서는 2026-10-23에 공식 Test a mod 페이지를 기준으로 작성했습니다. 테스트 키트 API는 설치한 버전의 types를 함께 확인하세요.