🧪

Vitest 4の新機能 expect.schemaMatching とHonoとZodでスキーマ駆動でテストをやる

に公開

はじめに

こんにちは、株式会社アサインでエンジニアをしているうちほり(@showichiro0123)です。

2025年10月22日にリリースされたVitest 4には、テスト駆動開発やスキーマ駆動開発を加速させる新機能が多数追加されました。中でも私の目を引いたのは、Zodなどのスキーマライブラリと統合できるexpect.schemaMatchingです。

本記事では、Vitest 4の新機能を概観しつつ、expect.schemaMatchingを使ったスキーマ駆動テストの実践方法を、Honoを使ったWeb APIの開発例を交えて紹介します。

Vitest 4の主要な新機能

まず、Vitest 4で追加された主要な新機能を見ていきましょう。

1. Browser Modeの正式版化

これまで実験的機能だったBrowser Modeが正式版になりました。Playwright、WebdriverIO、Previewといった複数のプロバイダーをサポートし、実際のブラウザ環境でテストを実行できます。

vitest --browser.name=chromium

2. ビジュアルリグレッションテスト

toMatchScreenshot()toBeInViewport()といった新しいマッチャーが追加され、UIの視覚的な検証が可能になりました。

await expect(page).toMatchScreenshot();

3. Type-Aware Hooks

test.beforeEach()test.afterEach()が拡張コンテキストをサポートし、型安全なセットアップ・クリーンアップが可能になりました。

4. 新しいマッチャー

  • expect.assert(): Chaiのassert APIをexpectオブジェクトで使用可能に
  • expect.schemaMatching(): Standard Schema v1準拠のスキーマライブラリ(Zod、Valibot、ArkType)による検証

今回の記事では、このexpect.schemaMatching()に焦点を当てます。

expect.schemaMatching とは

expect.schemaMatching()は、Standard Schema v1オブジェクトを受け入れ、スキーマに対して値を検証する非対称マッチャーです。
非対称マッチャーとは、テストの期待値側で使用できる特殊なマッチャーで、実際の値と期待値を柔軟に比較できます。例えばexpect.any(String)expect.objectContaining()などが非対称マッチャーの例です。

サポートするスキーマライブラリ

具体的には以下のようなバリデーションライブラリを利用することができます。

  • Zod: z.string().email()
  • Valibot: v.pipe(v.string(), v.email())
  • ArkType: type('string.email')

expect.schemaMatchingと一緒に使用できるマッチャー

前述の通り、expect.schemaMatchingは非対称マッチャーなので、以下のような等価性チェックマッチャーと組み合わせて使用できます。

  • toEqual
  • toStrictEqual
  • toMatchObject
  • toContainEqual
  • toHaveBeenCalledWith

これにより、オブジェクトの一部分だけをスキーマ検証したり、関数の引数がスキーマに準拠しているかを検証したりできます。

実践: Honoでスキーマ駆動テストを書く

ここからは、実際にHono + Zod + Vitest 4を使った具体例を見ていきます。

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。

{
  "dependencies": {
    "@hono/swagger-ui": "^0.5.2",
    "@hono/zod-openapi": "^1.1.4",
    "hono": "^4.10.2"
  },
  "devDependencies": {
    "vitest": "^4.0.1",
    "zod": "^4.1.12"
  }
}

1. Zodスキーマの定義

まず、APIで扱うデータのスキーマをZodで定義します。

src/schemas/user.ts
import { z } from '@hono/zod-openapi'

// ユーザースキーマ
export const UserSchema = z.object({
  id: z.string().openapi({
    example: '123',
    description: 'ユーザーID',
  }),
  name: z.string().min(1).max(100).openapi({
    example: '山田太郎',
    description: 'ユーザー名',
  }),
  email: z.string().email().openapi({
    example: 'yamada@example.com',
    description: 'メールアドレス',
  }),
  age: z.number().int().min(0).max(150).optional().openapi({
    example: 25,
    description: '年齢',
  }),
  createdAt: z.string().datetime().openapi({
    example: '2025-01-01T00:00:00Z',
    description: '作成日時',
  }),
})

// ユーザー作成リクエストスキーマ
export const CreateUserRequestSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
})

// ユーザー一覧レスポンススキーマ
export const UsersListResponseSchema = z.object({
  users: z.array(UserSchema),
  total: z.number().int(),
})

// エラーレスポンススキーマ
export const ErrorResponseSchema = z.object({
  error: z.string(),
  message: z.string().optional(),
})

// 型のエクスポート
export type User = z.infer<typeof UserSchema>
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>

2. APIルートの定義

次に、@hono/zod-openapiを使ってAPIルートを定義します。

src/routes/users.ts
import { createRoute } from '@hono/zod-openapi'
import { UserSchema, UsersListResponseSchema, ErrorResponseSchema } from '../schemas/user'

