SpyBara
Go Premium

plugins/mods/test.md 2026-10-01 23:59 UTC to 2026-10-02 19:58 UTC

This page contains 80 additions and 80 deletions.

2026
Thu 1 23:59 Fri 2 19:58

mod 테스트하기

세션, 로그인, 네트워크 없이 이벤트를 발생시키고, Claude Code의 응답을 스텁으로 대체하고, 버튼을 누르는 Claude Code mod용 자동화 테스트를 작성합니다.

mod용 자동화 테스트를 작성하고 claude plugin test로 셸에서 실행할 수 있습니다. 테스트는 훅이 처리하는 이벤트를 발생시키고 훅이 수행한 작업을 확인하므로, 문제가 세션에 도달하기 전에 발견할 수 있습니다. 첫 번째 예제는 mod 만들기의 mod를 테스트합니다.

테스트 작성하기

테스트는 mod를 로드하고, Claude Code가 하는 방식 그대로 mod의 훅에 이벤트를 보낸 다음, 훅이 무엇을 했는지 확인합니다. 이 과정에는 세션, 로그인, 네트워크가 필요하지 않습니다. 테스트는 셸에서 claude plugin test로 실행하며, 각 테스트 파일은 claude-code/testing 모듈에 있는 테스트 라이브러리인 테스트 키트를 가져옵니다.

각 테스트 파일의 이름은 first-mod.test.ts처럼 .test.ts로 끝나야 하며, 플러그인 디렉터리 안 어디에든 저장할 수 있습니다. 모든 테스트 파일에는 test()가 하나 이상 있어야 하며, 그렇지 않으면 declares no test(): nothing ran 오류와 함께 실행이 실패합니다. 테스트 파일은 mod 자체의 파일과 같은 위치의 .ts 헬퍼를 가져올 수 있으므로, 게임 규칙 같은 일반 함수는 키트 없이 단위 테스트할 수 있습니다.

다음 테스트는 도구 호출 두 개를 발생시키고, mod 만들기의 /tally 명령을 실행한 다음, 응답이 두 호출을 모두 세는지 확인합니다. 첫 줄은 Claude Code 대신 도구 호출에 응답하는 스텁입니다. 이 파일을 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.call 훅을 거쳤고, 이 훅은 카운트를 하나 늘린 뒤 호출을 스텁으로 넘겼습니다. ls는 실행되지 않았고 파일도 읽히지 않았습니다. 이어서 $.command.run은 mod의 command.run 훅으로 전달되었으며, answer는 그 훅이 반환한 객체입니다.

이 명령은 테스트가 실패하면 상태 코드 1로 종료되므로 CI에서도 사용할 수 있습니다. 명령을 실행하는 셸에서 사용자 자신의 mod를 로드할 수 없으면, claude plugin test: hooks modules are turned off로 시작하는 줄과 그 이유를 출력하고 상태 코드 1로 종료됩니다.

Claude Code가 응답할 내용을 스텁으로 제공하기

테스트에서는 모델, 저장소, 도구가 실행되지 않으므로, mod가 Claude Code의 응답을 기대하는 모든 곳에서 테스트가 스텁으로 응답을 제공합니다. 이를 위해 테스트 함수는 두 개의 인수를 받습니다.

  • $: Claude Code 역할을 하는 테스트 자체의 $입니다. 훅이 받는 mods API가 아닙니다. 각 메서드는 같은 이름의 이벤트를 발생시켜 mod의 훅으로 보내고, 그 결과로 resolve됩니다. 예를 들어 $.tool.call({ tool: 'Bash', command: 'ls' })는 tool.call을 발생시킵니다. $.command.run, $.prompt.submit, $.session.start, $.turn.complete도 같은 방식으로 동작하며, $.classic.Stop을 비롯한 $.classic 메서드는 설정 훅 이벤트를 발생시킵니다. 테스트는 ui.close 같은 mods API 호출을 직접 발생시킬 수 없습니다. 창을 닫는 버튼을 누르는 식으로 mod를 통해 트리거해야 합니다.
  • on: 스텁을 등록할 때 호출합니다. 스텁은 Claude Code 대신 응답하는 훅입니다. mods API 호출에 대한 스텁은 $. 없이 이름을 지정하므로, store.get으로 등록한 스텁은 mod의 $.store.get에 응답합니다. mod가 $.model.complete나 $.store.get을 호출하면 스텁이 응답을 제공합니다.

