🔥

Vitest × Firebase (× Nuxt3)(× Bun)でAPIのテストを作ろうとしてちょっと苦労したのでまとめる

2024/08/20に公開

はじめに

さて、うちは個人開発ということもあり、これまでテスト用ライブラリを使用したテストコードは書いてきませんでした。
が、最近他社に協力して医療系のシステムに携わることになり、また弊社で開発している開発支援システム「N-Dev」にもテストの自動生成機能を付けたいと考え、テストに入門することにしました。
今回は時代に合わせてライブラリとして「Vitest」を選択しましたが、「Jest」含めその他のライブラリも使ったことはないテスト童貞ですので、お手柔らかにお願いします。

なお、Nuxt3が公式に対応しているテストライブラリは「Vitest」のみです。

Nuxt × テスト のパターン分け

Nuxtでテストすると言っても、Nuxt特有の機能をテストするのと、関数やAPIなど、必ずしもNuxtと関係のない部分のテストでは必要なものが異なります。まずはそれを区分してみようと思います。

関数など単純なコードのテスト(Vitest)

プロジェクトがNuxtであっても、検証する対象が関数などのNuxt特有でない機能の場合は、「Vitest」ライブラリ単体で事足ります。Nuxt用の特別な設定は不要です。
例えば以下のサイトの前半や、Vitest公式を参考にすれば簡単に取り掛かることができると思います。
https://zenn.dev/kazu1/articles/003120c0d53e5a

具体的には、「vitest」をインストールし、「.test」を含むファイルを作成してテストコードを記述。「vitest」コマンドを実行すれば完了です。

Nuxtのコンポーネントのテスト(@nuxt/test-utils)

Nuxtのコンポーネントのテストについては、Nuxt3の公式ページにもある「@nuxt/test-utils」などを活用します。
少し手間がありますが、モックを作成する関数など様々な便利な関数がありますし、他の記事なども比較的充実していますので参考にされてください。

なお、このテストは今回の私の目当てとは異なりましたので検証しておりません。

https://qiita.com/yuki_s_14/items/be8416bd392065fda30a
https://developer.mamezou-tech.com/blogs/2024/02/07/nuxt3-unit-testing-mount/

NuxtのuseState等を含んだテスト(@nuxt/test-utils or Vitest)

Nuxt特有の機能として「useState」という状態管理が可能な関数があります。テストコードからはこういった特有の関数についてはアクセスできず、モックを作成して対応する必要があります。

この辺りが様々な問題が絡み合って随分と苦労をしました。
なお、Nuxt3公式にはテストで「useState」を使用する方法には言及がなく、Issue等に対してもそれは対応範囲外である、というような反応が見受けられるように思います。

さて、結論から申し上げると「@nuxt/test-utils」を使用した方法は上手くいきませんでした。
加えて、ランタイムにBun.jsを使用した場合に、例えば以下のようなエラーが出て上手くいきませんでした。

Segmentation fault at address 0x5
vi.stubGlobal is not a function. (In 'vi.stubGlobal("useState", useStateMock)', 'vi.stubGlobal' is undefined)

一方、npmを使用した場合はそういったエラーは無く、一つの解決方法として、以下の記事を参考にして「Vitest」単体で用いながら、「useState」を自前でモックすると上手くいきます。
以下の記事は少し古いですが、問題ありません。
なお、Bun周りは「Bunの話」でもう少し詳細に書きます。

https://zenn.dev/ninebolt6/articles/cadc924cb2416d

APIのテスト(Vitest × Firebase)*メイン

さて、色々書いてきましたが今回の目的はAPIをテストすることです。果たしてAPIのテストをVitestで行うべきか、という問題はありますが、Nuxt3ではせっかくフロントとバックを同じプロジェクトの中で一元管理・開発できるわけですので、その中でテストも済ませたい、という考えです。ご了承ください。

まず、「@nuxt/test-utils」を使用するか「Vitest」単体で使用するかという問題ですが、正直「@nuxt/test-utils」はちょっと不安定だと感じ、また今回の目的には必須ではなかったので「Vitest」単体で用います。

この時の一番の問題になったのはAPI操作に必要な「Firebase Authentication(以下 Auth)」で発行するトークンをどのように取得するかという点です。

まず、弊社(及びN-Dev)の想定するAPIでは、以下のようにヘッダにAuthで発行されたトークンを突っ込んで、サーバ側で検証します。

headers: {
    'Content-Type': 'application/json',
    'Authorization': `token ${idToken}`,
},

が、テストコード中ではログイン操作をしていない状態ですので、トークンを発行してやる必要があります。そのあたりを含めたコードを以下に示します。

/test/utils.ts
// admin
import {cert, initializeApp} from "firebase-admin/app";
import {getAuth} from "firebase-admin/auth";
// client
import {FirebaseApp, initializeApp as clientInitializeApp, type FirebaseOptions} from "firebase/app";
import {getIdToken, signInWithCustomToken, getAuth as getClientAuth} from "firebase/auth";

//NOTE:
import serviceAccount from "~/server/firebase-adminsdk.json";
//NOTE:
const API_ENDPOINT = "http://localhost:3000";
const adminApp = initializeApp({
    credential: cert(serviceAccount)
});
// Firebase Authの取得
const adminAuth = getAuth(adminApp);

let firebaseConfig: FirebaseOptions = {
    apiKey: "XXXXXXXXXXXXXXXXXXXXXXX",
    projectId: "xxxx-xxxx",
    storageBucket: "xxxx-xxxx.appspot.com",
    messagingSenderId: "123456789",
    appId: "1:123456789:web:xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    measurementId: "G-XXXXXXXXXXXXXXXXXXXXX",
};
const firebaseApp: FirebaseApp = clientInitializeApp(firebaseConfig);
const clientAuth = getClientAuth()