// GET /users - ユーザー一覧取得
export const listUsersRoute = createRoute({
  method: 'get',
  path: '/users',
  summary: 'ユーザー一覧取得',
  tags: ['users'],
  responses: {
    200: {
      content: {
        'application/json': {
          schema: UsersListResponseSchema,
        },
      },
      description: 'ユーザー一覧を返却',
    },
  },
})

// GET /users/:id - ユーザー詳細取得
export const getUserRoute = createRoute({
  method: 'get',
  path: '/users/{id}',
  summary: 'ユーザー詳細取得',
  tags: ['users'],
  request: {
    params: UserSchema.pick({ id: true }),
  },
  responses: {
    200: {
      content: {
        'application/json': {
          schema: UserSchema,
        },
      },
      description: 'ユーザー情報を返却',
    },
    404: {
      content: {
        'application/json': {
          schema: ErrorResponseSchema,
        },
      },
      description: 'ユーザーが見つかりません',
    },
  },
})

3. ハンドラの実装

定義したルートに対してハンドラを実装します。

src/index.ts
import { OpenAPIHono } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
import { listUsersRoute, getUserRoute } from './routes/users'

const app = new OpenAPIHono()

const mockUsers = [
  {
    id: '1',
    name: '山田太郎',
    email: 'yamada@example.com',
    age: 25,
    createdAt: '2025-01-01T00:00:00Z',
  },
  {
    id: '2',
    name: '佐藤花子',
    email: 'sato@example.com',
    age: 30,
    createdAt: '2025-01-02T00:00:00Z',
  },
]

// GET /users - ユーザー一覧取得
app.openapi(listUsersRoute, (c) => {
  return c.json({
    users: mockUsers,
    total: mockUsers.length,
  })
})

// GET /users/:id - ユーザー詳細取得
app.openapi(getUserRoute, (c) => {
  const { id } = c.req.valid('param')
  const user = mockUsers.find((u) => u.id === id)

  if (!user) {
    return c.json({ error: 'User not found' }, 404)
  }

  return c.json(user, 200)
})

// OpenAPI仕様書とSwagger UI
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    title: 'User API',
    version: '1.0.0',
  },
})

app.get('/ui', swaggerUI({ url: '/doc' }))

export default app

4. expect.schemaMatching を使ったテスト

ここからが本題です。Vitest 4のexpect.schemaMatching()を使ってテストを書いていきます。

src/__tests__/users.test.ts
import { describe, expect, test } from 'vitest'
import app from '../index'
import {
  UserSchema,
  UsersListResponseSchema,
  ErrorResponseSchema,
} from '../schemas/user'

describe('Users API - スキーマ駆動テスト', () => {
  describe('GET /users - ユーザー一覧取得', () => {
    test('正しいスキーマのレスポンスを返す', async () => {
      const res = await app.request('/users')

      expect(res.status).toBe(200)

      const data = await res.json()

      // expect.schemaMatchingでレスポンス全体を検証
      expect(data).toEqual(expect.schemaMatching(UsersListResponseSchema))
    })

    test('users配列の各要素がUserSchemaに準拠', async () => {
      const res = await app.request('/users')
      const data = await res.json()

      // 各ユーザーがUserSchemaに準拠していることを検証
      data.users.forEach((user: any) => {
        expect(user).toEqual(expect.schemaMatching(UserSchema))
      })
    })

    test('total値が正しい型', async () => {
      const res = await app.request('/users')
      const data = await res.json()

      // スキーマの一部分だけを検証
      expect(data).toMatchObject({
        total: expect.schemaMatching(UsersListResponseSchema.shape.total),
      })
    })
  })

  describe('GET /users/:id - ユーザー詳細取得', () => {
    test('存在するユーザーIDで正しいスキーマのレスポンスを返す', async () => {
      const res = await app.request('/users/1')

      expect(res.status).toBe(200)

      const data = await res.json()

      // UserSchemaに準拠したレスポンスが返ることを検証
      expect(data).toEqual(expect.schemaMatching(UserSchema))
    })

    test('特定のユーザー情報を検証', async () => {
      const res = await app.request('/users/1')
      const data = await res.json()

      // 個別フィールドのスキーマ検証
      expect(data).toMatchObject({
        id: expect.schemaMatching(UserSchema.shape.id),
        name: expect.schemaMatching(UserSchema.shape.name),
        email: expect.schemaMatching(UserSchema.shape.email),
        createdAt: expect.schemaMatching(UserSchema.shape.createdAt),
      })
    })

    test('存在しないユーザーIDでエラースキーマを返す', async () => {
      const res = await app.request('/users/999')

      expect(res.status).toBe(404)

      const data = await res.json()

      // ErrorResponseSchemaに準拠したエラーレスポンスが返ることを検証
      expect(data).toEqual(expect.schemaMatching(ErrorResponseSchema))
    })
  })
})