다음 예시는 모델 호출을 스텁으로 처리합니다. 이 훅은 grader라는 mod에 속하며, 문장을 모델에 보내고 응답이 PASS로 시작하는지 보고하는 /grade 명령을 처리합니다. 이 파일에는 테스트 대상 훅만 들어 있으므로, mod에는 mod 만들기에서처럼 plugin.json과 hooks.json도 필요합니다. 세션에서 /grade를 입력하려면 mod가 명령도 등록해야 합니다.

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' }
  })
}

다음 테스트는 모델 호출을 스텁으로 처리하여, 통과 응답을 받았을 때 훅이 무엇을 하는지 확인합니다.

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')
})

훅의 reply가 value 아래의 객체이고 그 text가 PASS로 시작하므로 테스트가 통과합니다. 다른 경우를 확인하려면 스텁이 FAIL로 시작하는 text를 반환하는 두 번째 테스트를 추가하고 Try again을 기대값으로 지정합니다.

mods API 호출에 대한 스텁은 value 필드가 있는 객체를 반환하며, 이 필드에는 mod에서 해당 호출이 resolve될 값이 들어갑니다. 예를 들어 { value: 7 }은 $.store.get이 7로 resolve되게 합니다. turn.step이나 tool.call 같은 Claude Code 이벤트에 대한 스텁은 { result: 'ok' }처럼 해당 이벤트 자체의 결과를 반환합니다. 표에서 보듯이 $.session.send와 $.prompt.fill도 해당 이벤트의 결과를 받습니다. 자주 쓰이는 각 이름이 어떤 형식을 따르는지는 스텁이 반환하는 값 찾아보기에서 확인할 수 있습니다. 다음 오류는 스텁이 잘못되었거나 누락되었다는 뜻입니다. 실패한 테스트의 출력에는 the engine reported:로 시작하는 블록이 포함되며, 각 오류가 그 안에 표시됩니다.

  • returned neither { value } nor { deny }: mods API 호출에 대한 스텁이 값을 그대로 반환했습니다
  • no implementation for 뒤에 이름이 오는 경우: mod가 해당 호출을 했지만 응답하는 스텁이 없습니다

키트는 네임스페이스 전체에 대신 응답하는 인메모리 mock도 내보냅니다. mock.clock(on)은 $.clock에 응답하고, mock.store(on, { count: 7 })는 지정한 항목으로 시작하는 저장소에서 $.store에 응답하며, mock.env(on, { CI: 'true' })는 지정한 변수에서 $.env.get에 응답합니다. mock.clock은 테스트가 시간을 앞당길 수 있는 mock 시계를 반환하므로, 타이머 테스트가 기다릴 필요가 없습니다. mock.store는 아무것도 반환하지 않으므로, mod가 무엇을 저장했는지 확인하려면 그리기 테스트처럼 두 개의 store 스텁을 직접 작성해야 합니다.

테스트 키트의 규칙 따르기

테스트 키트에는 자체 규칙이 몇 가지 있으며, 이를 어기면 테스트를 처음 작성하는 사용자가 가장 먼저 마주치는 오류가 발생합니다.

  • 테스트에서 $를 처음 호출하기 전에 모든 스텁을 등록합니다. 그 이후에 on을 호출하면 on("ui.render") after the test first called $ 같은 오류가 발생합니다.

  • session.start는 자동으로 실행되지 않습니다. 각 테스트는 모듈이 새로 로드되고 어떤 훅도 호출되지 않은 상태로 시작하므로, 모듈 수준 변수는 초기값을 유지합니다. 훅이 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' })
    

    두 번째 스텁은 튜토리얼의 훅 같은 session.start 훅이 하는 $.command.register 호출에 응답합니다. 이 스텁이 없으면 해당 호출이 no implementation for command.register로 reject되고 키트가 훅을 건너뛰므로, 훅에서 그 호출 이후의 내용은 실행되지 않습니다. 이 시점에 테스트가 실패하지는 않습니다. 건너뛴 훅은 이후의 검사가 실패할 때만 the engine reported: 아래에 표시됩니다.

  • next(e)를 반환하는 훅에는 응답할 스텁이 필요합니다. 예를 들어 Claude가 유휴 상태일 때 아무것도 그리지 않도록 ui.render 훅이 next(e)를 반환하면, 마운트가 no implementation for ui.render로 실패합니다. 요소를 일반 데이터로 반환하는 스텁을 등록합니다.

    // Stands for what Claude Code would draw at the site
    on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))
    

    스텁을 등록하면 마운트가 성공하며, 훅이 next(e)를 반환할 때마다 ui.find({ type: 'Text' })가 그 요소를 반환합니다.

  • turn.step에 대한 스텁은 비동기 제너레이터이며, 테스트는 결과를 얻기 위해 스트림을 끝까지 읽습니다.

    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는 스텁이 반환한 객체이며, turn.step 훅이 이를 변경할 기회를 가진 뒤의 값입니다. 여기서 result.answer는 'ok'입니다.

  • 도구 호출은 도구 이름과 인수를 필드로 지정하여 발생시킵니다. 예를 들어 await $.tool.call({ tool: 'Bash', command: 'ls' })처럼 작성하고, { result }를 반환하는 tool.call 스텁을 등록합니다.

