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(result.output.age).toEqual(25);
  
});
参考: `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 });
  
});

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");
  }
  
  expect(result.output).toEqual({ name: "John Doe" });
});

expect だけでは型が絞り込まれないため、result.output にアクセスできるはずなのに、型チェックで偽陽性になってしまいます。

そこで使えるのが、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");
  }
  
  expect(result.output.age).toEqual(25);
});

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

 FAIL  src/user.test.ts > should successfully parse a valid user
AssertionError: expected

▼ ドキュメント

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

assert との違い

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

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

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

どちらも似たりよったりですが、個人的に expect.unreachable には「ただの if 文で、テストでない Production コードと同様に書けるので、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)
  
  if (!user) {
    notFound()
  }
 
  
}

以上。


元の記事を確認する

関連記事