5. テスト実行

テストを実行します。

expect.schemaMatching の利点

従来のテスト方法と比較して、expect.schemaMatching()には以下のような利点があります。

1. テストコードの簡潔化

従来のテスト方法では、複数のアサーションやexpect.objectContainingを組み合わせる必要がありました:

// パターン1: 個別にプロパティを検証
expect(data).toHaveProperty("id");
expect(typeof data.id).toBe("string");
expect(data).toHaveProperty("name");
expect(typeof data.name).toBe("string");
expect(data.name.length).toBeGreaterThan(0);
expect(data).toHaveProperty("email");
expect(data.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);

// パターン2: expect.objectContainingを使用
expect(data).toEqual(
  expect.objectContaining({
    id: expect.any(String),
    name: expect.any(String),
    email: expect.stringMatching(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
    age: expect.any(Number),
    createdAt: expect.any(String),
  }),
);
// スキーマとは別にスキーマを書いているようなもの...

expect.schemaMatching()を使用:

// スキーマで一括検証 - バリデーションルールも含めて検証される
expect(data).toEqual(expect.schemaMatching(UserSchema));

2. スキーマとテストの一元管理

スキーマが単一の情報源(Single Source of Truth)となり、以下が全て同期されます。

  • 型定義(TypeScript)
  • バリデーション(実行時チェック)
  • APIドキュメント(OpenAPI)
  • テストの期待値

スキーマを更新すれば、これら全てに変更が反映されます。

3. 複雑なデータ構造の検証が容易

ネストした構造や配列の検証も簡潔に書けます。

test("users配列の各要素がUserSchemaに準拠", async () => {
  const res = await app.request("/users");
  const data = await res.json();

  // 配列の各要素を検証
  data.users.forEach((user: unknown) => {
    expect(user).toEqual(expect.schemaMatching(UserSchema));
  });
});

スキーマ駆動開発のワークフロー

expect.schemaMatching()は、スキーマ駆動開発のワークフローと非常に相性が良いです。

ワークフローの例

  1. スキーマ定義: Zodでデータ構造を定義
  2. ルート定義: createRouteでAPIエンドポイントを定義
  3. テスト作成: expect.schemaMatching()でスキーマベースのテストを書く
  4. 実装: ハンドラを実装
  5. テスト実行: スキーマに準拠しているか自動検証

このワークフローにより、スキーマを中心とした一貫性のある開発が可能になります。

AI駆動開発との相性

AI駆動開発において、expect.schemaMatching()は特に有効です。

AIにとって明確な仕様

スキーマは構造化されたデータであり、AIにとって理解しやすい形式です。

// このスキーマを見れば、AIは以下を理解できる:
// - idは文字列
// - nameは1〜100文字の文字列
// - emailはメール形式
// - ageはオプショナルで0〜150の整数
export const UserSchema = z.object({
  id: z.string(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  createdAt: z.string().datetime(),
});

高速なフィードバックループ

テストを実行すれば、スキーマに準拠しているかが即座に判明します。

$ vitest run

 ✓ src/__tests__/users.test.ts (8 tests) 45ms
   ✓ Users API - スキーマ駆動テスト
     ✓ GET /users - ユーザー一覧取得
       ✓ 正しいスキーマのレスポンスを返す
       ✓ users配列の各要素がUserSchemaに準拠
     ✓ GET /users/:id - ユーザー詳細取得
       ✓ 存在するユーザーIDで正しいスキーマのレスポンスを返す
       ✓ 存在しないユーザーIDでエラースキーマを返す

このように、AIが生成したコードが仕様(スキーマ)を満たしているかを高速にチェックできます。

まとめ

Vitest 4のexpect.schemaMatching()は、AI駆動開発とスキーマ駆動開発とテスト駆動開発をつなぐ機能だなと考えています。

主な利点

  1. 一元管理: スキーマが型・バリデーション・ドキュメント・テストの単一の情報源に
  2. AI駆動開発との親和性: AIにとって理解しやすく、高速なフィードバックが可能
  3. 保守性の向上: スキーマ変更時、テストも自動的に追従

推奨する開発フロー

  1. Zodでスキーマを定義
  2. @hono/zod-openapiでAPIルートを定義
  3. expect.schemaMatching()でテストを書く
  4. 実装を進める
  5. テストでスキーマ準拠を自動検証

Vitest 4とZodの組み合わせにより、より堅牢で保守性の高いWebアプリケーション開発が実現できるのではないかと考えています。ぜひ試してみてください。

サンプルコード

本記事で紹介したコードの完全版は、以下のリポジトリで公開しています。

https://github.com/Showichiro/hono-vitest-v4

実際に動作するコードを確認できますので、ぜひ参考にしてください。

参考リンク

ASSIGN

Discussion