스텁이 반환하는 값 찾아보기

테스트에서 mod가 하는 모든 mods API 호출에는 Claude Code 대신 응답하는 스텁이 필요합니다. 단, 키트가 직접 응답하는 몇 가지, 즉 $.ui.invalidate와 $.state 호출은 예외입니다. $.clock 호출에는 mock.clock(on)을 사용해야 하며, 그렇지 않으면 mod의 $.clock.now()가 no implementation for clock.now로 실패합니다.

다음 표에는 mod가 가장 많이 사용하는 항목이 나와 있습니다. 첫 번째 열은 mod가 하는 호출 또는 next(e)로 넘기는 이벤트입니다. 두 번째 열은 그 이름으로 on에 전달할 함수이므로, $.store.get 행은 on('store.get', ($, e) => ({ value: saved.get(e.key) }))이 됩니다. 스텁의 '...'은 사용자가 채울 텍스트를 나타냅니다.

mod가 호출하거나 넘기는 항목 스텁
$.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 스텁을 사용합니다: ($, 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됩니다. 예외를 던지는 스텁은 대신 건너뜁니다.
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 } })
$.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에는 toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, toThrow 어설션이 있으며, 이들 앞에 .not을 붙일 수 있습니다.

타이머 테스트하기

타이머로 작업을 실행하는 mod에는 테스트가 제어하는 시계가 필요합니다. 그래야 테스트가 기다리는 대신 시간을 앞으로 이동시킬 수 있습니다. const clock = mock.clock(on)은 0에서 시작하고 테스트가 이동시킬 때만 움직이는 모의 시계를 반환합니다. 다른 시간에서 시작하려면 mock.clock(on, { now: 5000 })처럼 밀리초 단위로 전달합니다. 시계에는 다음 메서드가 있습니다.

메서드 동작
await clock.advance(1000) 지정한 밀리초만큼 시간을 앞으로 이동하고 기한이 도래한 각 타이머를 실행합니다
await clock.set(5000) advance와 마찬가지로 시간을 해당 값까지 앞으로 이동합니다
clock.now() 시간을 반환하며, 이 값이 mod의 $.clock.now()가 반환하는 값입니다
await clock.settle() 시간을 이동하지 않고, 지연 시간이 0인 $.clock.after 호출 체인처럼 이미 기한이 도래한 타이머를 실행합니다
await clock.sleep(2000) 스텁 내부에서 사용하면 테스트가 그만큼 시간을 진행한 뒤에만 해당 스텁이 응답하도록 합니다. 느린 모델이나 프로세스를 시뮬레이션하는 방법입니다

이 훅은 countdown이라는 mod에 속하며, 초 단위 숫자를 받아 1초 간격의 $.clock.every 타이머를 시작하고 0이 되면 토스트를 표시하는 /countdown 명령을 처리합니다. grader와 마찬가지로 이 파일에는 테스트 대상 훅만 들어 있으며 명령을 등록하지는 않습니다.

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을 실행하고 모의 시계를 이동시키므로, 3초를 기다리지 않고도 3초 동안의 동작을 확인합니다.

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는 토스트가 일찍 표시되지 않음을 보여 주고, 두 번째 expect는 토스트가 한 번 표시됨을 보여 줍니다. 각 advance는 기한이 도래한 타이머가 실행된 후에 완료되므로, 다음 줄의 확인에서 그 결과를 볼 수 있습니다.

그리기 테스트

테스트는 mod의 렌더링 지점 중 하나를 그린 다음, 그린 요소를 누르고, 요소에 입력하고, 요소를 찾을 수 있습니다. $.ui.mount는 mod의 ui.render 훅을 통해 해당 지점을 그리고, 이러한 각 작업에 대한 메서드가 있는 핸들을 반환합니다. 하나의 테스트에서 여러 앱을 다루려면 surface를 그릴 대상 앱으로 설정합니다. 다음 테스트는 탭이 있는 창 만들기의 창을 열고, 탭을 전환하고, 버튼을 누른 다음, 터미널과 Desktop 앱에서 카운트를 확인합니다.

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를 저장하면 테스트가 통과합니다. 두 마운트가 동일하게 로드된 모듈을 사용하므로 카운트는 첫 번째 앱에서 두 번째 앱으로 이어집니다.

