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は非対称マッチャーなので、以下のような等価性チェックマッチャーと組み合わせて使用できます。
toEqualtoStrictEqualtoMatchObjecttoContainEqualtoHaveBeenCalledWith
これにより、オブジェクトの一部分だけをスキーマ検証したり、関数の引数がスキーマに準拠しているかを検証したりできます。
実践: 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で定義します。
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ルートを定義します。
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. ハンドラの実装
定義したルートに対してハンドラを実装します。
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()を使ってテストを書いていきます。
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()は、スキーマ駆動開発のワークフローと非常に相性が良いです。
ワークフローの例
- スキーマ定義: Zodでデータ構造を定義
- ルート定義:
createRouteでAPIエンドポイントを定義 - テスト作成:
expect.schemaMatching()でスキーマベースのテストを書く - 実装: ハンドラを実装
- テスト実行: スキーマに準拠しているか自動検証
このワークフローにより、スキーマを中心とした一貫性のある開発が可能になります。
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駆動開発とスキーマ駆動開発とテスト駆動開発をつなぐ機能だなと考えています。
主な利点
- 一元管理: スキーマが型・バリデーション・ドキュメント・テストの単一の情報源に
- AI駆動開発との親和性: AIにとって理解しやすく、高速なフィードバックが可能
- 保守性の向上: スキーマ変更時、テストも自動的に追従
推奨する開発フロー
- Zodでスキーマを定義
-
@hono/zod-openapiでAPIルートを定義 -
expect.schemaMatching()でテストを書く - 実装を進める
- テストでスキーマ準拠を自動検証
Vitest 4とZodの組み合わせにより、より堅牢で保守性の高いWebアプリケーション開発が実現できるのではないかと考えています。ぜひ試してみてください。
サンプルコード
本記事で紹介したコードの完全版は、以下のリポジトリで公開しています。
実際に動作するコードを確認できますので、ぜひ参考にしてください。
Discussion