
はじめに
こんにちは! ラクスル Advent Calendar 2025 17日目を担当する、ラクスル事業本部 25新卒エンジニアの酒井です。
ラクスルの印刷 EC サービスのフロントエンドは主に Vue.js + TypeScript で開発しており、ユニットテストには Vitest を使用しています。比較的モダンな環境ですがコードの中には Vue2 で書かれた古いアプリケーションから移植したレガシーなものも多く、特にテストコードに限って言えば Eslint や Prettier、tsc による型チェックなど静的テストの整備が十分ではありませんでした。今回は、こうした状況の改善を進める中で行ったテストコードの型チェック環境を整備する取り組みについて紹介します。
取り組み前の状況と課題
既に多くのコードは TypeScript で書かれており、アプリケーションコードに対しては vue-tsc による型チェックが CI で行われる状態になっていた一方で、テストコードはその対象外とされていました。これらは主にビジネスロジックやコンポーネントの動作をテストするもので、コンポーネント側の変更に追従できておらず正しくない prop を流し込んでいるものや、新規テストに型安全を強制する仕組みが無いなど、テスト自体の信頼性を担保できていない状態でした。
目指した状態
CI でテストコードに対しても型チェックをするのが一番の解決策です。しかしいざ現状を確認してみると、既存のテストコードに数百件もの型エラーが検出されました。(だから今まで型チェックをしていなかったのか、とここで全てを悟りました...)いくら AI が賢くなったとはいえ、普段の開発を進めつつこれらを一度に全て修正するのは、工数やレビュー負荷的に現実的ではありません。これを踏まえ、以下の方針で進めることにしました。
- これから新しく書かれるテストコードについては、最初から型チェックを必須にする。
- 既存のエラーは一括修正せず、少しずつ解消していく。
取り組み1:環境の整備
まずエラーのある既存ファイルを無視してでも、とにかく型チェックを行う方法を考えました。候補となったのは当該のファイル全てに @ts-nocheck を追加する方法と tsconfig.json の exclude にエラーのあるファイルを列挙する方法でしたが、前者は型エラーの解消状況が一覧で見えず追加して終わりになってしまう可能性があったため避けたいと考えました。
一方の後者ですが、そもそも exclude は型チェックを除外するための機能ではありません。実際に追加してみると tsc にコンパイルすべきプロジェクトコードとして認識されなくなり、パスエイリアスが解決されないためにエディターの定義・参照ジャンプも使えなくなってしまいました。
型チェックだけを除外するファイルを一覧で管理したいという 私の大変欲張りな 要求に応えるため、「テスト実行・開発用」と「型チェック用」の tsconfig を分けるアプローチを取りました。tsconfig にはベースのファイルを継承し一部を上書きする形で設定を記述する extends というオプションが用意されているため、これを活用して二重管理を回避しています。exclude はワイルドカードを使った一括指定も可能ですが、できるだけ小さく修正を進められるよう敢えてファイル単位で列挙しています。
実際の構成
tsconfig.vitest.json(元からあった vitest 用の tsconfig)- vitest が参照するファイル。
- テストファイルだけを
includeする。 - 今回何も変更していないので
vitestは今まで通り動作する。
tsconfig.vitest.todo.json(追加したもの)- CI での型チェック専用ファイル。
tsconfig.vitest.jsonをextendsしてexcludeのみをオーバーライドする。- 既存のエラーが出ているファイルを
excludeに列挙する。
// tsconfig.vitest.todo.json { "extends": "./tsconfig.vitest.json", "exclude": [ // 型エラーが残っているファイル。修正したら消す。 "tests/components/Button.spec.ts", "tests/components/Modal.spec.ts", // ... 他多数 ] }
package.json の scripts に以下を追加し、CI で型チェックを実施しました。
{ "scripts": { "type-check:test": "vue-tsc -p tsconfig.vitest.todo.json --noEmit" } }
これで既存のエラーファイルは無視しつつ、新規ファイルには CI で型チェックが行われる状態を実現できました。あとは暇を見てエラーを直し、 exclude リストからファイルを削除していくだけです。(年末に exclude 削除大会をやりたいですね。)
ちなみに tsconfig.vitest.todo.json というファイル名は、Rubocop(ラクスルで多用している Ruby のリンター)の .rubocop_todo.yml から名付けました。
取り組み2:型エラーの修正方針
環境整備を終えて型エラーの発生している既存コードを眺めていると、props の型不一致のような一般的なやり方で修正できるもの以外に、チーム全体で方針を定めたほうが良さそうなものを発見しました。
それは最も件数が多かった型エラーで、コンポーネントの private な(expose されていない)プロパティやメソッドにアクセスする際の次のようなコードで発生していました。
import { shallowMount } from '@vue/test-utils' const wrapper = shallowMount(Counter) expect(wrapper.vm.count).toEqual(0)
Vue Test Utils の wrapper の型には、明示的に expose されたプロパティしか含まれていません。しかし既存のテストにはコンポーネントの内部状態(例:vm.count)を確認する実装が多く、これが型エラーの原因となっていました。
チームでも議論の末、シンプルに @ts-expect-error を使う方針としました。
// @ts-expect-error: internal property access for testing expect(wrapper.vm.count).toEqual(0)
この他にもいくつかのアプローチを検討しました。Vue3 の script setup 構文では、defineExpose() を使って外部へ値やメソッドを公開できます。ですが今回のケースだとテストのためにアプリケーションコードを汚すことになり本末転倒なため選択肢から外しています。
- 型定義を拡張する
const wrapper: VueWrapper<Partial<typeof SomeComponent & { count: number }>> = ...のように型を付ける。- 実装変更に追従できず型安全"風"でしかない割に手間がかかる。悪くはないが、なんだか努力の方向が間違っていそう。
as anyやas unknownでキャストexpect(wrapper.vm.count as any).toEqual(0)no-explicit-anyルールに違反する- 修正箇所が多いため、こうした記述を使うことに抵抗がなくなっていく恐れがあり、禁断の選択肢のように思える。
@ts-expect-errorを使うas anyと本質的には近いが、型エラーが起きることは認識しているというニュアンスを出せる。- 同時にコメントを書くことで仕組み上仕方ないものであることが伝わりやすい。
@ts-ignoreを使う@ts-expect-errorとほぼ同じだが、型エラーが発生しなくなった場合に気づくことが出来る点で@ts-expect-errorの方が良い。
このようなケースでの対処方法を調べていくと、コンポーネントテストはその内部状態を確認するのではなく結果的に外部へもたらす振る舞い(DOM やイベントの発火)をテストすべきというのが近年の一般的な原則であることを知りました。*1 これを踏まえると、型エラーより先にテストの書き方自体を近年のベストプラクティスに沿うよう見直す必要がありそうです。(テスト自体を見直すことが最初の選択肢であるという認識を当時あまり持てていなかったと今になって気づきました。ブログ書いて良かった...!)
とはいえ現実解としてテストの実装を維持しつつ型エラーを抑制したいケースもあり得るため、方針としては必要だったのかなと思っています。
まとめ
今回の取り組みで、型エラー修正がチームのタスクとして認識されるようになりました。1ファイル単位で進めやすい仕組みのおかげで手が空いた隙に着手でき、(正直まだ終わりは見えませんが...)型エラー撲滅に向けて少しずつ修正が進んでいます。
フロントエンド開発において TypeScript がデファクトとなった今、新規でアプリケーションを開発する場合に当たり前に行う型チェックですが、型安全でないコードを積み重ねると後から是正するのは非常に大変だと身を以て感じました。