// UIDからidTokenの取得(Admin)
export const getIdTokenByUid = async (uid: string) => {
    const customToken = await adminAuth.createCustomToken(uid);
    const {user} = await signInWithCustomToken(clientAuth, customToken);
    return await getIdToken(user, false);
}

export const callApi = async (method: "POST" | "PUT" | "DELETE", endpoint: string, idToken: string, params: Dictionary) => {
    let r: Response;
    r = await fetch(`${API_ENDPOINT}${endpoint}`, {
        method: method,
        body: JSON.stringify(params),
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `token ${idToken}`,
        },
    });
    return await r.json();
}

このコードのほとんどは以下のサイトを参考にし、補完したものです。
https://www.memory-lovers.blog/entry/2024/03/06/174149

テストにFirebaseのサーバのライブラリとクライアントのライブラリの両方を読み込んで、任意のUIDからidTokenを取得します。

APIはcallApiという関数を作成し、Nuxt3らしくFetchApiでAPIをたたいてます。

後は例えば以下のようにテストを書いてあげてください(適当)。

/test/api.test.ts
import {describe, expect, it, vi} from 'vitest'
import {reactive, toRef, isRef, Ref} from 'vue'
import {getIdTokenByUid, callApi} from "~/test/utils"

const uid = "asdfghjklzxcvbnm"
let idToken = await getIdTokenByUid(uid)

// Nuxtのpayloadの一部をmockする
const payload = reactive<{ state: Record<string, any> }>({
    state: {},
})

// useStateをmockする
const useStateMock = vi.fn((key: string, init?: () => any) => {
    const state = toRef(payload.state, key)
    if (state.value === undefined && init) {
        const initialValue = init()
        if (isRef(initialValue)) {
            payload.state[key] = initialValue
            return initialValue as Ref<any>
        }
        state.value = initialValue
    }
    return state
})

// モックをグローバルに使えるようにする
vi.stubGlobal('useState', useStateMock)

describe('api app', async () => {
    let appId: number = 0
    await it('addApp', async () => {
        let res = await callApi("POST", "/api/app/addApp", idToken, {
            item: {
                appName: "testApp",
                appDescription: "made by test code with vitest.",
            },
        } as Dictionary)
        // console.log("res", res)
        expect(res.error).toBe(false)
        appId = res.data
        console.log("appId", appId)
    })
    await it('updateApp', async () => {
        let res = await callApi("POST", "/api/app/updateApp", idToken, {
            item: {
                appId: appId,
                appName: "testAppUpdated",
                appDescription: "updated by test code with vitest.",
            },
        });
        // console.log("res", res);
        expect(res.error).toBe(false)
    })
    await it('getApp', async () => {
        let res = await callApi("POST", "/api/app/getApp", idToken, {})
        // console.log("res", res)
        expect(res.error).toBe(false)
        expect((res.data.pop()).appName).toBe("testAppUpdated")
    })
    await it('deleteApp', async () => {
        let res = await callApi("POST", "/api/app/deleteApp", idToken, {
            appId: appId,
        })
        // console.log("res", res);
        expect(res.error).toBe(false)
    })
})

こちらもモックの部分は前にあげた参考サイトからほぼそのままです。
なお、Nuxt3としての都合もあり、APIはRESTではありません。すべてPOSTでやり取りしてます。

各詰まった個所

Vitestコマンドの話

Vitestでテストを行うコマンドに「vitest」があります。
ところが、以下のように設定したうえで、「npm run test」を実行するのと、コンソールで「vitest」とするのと、Bun.jsをランタイムとして使用してIDE(WebStorm)から実行するので結果が変わります、、、なんで

package.json
"scripts": {
    "vitest": "vitest",
}

「vitest」コマンドについては公式見てもらうとわかるのですが、「開発環境ではウォッチ モード、CI では実行モードに自動的に切り替わります。」うーん余計なお世話。

「vitest run」とすればウォッチモード無しで実行できるので、ウォッチモードにしたくない場合はこちらを、ウォッチモードにしたい場合は「vitest watch」か「vitest dev」を使用するとよいでしょう。この二つは同じです。

Bunの話

次、恐らくBunのせいだと思うのですが、Issueもあったし、Bunで実行すると「Segmentation fault at address 0x5」というエラーが出て動かないことがありました。Vitestでskipやonlyを使用したときに発生しましたが、理由はよくわかってません。また、package.jsonに

package.json
"scripts": {
    "vitest": "vitest run",
}

と書いたときは上手くいくのですが、

package.json
"scripts": {
    "test": "vitest run",
}

と書いたら「vi.stubGlobal is not a function. (In 'vi.stubGlobal("useState", useStateMock)', 'vi.stubGlobal' is undefined)」のエラーが出ました、、、???

私が知らない仕様があるのでしょうか。ある気もする。
取り合えずnpmで実行するとエラーは出ません。

パスの話

これは普通に設定の話ですが、IDEから実行するときとコンソールから実行するときでホームディレクトリが変わっていて、Nuxtでは「~/」みたいなパスの指定、というかエイリアスを使用すると思いますが、この辺りがうまく動作しないので、「vitest.config.ts」に例えば以下のように書いてあげる必要があります。

vitest.config.ts
import * as path from "path";
import { defineConfig } from 'vite'

export default defineConfig({
    test: {
        alias: {
            "@": path.resolve(__dirname, "./"),
            "~": path.resolve(__dirname, "./"),
        },
    },
})

教訓

Nuxt3のオートインポートってあんまりよくない
自動化って実は不親切
中途半端な発展ライブラリよりはしっかりした基礎ライブラリ

...Nuxtはまだまだだね
Bunも
Nuxtはパッと見かなり少人数で管理されているので唐突になくなりゃしないかと心配

Discussion