エージェントのコードを書くのは楽しい。だが「本当に動くか」を確認する方法は、ふつうの関数テストとは勝手が違う。MCPスキルサーバーはJSONのやり取りで動き、外部APIを呼び、ツール呼び出しの結果はモデルの判断に依存します。試しに手動で叩いてみるだけでは限界があり、リファクタリングのたびに怖い思いをすることになります。
ここでは前回の記事で構築したWeatherスキルサーバーを題材に、Vitestでユニットテスト・統合テストを書く方法を解説します。「エージェントのテストって何から始めればいい?」という疑問に、動くコードで答える。
エージェントテストで本当に難しいのはどこか
まず現実を整理しよう。MCPスキルサーバーのテストには3つの難しさがあります。
1. 非同期 + ストリーミング: MCP SDKはStdioトランスポートでJSON-RPCをやり取りします。単純な return ではなく、プロセス間通信が絡む。
2. 外部API依存: Weatherスキルなら OpenWeatherMap、DBスキルなら実際のDBに依存します。テストごとにAPIを叩いていては遅くてコストがかかります。
3. LLMの非決定性: 最終的なエージェントの出力はモデルの判断に依存します。ここはテストするのではなく、スキルロジックだけをテストするという割り切りが重要です。
つまり、テストすべき対象はこう切り分けられる:
┌─────────────────────────────────┐
│ テストすべき(決定的) │
│ ・ツールハンドラーのロジック │
│ ・入力バリデーション │
│ ・エラーハンドリング │
│ ・モックを使った外部API呼び出し │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ テスト不要(非決定的) │
│ ・LLMがどのツールを選ぶか │
│ ・モデルの出力文章 │
└─────────────────────────────────┘
この割り切りがあれば、テストは書ける。
プロジェクト構成とVitest導入
前回構築したWeatherスキルサーバーのディレクトリに、テスト環境を追加します。
weather-skill/
├── src/
│ ├── index.ts # MCPサーバーエントリポイント
│ ├── tools/
│ │ └── weather.ts # ツールハンドラー(テスト対象)
│ └── utils/
│ └── openweather.ts # APIクライアント(モック対象)
├── tests/
│ ├── unit/
│ │ └── weather.test.ts
│ └── integration/
│ └── server.test.ts
├── vitest.config.ts
└── package.jsonまずVitestをインストールする:
npm install -D vitest @vitest/coverage-v8vitest.config.ts を作成:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/index.ts'], // エントリポイントは除外
},
},
})package.json にスクリプトを追加:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}ツールロジックのユニットテスト
前回のWeatherスキルでは、ツールハンドラーを src/tools/weather.ts に分離しました。この関数を純粋にテストするのが最初のステップです。
まず、テスト対象の src/utils/openweather.ts を確認する:
// src/utils/openweather.ts
export interface WeatherData {
city: string
temperature: number
condition: string
humidity: number
}
export async function fetchCurrentWeather(city: string): Promise<WeatherData> {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${process.env.OPENWEATHER_API_KEY}&units=metric`
const res = await fetch(url)
if (\!res.ok) {
throw new Error(`OpenWeatherMap API error: ${res.status}`)
}
const data = await res.json()
return {
city: data.name,
temperature: Math.round(data.main.temp),
condition: data.weather[0].description,
humidity: data.main.humidity,
}
}次に、ツールハンドラー src/tools/weather.ts:
// src/tools/weather.ts
import { fetchCurrentWeather } from '../utils/openweather.js'
export async function handleGetWeather(args: { city: string }) {
if (\!args.city || args.city.trim() === '') {
throw new Error('city パラメータが必要です')
}
const weather = await fetchCurrentWeather(args.city.trim())
return {
content: [
{
type: 'text' as const,
text: `${weather.city}の現在の天気: ${weather.temperature}℃、${weather.condition}、湿度${weather.humidity}%`,
},
],
}
}ここでのポイントは fetchCurrentWeather を 直接呼ぶのではなく、モジュールとして切り出していること。これでVitestの vi.mock() が使えるようになります。
ユニットテストを書く:
// tests/unit/weather.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { handleGetWeather } from '../../src/tools/weather.js'
// openweather モジュール全体をモック
vi.mock('../../src/utils/openweather.js', () => ({
fetchCurrentWeather: vi.fn(),
}))
// モック関数への参照を取得
import { fetchCurrentWeather } from '../../src/utils/openweather.js'
const mockFetchWeather = vi.mocked(fetchCurrentWeather)
describe('handleGetWeather', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('正常な都市名でテキストレスポンスを返す', async () => {
// モックの戻り値を設定
mockFetchWeather.mockResolvedValue({
city: 'Tokyo',
temperature: 22,
condition: 'clear sky',
humidity: 60,
})
const result = await handleGetWeather({ city: 'Tokyo' })
// 構造を検証
expect(result.content).toHaveLength(1)
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toContain('Tokyo')
expect(result.content[0].text).toContain('22℃')
expect(result.content[0].text).toContain('60%')
})
it('空文字列の都市名でエラーをスローする', async () => {
await expect(handleGetWeather({ city: '' })).rejects.toThrow('city パラメータが必要です')
// 空文字の場合はAPIを呼ばないことも確認
expect(mockFetchWeather).not.toHaveBeenCalled()
})
it('スペースだけの都市名もバリデーションで弾く', async () => {
await expect(handleGetWeather({ city: ' ' })).rejects.toThrow('city パラメータが必要です')
})
it('APIエラーをそのまま伝播する', async () => {
mockFetchWeather.mockRejectedValue(new Error('OpenWeatherMap API error: 404'))
await expect(handleGetWeather({ city: 'InvalidCity' })).rejects.toThrow('OpenWeatherMap API error: 404')
})
it('前後の空白をトリムしてAPIを呼ぶ', async () => {
mockFetchWeather.mockResolvedValue({
city: 'Osaka',
temperature: 25,
condition: 'partly cloudy',
humidity: 55,
})
await handleGetWeather({ city: ' Osaka ' })
expect(mockFetchWeather).toHaveBeenCalledWith('Osaka')
})
})npm test を実行すると5つのテストがパスします。これでツールロジックの品質が保証された。
APIクライアントのテスト — fetchのモック
fetchCurrentWeather 自体もテストしたい。ここでは fetch をモックする:
// tests/unit/openweather.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchCurrentWeather } from '../../src/utils/openweather.js'
// グローバルfetchをモック
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('fetchCurrentWeather', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.OPENWEATHER_API_KEY = 'test-api-key'
})
it('APIレスポンスを正しくパースして返す', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
name: 'Tokyo',
main: { temp: 22.4, humidity: 60 },
weather: [{ description: 'clear sky' }],
}),
})
const result = await fetchCurrentWeather('Tokyo')
expect(result).toEqual({
city: 'Tokyo',
temperature: 22, // Math.round(22.4)
condition: 'clear sky',
humidity: 60,
})
})
it('APIキーをクエリパラメータに含める', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
name: 'Tokyo',
main: { temp: 20, humidity: 50 },
weather: [{ description: 'clear' }],
}),
})
await fetchCurrentWeather('Tokyo')
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('appid=test-api-key')
expect(calledUrl).toContain('units=metric')
expect(calledUrl).toContain('q=Tokyo')
})
it('APIレスポンスがok=falseの場合にエラーをスローする', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 })
await expect(fetchCurrentWeather('NonExistentCity')).rejects.toThrow('OpenWeatherMap API error: 404')
})
})ここで重要なのは、temperature が Math.round されているかのテストです。22.4 が 22 に変換される仕様は、ユニットテストなしだと気づかず壊れやすい部分です。
MCPサーバー全体の統合テスト
ユニットテストでロジックを固めたら、MCPサーバー全体の統合テストを書く。ここでは @modelcontextprotocol/sdk の InMemoryTransport を使い、実際のJSON-RPCメッセージをやり取りする:
// tests/integration/server.test.ts
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createWeatherServer } from '../../src/server.js'
// 統合テストでも外部APIはモック
vi.mock('../../src/utils/openweather.js', () => ({
fetchCurrentWeather: vi.fn().mockResolvedValue({
city: 'Tokyo',
temperature: 22,
condition: 'clear sky',
humidity: 60,
}),
}))
describe('WeatherMCPServer 統合テスト', () => {
let server: Server
let client: Client
beforeAll(async () => {
server = createWeatherServer()
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} })
// InMemoryTransportで接続(実プロセス不要)
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
])
})
afterAll(async () => {
await client.close()
await server.close()
})
it('ツール一覧を返す', async () => {
const tools = await client.listTools()
const toolNames = tools.tools.map(t => t.name)
expect(toolNames).toContain('get_current_weather')
})
it('get_current_weather を呼び出して結果を返す', async () => {
const result = await client.callTool({ name: 'get_current_weather', arguments: { city: 'Tokyo' } })
expect(result.isError).toBeFalsy()
expect(result.content).toHaveLength(1)
expect(result.content[0].type).toBe('text')
expect((result.content[0] as { type: string; text: string }).text).toContain('Tokyo')
})
it('存在しないツール名でエラーレスポンスを返す', async () => {
await expect(
client.callTool({ name: 'nonexistent_tool', arguments: {} })
).rejects.toThrow()
})
})InMemoryTransport を使うことで、実際のStdioプロセスを立ち上げることなくMCPの通信全体をテストできます。これが統合テストの要です。
GitHub Actions で CI に組み込む
テストを書いたら、CIで自動実行します。.github/workflows/test.yml を作成:
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
env:
OPENWEATHER_API_KEY: ${{ secrets.OPENWEATHER_API_KEY }}
# テスト用ダミーキー(実APIを呼ばないのでダミーでよい)
# CI環境では secrets に "test-dummy-key" を設定しておく
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
fail_ci_if_error: falseここで一点注意があります。OPENWEATHER_API_KEY はテスト内でモックしているので実際のAPIキーは不要だが、process.env.OPENWEATHER_API_KEY への参照がコード内にあるとランタイムエラーになることがあります。secrets に "test-dummy-key" などのダミー値を登録しておくのが安全です。
テスト設計で覚えておくこと
AgentKit 2.0 スキルのテストで実際に書いてみて分かった落とし穴を3つ挙げる。
1. vi.mock() はファイルトップにホイストされる
Vitestは vi.mock() 呼び出しをファイルの先頭に自動ホイストします。import 文の後に書いても問題なく動く理由はこれだが、beforeEach 内で mockResolvedValue を切り替えることが前提になる。モックの初期値を vi.mock() のファクトリーに書いてしまうと、テストごとの上書きが効かないケースがあります。
2. InMemoryTransport はSDKのバージョンに依存する
@modelcontextprotocol/sdk v1.9以降で InMemoryTransport のAPIが変わっています。createLinkedPair() が使える版かどうかを package.json で確認すること。古いバージョンでは new InMemoryTransport() を2つ作って手動でリンクします。
3. MCPの isError フラグを必ず確認する
MCPではツール呼び出しのエラーは例外ではなく { isError: true, content: [...] } で返る設計になっています。result.content[0].text を直接アサートする前に result.isError が false であることを確認する習慣をつけると、エラー時の偽陽性テストを防げる。
全体を振り返って — どこから始めるか
3段階で進めるのが現実的です。
まずツールハンドラーだけをユニットテストします。外部APIを vi.mock() で置き換え、入力バリデーションとエラー伝播を確認します。これだけでもリファクタリングの安心感が大きく変わる。
次に InMemoryTransport を使った統合テストを追加します。JSON-RPC全体の流れ(listTools → callTool → レスポンスパース)が正しく動くかを確認します。
最後にGitHub ActionsでCIに組み込む。PRごとにテストが走る環境があれば、複数スキルを開発しても品質が維持しやすくなります。
前回の記事でスキルを作り、今回のテストを追加すれば、プロダクションに投入できる品質のMCPスキルサーバーが完成します。