テストでDiscriminated Unionを特定の型に絞り込みたくて


TypeScriptにおいてDiscriminated Unionというものをご存知だろうか。 判別可能なユニオン型である。

詳しくはサバイバルTypeScriptを読んでほしい。

判別可能なユニオン型 (discriminated union) | TypeScript入門『サバイバルTypeScript』TypeScriptの判別可能なユニオン型は、ユニオンに属する各オブジェクトの型を区別するための「しるし」がついた特別なユニオン型です。オブジェクトの型からなるユニオン型を絞り込む際に、分岐ロジックが複雑になる場合は、判別可能なユニオン型を使うとコードの可読性と保守性がよくなります。typescriptbook.jp

今回の話はこのDiscriminated Unionが関わるテストコードにおいて「困ったぞ」という話である。

Doscriminated Unionといって最初に出てくるのは

筆者の中ではReuslt型である。

type Result<T,E> = {
  kind: "ok",
  value: T
} | {
  kind: "ng",
  err: E
}

この型はkindというスキームがokかngによってvalueかerrであることを判別している。

テストの対象とする関数

下記の関数を書いていたとしよう。

export function okValue() {
  const value = createOk("hoge")

  return value
}

(なんの捻りもない関数でつまらない気もするがテストのことを熱く語りたいので、このような形にした。)

このcreateOkは、下記のように定義されている。

const basic = {
    RESULT_OK: "ok",
    RESULT_NG: "ng",
} as const;

export function createOk<T>(value: NonNullable<T>): Result<T, never> {
    return { kind: basic.RESULT_OK, value };
}

元の関数は、以下のような実行結果になる。

{
  kind: "ok",
  value: "hoge"
}

この関数を元に当時のテストコードをどんな考えで書いていたのかをなるべくストリー仕立て風に書いていこうと思う。

kindが"ok"であることと"value"が"hoge"であることを確認したい

筆者は当時、expectAPIのtoBeメソッドでのやり方しか知らなかった。

VitestNext generation testing framework powered by Vitevitest.dev

import { it, expect } from "vitest";

it("検証1", () => {
  const value = okValue();
  
  expect(value.kind).toBe("ok");
  expect(value.value).toBe("hoge");
});

上記ソースは一見何も問題のないコードである。 しかし、

expect(value.value).toBe("hoge");

ここのvalue.valueが型エラーとなってしまう。

kindがokであることをTypeScript側が認知していないのだ。

筆者はそこで思った。

「よし!ならif文を使おう!」

ということでif文を書けば型のエラーが凌げるということで実際に書いてみる。

it("検証2", () => {
  const value = okValue();

  expect(value.kind).toBe("ok");

  if (value.kind === "ok") {
    expect(value.value).toBe("hoge");
  }
});

これで型エラーは防げる。しかし、if文を使うとテストの書き方次第では気づかぬバグを引き起こすことになる。 例えば、

it("検証2のダミー", () => {
  const value = okValue();

  if (value.kind === "ok") {
    //もし、kindがokでなかった場合このフローには入らずかつ、テストが成功したとみなされる。
    expect(value.value).toBe("hoge");
  }
});

こういうコードを書いたとしよう。

何が問題かというと、kindが"ok"かどうかの検証をせずにif文を書いているので仮にkindが"ng"だった場合、失敗しているにも関わらずテストは通ってしまう。

なのでif文の中で検証コードを書くならば、慎重な取り扱いをしなくてはならないと。

なんだかそれはリスクがあるなと判断した筆者は次なるパターンに移行する。

「それなら、if文の中に検証コードを書かずにエラーをthrowすれば良いじゃん!」

それならifの中で検証コードを書かなくすれば良いという発想になった。

it("検証3", () => {
  const value = okValue();

  expect(value.kind).toBe("ok");

  if(value.kind !== "ok"){
    throw Error("エラーだよん")
  }

  expect(value.value).toBe("hoge");
});

当然こうすれば、型エラーになることはないし、危険なコードにもならない。 expectが失敗した瞬間このテストは終わるし良いだろうと思うが、一つ気になったことがある。 「コードが冗長だし、エラーをthrowするところどう考えても無駄だろ!」

ここで筆者はかなり悩んでしまった。

救世主、ChatGPTの出した答えは。。

涙目になりながらChatGPTに筆者は質問をした。 そしたら一つのAPIを提示してくれた。

asert APIだ。

