Vitest の expect.unreachable で条件分岐を伴うテストで型を絞り込める

に公開

expect.unreachable、かなり便利

Zod や Valibot などで作成した schema について、Vitest で Unit テストを記述する際に、「成功したかどうか」の分岐がでた時に少し困りませんか?

import * as v from "valibot";

export const UserSchema = v.object({
  name: v.string(),
  age: v.number(),
});
// 🔴 うまくいかない例
import { UserSchema } from "./user";
import * as v from "valibot";

it("should successfully parse a valid user", () => {
  const user = { name: "John Doe", age: 25 };
  const result = v.safeParse(UserSchema, user);
  expect(result.success).toBe(true);

  // expect だけでは型が絞り込まれず、その場合 output が unknown 型になるので
  // @ts-expect-error
  expect(result.output.age).toEqual(25);
  //                   ^^^ 'age' プロパティは存在しません。
});

expect だけでは型が絞り込まれないため、型チェックで偽陽性(実行時には正しく動くはずなのに、型チェック時にはエラー)になってしまいます。

result.output にはアクセスできるのですが、型が unknown のままで UserSchema の型には絞り込まれていないため、 age プロパティにアクセスできません。

参考: `output` のアクセス自体は失敗せず、`unknown` 型になる理由

Valibot の safeParse の戻り値の型定義を見てみると、以下のようになっています。

  • 成功(success === true)のとき、output の型が決まる
  • 失敗(success === false)のとき、output の型が unknown になる
    • output プロパティが存在しない」とはならない

https://github.com/open-circle/valibot/blob/df7152ac637070c5ba1fd7685bd34fe614b57a51/library/src/methods/safeParse/types.ts#L12-L41

今回のようなコードを書く時の利便性を考慮してのことですかね…?実際に、 age のような個別のプロパティではなく、 output の内容全体を比較しさえすれば良いなら、型エラーを起こさずに書けます。

また、「✅️完全版コード」のようにして型チェックを有効化したとしても、(expect の定義がゆるいので)型チェックが特に厳しくなるわけでもありません。

なので、実は、safeParse は、unreachable のメリットの説明のためには少し不完全ですが、論理的に正しい主張のはずなのでそれに免じて許してください。🙇

// 実はそんなに問題がない
import { UserSchema } from "./user";
import * as v from "valibot";

it("should successfully parse a valid user", () => {
  const user = { name: "John Doe", age: 25 };
  const result = v.safeParse(UserSchema, user);
  expect(result.success).toBe(true);

  expect(result.output).toEqual({ name: "John Doe", age: 25 });
  //            ^^^^^^ unknown 型
});
// ✅️ 完全版コード
import { UserSchema } from "./user";
import * as v from "valibot";

it("should successfully parse a valid user", () => {
  const user = { name: "John Doe", age: 25 };
  const result = v.safeParse(UserSchema, user);
  if (!result.success) {
    expect.unreachable("Should not fail safeParse");
  }
  // 🔴書き間違いだが、toEqual の型定義が緩く、型チェックで検出されない。 
  expect(result.output).toEqual({ name: "John Doe" });
});

そこで使えるのが、expect.unreachable です。

// ✅️ 完全版コード
import { UserSchema } from "./user";
import * as v from "valibot";

it("should successfully parse a valid user", () => {
  const user = { name: "John Doe", age: 25 };
  const result = v.safeParse(UserSchema, user);
  if (!result.success) {
    expect.unreachable("Should not fail safeParse");
  }
  // ✅️ output の型が、狙い通りに絞り込まれている
  expect(result.output.age).toEqual(25);
});

テストをわざと失敗させてみると、以下のようなメッセージを出してくれます。

 FAIL  src/user.test.ts > should successfully parse a valid user
AssertionError: expected "Should not fail safeParse" not to be reached
❯ src/user.test.ts:8:12

▼ ドキュメント

https://vitest.dev/api/expect.html#expect-unreachable

assert との違い

実は、この記事を書いたきっかけになったのは、Vitest の assert を紹介している以下の記事です。

assertexpect.unreachable と同様に、到達不能コードを TypeScript に教えて型を絞り込むのに使えます。

https://zenn.dev/apple_yagi/articles/3fecd12aed68d5

どちらも似たりよったりですが、個人的に expect.unreachable には「ただの if 文で、通常のプロダクションのコードと同様に(型ガードとして有効なガード節として)書けるので、assert の API の知識が不要」という利点があると思います。

(もちろん「テストを全体的に expect を使って書いている場合には」という条件付きですが…)

Q.どんなカラクリになってるの? A. never 型による大域脱出の明示

expect.unreachable の型定義を見てみると、以下のようになっています。

	interface ExpectStatic {
    /* 中略 */
		unreachable: (message?: string) => never;

never を返す」という見慣れないシグネチャになっています。このような関数を呼び出すと、「大域脱出」として TypeScript に認識されます。これに条件分岐を組み合わせることで、早期リターンによる型ガードと同様に、型の絞り込みができているんですね。

https://typescriptbook.jp/reference/statements/never

同じ仕組みを使った例として: Next.js の notFound 関数 があります。今回と同様、条件分岐の中で notFound を呼び出すことで、

  • id が undefined のときは Not Found のエラーを見せる
  • → そうでない場合の分岐では id が undefined でないことが保証されている

といった型チェックが可能です。

import { notFound } from 'next/navigation'
import { fetchUser } from './fetch-user'
 
export default async function Profile({ params }: PageProps<'/users/[id]'>) {
  const { id } = await params
  const user = await fetchUser(id)
  //    ^? : User | undefined 型
  if (!user) {
    notFound()
  }
 
  // 以降、user が undefined でないことが保証されている
}

以上。

ゆめみ

Discussion