$.ui.mount가 반환하는 핸들에는 다음과 같은 메서드가 있으며, 이 메서드들은 요소에 부여한 key로 요소를 지정합니다.

메서드 기능
press({ key: 'more' }) 해당 키를 가진 Button을 누릅니다
input({ key: 'new-note', text: 'buy milk' }) 해당 키를 가진 Input에 텍스트를 입력하고 Enter를 누릅니다. 제출하지 않고 입력만 하려면 kind: 'change'를 추가합니다.
select({ key: 'size', value: 'large' }) 해당 키를 가진 Select에서 해당 값을 가진 옵션을 선택합니다
find({ key: 'more' }) 또는 find({ type: 'Text', text: 'Count: 2' }) 처음으로 일치하는 요소를 { type, props, children } 형태로 반환하거나, undefined를 반환합니다. text는 문자열 또는 정규 표현식일 수 있습니다.
unmount() 그린 내용을 제거합니다

각 메서드는 핸들러가 완료된 후에 resolve되므로 바로 다음 줄에서 결과를 확인할 수 있습니다. props는 Claude Code가 해당 지점에 전달할 값으로 설정합니다. 렌더링 지점 표에는 각 지점의 prop이 나열되어 있으며, 사용 중인 빌드의 타입에서 해당 타입을 확인할 수 있습니다.

그리기 테스트는 훅이 반환하는 트리와 해당 트리가 그 앱에서 유효한지를 확인합니다. 앱이 이를 실제로 어떻게 그려 내는지는 확인하지 않으므로, 새 레이아웃은 실제 세션에서도 살펴보시기 바랍니다.

`/clear` 후 그리기 테스트

각 테스트는 모든 $.state 값이 기본값인 상태로 시작하며, 이는 /clear가 남기는 상태와 같습니다. mod가 그다음에 수행하는 동작을 테스트하려면 session.start를 건너뛰고, source: 'clear'로 classic.SessionStart를 발생시킨 다음, mod가 그리는 내용을 확인합니다.

다음 테스트는 /clear 후 저장된 값 다시 로드하기의 모듈을 확인합니다. PANE이 정의된 그리기 테스트의 파일에 추가합니다. 해당 파일의 첫 번째 테스트는 둘 이상의 세션에서 저장하기의 버튼처럼 버튼이 카운트를 저장할 것으로 예상합니다.

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 훅이 저장된 7을 $.state에 복사하면 테스트가 통과합니다. 모듈에 해당 훅이 없으면 창에 Count: 0이 그려지고, find가 undefined를 반환하며, 테스트는 toBeDefined에서 실패합니다.

정책 mod 테스트하기

조직이 prependPlugins에 등록한 mod는 다른 mod가 로드되기 전에 해당 mod를 거부할 수 있습니다. 이러한 mod를 테스트하려면 mod의 티어를 설정하고, 테스트 대상 mod가 허용하거나 거부할 두 번째 mod를 테스트에 제공합니다.

  • tier: 테스트 파일 맨 위에서 tier('prepend')처럼 한 번 호출하여 mod를 prepend, append 또는 builtin으로 로드합니다. 이는 mod가 실행되는 순서에서 해당 mod의 위치를 지정합니다. 호출하지 않으면 mod는 user로 로드됩니다.
  • plugins: 테스트 본문 앞에 옵션 객체를 test에 전달합니다. 이 객체의 plugins 배열에는 인라인으로 작성한 mod가 들어가며, 각 mod에는 name과 register 함수가 있습니다. mod를 user가 아닌 다른 위치에 로드하려면 해당 mod에 tier를 추가합니다.

이 테스트 파일은 관리자 페이지의 정책 mod를 가장 먼저 로드합니다. 그리고 정책 mod가 프로세스를 시작하는 mod는 거부하고 프로세스를 시작하지 않는 mod는 허용하는지 확인합니다.

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를 실행합니다. 정책 mod가 관리자 페이지에 나온 그대로라면 두 테스트 모두 통과합니다.

키트는 테스트에서 $를 처음 호출할 때 모든 mod를 로드합니다. 테스트 대상 mod가 어떤 mod를 거부하면 해당 호출에서 오류가 발생하며, 메시지에는 거부된 mod, 이를 거부한 mod, 그리고 지정한 사유가 표시됩니다. 두 번째 테스트에서는 아무것도 거부되지 않으므로, 도구 호출이 스텁에 도달하기 전에 reader가 응답합니다.

다음 단계

  • mod 문제 해결: 세션에서 mod가 아무 동작도 하지 않는 이유를 확인합니다
  • mod 참조: stub 작성을 위한 모든 이벤트의 입력과 결과를 확인합니다