VitestNext generation testing framework powered by Vitevitest.dev

どうやらこいつは、検証をしてくれるだけではなく型まで絞り込んでくれるようだ。

さらにvitest v4.0.0からexpectAPIにassertメソッドとして追加されていてわざわざassertAPIをimportしなくても良くなった。

feat: support `expect.assert` for type narrowing by sheremet-va · Pull Request #8695 · vitest-dev/vitestDescription Please don't delete this checklist! Before submitting the PR, please make sure you do the following: It's really useful if your PR references an issue where it is discussed ...github.com

assertメソッドを使ったテストの実装

早速テストコードに取り入れた。

it("検証4", () => {
  const value = okValue();

  expect.assert(value.kind==="ok","エラーだよん");
  expect(value.value).toBe("hoge");
});

「おおースッキリした」ということで型も絞り込みながら検証もできることがわかったのでスッキリとしたテストコードが完成した。

一体どんな魔法を使ったのか

assertにはどのような型定義がされているのかが気になったので、コードを覗きに行った。 覗いた結果以下のようになっていた。

export interface Assert {
    /**
     * @param expression    Expression to test for truthiness.
     * @param message    Message to display on error.
     */
    (expression: any, message?: string): asserts expression;
    /**
     * Throws a failure.
     *
     * @param message    Message to display on error.
     * @remarks Node.js assert module-compatible.
     */
    fail(message?: string): never;
    //以下多くの型定義が続いていく

皆さんはお気づきだろうか?

asserts expression

なんじゃこいつは!!

asserts expenssionの正体

これはassert signatureであり、 「関数が正常終了したらならこの条件は真として扱って良い」 というものである。

つまり、

function wow(expenssion: unknown): asserts expenssion {
  if (!expenssion) {
    throw new Error();
  }
}

wow関数が正常に終了したらexpenssionは真として扱って良いとなる。 なので、テストコードに戻ると、

expect.assert(value.kind==="ok","エラーだよん");

assertsの関数が正常終了した場合value.kindを"ok"として扱って良い問いふうになるため型が絞り込めるというわけだ。

ただ、このassertsは気をつけて使わないといけない。 これらを使う際の、責務は我々にありTypeScript側では何も保証をしてくれないということだ。 型アサーションのasと同じようなものである。

なので筆者の考えとしては、ヘルパー関数以外は使うのを控えた方が良さそうという感想である。

まとめ

  • vitestにはassertというAPIとexpectのメソッドが存在する
  • assertにはasserts signatureが使われている
  • asserts signatureを使う際の責任は我々にある

(余談)ちなみに、toEqualメソッドを使うパターンもある

今回の内容とは逸れてしまうが、toEqualがexpect APIには存在する。

VitestNext generation testing framework powered by Vitevitest.dev

import { it, expect } from "vitest";

it("valueでhogeが返ってくる",()=>{
  const value = okValue();

  const compareValue = {
    kind: "ok",
    value: "hoge"
  };

  expect(value).toEqual(compareValue);
})

これでも十分に良い。実際にvalibotのsafeParseのテストでは似たようなコードを書かれていた。

valibot/library/src/methods/safeParser/safeParser.test.ts at 7237d90891d0a17cda37a2e054fc5ba8036c62cf · open-circle/valibotThe modular and type safe schema library for validating structural data 🤖 - open-circle/valibotgithub.com

(さらに余談) if禁止のリンターが存在する

実はテストコードにおけるif禁止のリンターが存在する。 筆者はoxlintを使っているが、

vitest/no-conditional-in-test | OxlintA collection of high-performance JavaScript tools written in Rustoxc.rs vitest/no-conditional-expect | OxlintA collection of high-performance JavaScript tools written in Rustoxc.rs vitest/no-conditional-tests | OxlintA collection of high-performance JavaScript tools written in Rustoxc.rs

などが用意されている。

特に、no-conditional-testsでは、

Conditional statements in test cases can make tests unpredictable and harder to understand. Tests should be consistent and straightforward to ensure reliable results and maintainability.

と書かれている。

日本語に訳すると

「テストケースに条件分岐を含めると、テストの結果が予測しづらくなり、理解しにくくなる可能性があります。信頼性の高い結果と保守性を確保するためには、テストは一貫性があり、わかりやすいものであるべきです。」

である。

安心安全なテストコードを書くためにもこのようなリンターを用意しておくというのは一つの手だと筆者は思った。