ANTIGRAVITY LABEN
記事一覧/Agents & Manager
Agents & Manager/2026-04-13中級

AgentKit 2.0 エージェントを Vitest でユニットテストする実践ガイド

AgentKit 2.0 で構築したMCPスキルサーバーをVitestでユニットテスト・統合テストする方法を解説。モック戦略からCI統合まで、実際に動くコード例とともに体系的に紹介します。

AgentKit17テスト2VitestMCP17TypeScript11CI/CD17ユニットテスト

エージェントのコードを書くのは楽しい。だが「本当に動くか」を確認する方法は、ふつうの関数テストとは勝手が違う。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-v8

vitest.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.422 に変換される仕様は、ユニットテストなしだと気づかず壊れやすい部分です。

MCPサーバー全体の統合テスト

ユニットテストでロジックを固めたら、MCPサーバー全体の統合テストを書く。ここでは @modelcontextprotocol/sdkInMemoryTransport を使い、実際の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.isErrorfalse であることを確認する習慣をつけると、エラー時の偽陽性テストを防げる。

全体を振り返って — どこから始めるか

3段階で進めるのが現実的です。

まずツールハンドラーだけをユニットテストします。外部APIを vi.mock() で置き換え、入力バリデーションとエラー伝播を確認します。これだけでもリファクタリングの安心感が大きく変わる。

次に InMemoryTransport を使った統合テストを追加します。JSON-RPC全体の流れ(listToolscallTool → レスポンスパース)が正しく動くかを確認します。

最後にGitHub ActionsでCIに組み込む。PRごとにテストが走る環境があれば、複数スキルを開発しても品質が維持しやすくなります。

前回の記事でスキルを作り、今回のテストを追加すれば、プロダクションに投入できる品質のMCPスキルサーバーが完成します。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

Agents & Manager2026-06-29
Antigravity に書かせたテストが「通るだけ」になっていないか — ミューテーションで実効性を測る
Antigravity のエージェントに書かせたテストは、通っても肝心のバグを捕まえられないことがあります。ミューテーションテストで実効性を測り、生存ミュータントを潰してから採用する運用を、動くコードとともにまとめます。
Agents & Manager2026-06-18
Antigravity エージェントの従量課金が「内部の利用量」と「Stripeの請求」でずれる — 計測の冪等化・遅延吸収・突合の実装メモ
Antigravityエージェントの従量課金で、自前の利用台帳とStripe Meter Eventsの集計が静かにずれていく問題を扱います。冪等キー設計、遅延イベントの吸収、35日ウィンドウ、日次突合ジョブまでを実装単位でまとめた運用メモです。
Agents & Manager2026-06-02
Antigravity のエージェント出力からシークレットを漏らさない多層防御 — ログ・差分・PR 本文を守る実装メモ
バックグラウンドエージェントの出力にシークレットが混入する3つの経路と、コミット差分・実行ログ・PR本文を守る多層防御の実装を、6アプリ運用の実体験と具体数値とともにまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →