【Firebase】Nuxt × Firebase でUnitテストを実施する

この記事では、FirebaseとNuxtを組み合わせたプロジェクトでのユニットテスト導入にJestを使用する際の簡単な手順を説明します。

2024年04月10日
関連記事

これまで通り、こちらのリポジトリを元に実装していこうと思います。

以下、今回の対象となるやつらです。

test
├── Executor.ts
├── firestore
│   ├── setupTest.ts
│   └── vegetable.spec.ts
└── libs
    └── firebaseUtil.ts
coverage
└── firestore_rules
    └── vegetable.html
jest.config.js
tsconfig.test.json
firestore.rules

前提条件

  • Node.jsがインストールされていること。
  • Nuxtプロジェクトがすでにセットアップされていること。
  • Firebaseプロジェクトが設定されていること。
  • Typescriptを使っている

細かいとこは省きますので、ハンズオン形式でトライする場合は差分の確認をおすすめします。

セットアップ

まずは必要なパッケージを入れていきます。

$ yarn add @types/jest jest ts-jest -D

Firebaseのセキュリティルールに沿ってテストを書いていくのでこちらも。

$ yarn add @firebase/rules-unit-testing -D

テストケースの確認

まずはセキュリティルールを確認しましょう。

  • 書き込み権限は認証済みユーザーのみ許可。
  • 読み取り権限は未認証でも許可。
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    match /vegetables/{vegetableId} {
      allow get: if true;
      allow list: if true;
      allow create: if isAuthenticated();
      allow update: if isAuthenticated();
      allow delete: if isAuthenticated();
    }
  }
}

readとwrite権限だけでも動作するのですが、せっかくテストするので詳細に書いてあげましょう。

セキュリティルールを読み込む

前提として、127.0.0.1:8080 でfirestoreが起動していることが必要です。
ルート直下にある firestore.rules を読み込みます。

import {
  initializeTestEnvironment as _initializeTestEnvironment,
  RulesTestEnvironment,
} from '@firebase/rules-unit-testing'
import { readFileSync } from 'fs'

let testEnv: RulesTestEnvironment

export const initializeTestEnvironment = async (projectId: string) => {
  testEnv = await _initializeTestEnvironment({
    projectId,
    firestore: {
      host: '127.0.0.1',
      port: 8080,
      rules: readFileSync('firestore.rules', 'utf8'),
    },
  })
}

export const getTestEnv = () => testEnv

ルールに合わせて共通処理を

test/Executor.ts にセキュリティルールでやることをまとめておき、各specで使い回します。

import { type KeyPair } from '@/types/KeyPair'
import { FirebaseFirestore } from '@firebase/firestore-types'
import {
  QuerySnapshot,
  deleteDoc,
  doc,
  getDoc,
  getDocs,
  setDoc,
  updateDoc
} from 'firebase/firestore'

export default class Executor {
  db: FirebaseFirestore
  collectionName: string

  constructor(
    db: FirebaseFirestore,
    collectionName: string
  ) {
    this.db = db
    this.collectionName = collectionName
  }

  collectionRef() {
    return this.db.collection(this.collectionName)
  }

  documentRef(id: string) {
    return doc(this.collectionRef(), id)
  }

  async get(id: string) {
    return getDoc(this.documentRef(id))
  }

  async all(): Promise<QuerySnapshot> {
    return getDocs(this.collectionRef())
  }

  async create(id: string, data: KeyPair): Promise<void> {
    return setDoc(this.documentRef(id), data)
  }

  async update(id: string, data: KeyPair): Promise<void> {
    return updateDoc(this.documentRef(id), data)
  }

  async delete(id: string): Promise<void> {
    return deleteDoc(this.documentRef(id))
  }
}

テストを書いていく

まず、セキュリティルールからどのようにテストを書いていくかを考えていきます。
権限は以下の通り。

認証済み 未認証
get ⚪︎ ⚪︎
list ⚪︎ ⚪︎
create ⚪︎ ×
update ⚪︎ ×
delete ⚪︎ ×

認証済み、未認証をdescribeで括ります。
難しいことはしません。firebaseが用意してくれている assertSucceeds, assertFails 関数で確認していきましょう。

import Executor from '@/test/Executor'
import { FirebaseFirestore } from '@firebase/firestore-types'
import {
  assertSucceeds,
  assertFails,
} from '@firebase/rules-unit-testing'
import { getTestEnv } from '@/test/libs/firebaseUtil'
import { setupTest } from '@/test/firestore/setupTest'
import { Vegetable } from '@/models/Vegetable'

import {
  beforeEach,
  describe,
  it,
} from '@jest/globals'

setupTest('vegetable')

describe('Firestore Vegetable rules', () => {
  let db: FirebaseFirestore
  let executor: Executor

  beforeEach(async () => {
    await getTestEnv().withSecurityRulesDisabled(async context => {
      const adminDB = context.firestore()
      executor = new Executor(adminDB, Vegetable.MODEL_NAME)
      const vegetables = [
        { id: 'vege_1_id', name: 'carrot' },
        { id: 'vege_2_id', name: 'pumpkin' },
        { id: 'vege_3_id', name: 'potato' },
      ]
      for (const vege of vegetables) {
        await executor.create(vege.id, { name: vege.name })
      }
    })
  })

  describe('認証済み', () => {
    beforeEach(async () => {
      db = getTestEnv().authenticatedContext('user_01').firestore()
      executor = new Executor(db, Vegetable.MODEL_NAME)
    })

    it('取得:全てできること', async () => {
      await assertSucceeds(executor.get('vege_1_id'))
      await assertSucceeds(executor.get('vege_2_id'))
      await assertSucceeds(executor.get('vege_3_id'))
    })

    it('一覧:全てできること', async () => {
      await assertSucceeds(executor.all())
    })

    it('登録:全てできること', async () => {
      await assertSucceeds(executor.create('new_id', { name: 'new_name' }))
    })

    it('更新:全てできること', async () => {
      await assertSucceeds(executor.update('vege_1_id', { name: 'update_name' }))
      await assertSucceeds(executor.update('vege_2_id', { name: 'update_name' }))
      await assertSucceeds(executor.update('vege_3_id', { name: 'update_name' }))
    })

    it('削除:全てできること', async () => {
      await assertSucceeds(executor.delete('vege_1_id'))
      await assertSucceeds(executor.delete('vege_2_id'))
      await assertSucceeds(executor.delete('vege_3_id'))
    })
  })

  describe('未認証', () => {
    beforeEach(async () => {
      db = getTestEnv().unauthenticatedContext().firestore()
      executor = new Executor(db, Vegetable.MODEL_NAME)
    })
    it('取得:全てできること', async () => {
      await assertSucceeds(executor.get('vege_1_id'))
      await assertSucceeds(executor.get('vege_2_id'))
      await assertSucceeds(executor.get('vege_3_id'))
    })

    it('一覧:全てできること', async () => {
      await assertSucceeds(executor.all())
    })

    it('登録:全てできないこと', async () => {
      await assertFails(executor.create('new_id', { name: 'new_name' }))
    })

    it('更新:全てできないこと', async () => {
      await assertFails(executor.update('vege_1_id', { name: 'update_name' }))
      await assertFails(executor.update('vege_2_id', { name: 'update_name' }))
      await assertFails(executor.update('vege_3_id', { name: 'update_name' }))
    })

    it('削除:全てできないここと', async () => {
      await assertFails(executor.delete('vege_1_id'))
      await assertFails(executor.delete('vege_2_id'))
      await assertFails(executor.delete('vege_3_id'))
    })
  })
})

注意

ちょっとしたポイントをまとめておきます。

  • 認証済みは authenticatedContext で設定。
  • 未認証は unauthenticatedContext を使おう。
  • setupTestはケバブケースで書こう。

実行方法

package.jsonに "test": "jest" と設定してあるので、yarn test で実行します。
以下のように出力されるはず。

 PASS  test/firestore/vegetable.spec.ts (5.831 s)
  Firestore Vegetable rules
    認証済み
      ✓ 取得:全てできること (554 ms)
      ✓ 一覧:全てできること (101 ms)
      ✓ 登録:全てできること (95 ms)
      ✓ 更新:全てできること (105 ms)
      ✓ 削除:全てできること (107 ms)
    未認証
      ✓ 取得:全てできること (152 ms)
      ✓ 一覧:全てできること (100 ms)
      ✓ 登録:全てできないこと (84 ms)
      ✓ 更新:全てできないこと (142 ms)
      ✓ 削除:全てできないここと (87 ms)

Test Suites: 1 passed, 1 total
Tests:       10 passed, 10 total
Snapshots:   0 total
Time:        5.911 s, estimated 6 s

カバレッジは coverage/firestore_rules/xxx.html のように出力されます。

筆者情報
IT業界経験6年目のフルスタックエンジニア。
フロントエンドを軸として技術を研鑽中でございます。