はじめに
私は個人開発でFirestoreを使っているのですが、恥ずかしながらセキュリティルールをあまり理解しておらず、テストも書いたことがありませんでした。
そんなとき、たまたま YouTubeにFirestore設計入門の動画を投稿したというツイート を見て、軽い気持ちで視聴しました(おい)。
動画 ではTypeScript + Jestを使ってサクサクテストを書いていてわかりやすく、これなら私でも書けると思い、実際に個人開発で使っているセキュリティルールのテストを書いてみました。
本記事では私がどのような観点でテストを書いたかを中心に、Firestoreのオペレーションのひとつである list
のテストについて解説します。
対象読者
本記事は、以下のような方に向けて執筆しています。
- Firestoreを使ったことがあり、セキュリティルールについて理解している
- セキュリティルールのテストを書いたことがなく、書きたいと思っている
- どのような観点で
list
のテストを書いていいかわからない
Firestoreのオペレーション
Firestoreの知識がある前提で執筆していますが、オペレーションだけ説明します。
Firestoreには5種類(+2)のオペレーションがあります。
-
write
以下の3つをまとめたオペレーション-
create
ドキュメントを作成する -
update
ドキュメントを更新する -
delete
ドキュメントを削除する
-
-
read
以下の2つをまとめたオペレーション-
get
ドキュメントを単体で取得する -
list
ドキュメントをまとめて取得する
-
当たり前ですが、CRUD操作を網羅しています。
セキュリティルールは、あるコレクションやドキュメントに対し、どのような条件でオペレーションを許可するかを記述します。
そのため、すべてのオペレーションを理解していないと、セキュリティルールを適切に表現できません。
不用意に write
や read
を許可するのでなく、必要なオペレーションのみ許可するのが望ましいです。
セキュリティルールはできる限り厳しくする ことを意識すると、どのように書けばいいかわかってくると思います。
私が個人開発しているアプリ
宣伝のため Firestoreをどのように使っているかわからないと解説が伝わりにくいため、私が個人開発しているアプリについて、 少しだけ 説明します。
概要
「ウホーイ図鑑」という、私が描いたキャラクターを閲覧するだけのアプリですw
OSSで開発しており、かんたんに開発環境を構築できるので、よかったらデバッグしてみてください。
まだ master
ブランチにマージしていないので、 develop
ブランチをチェックアウトしてください。
- iOS
https://github.com/uhooi/UhooiPicBook/tree/develop - Android
https://github.com/uhooi/UhooiPicBook-Android/tree/develop
PRやIssue、ソースレビューは特にルールを設けていないので、お気軽に送ってください!
画面一覧
主な画面は以下の2つです(スクリーンショットはiOS)。
キャラクター一覧 | キャラクター詳細 |
---|---|
私が描いたキュートなキャラクターを図鑑として見ることができる、非常に素晴らしいアプリ ということがわかります。
Firestoreの使われ方
ウホーイ図鑑では、キャラクターデータの配信にFirestoreを使っています(やっと本題)。
画像はCloud Storage for Firebaseに配置し、画像のURLをFirestoreに格納することで、Firebaseとのやりとりをキャラクターの全件取得( list
)の1回のみに抑えています。
Firestoreのセキュリティルールは以下の通りです。
本記事ではこのセキュリティルールのテストを書きます。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /monsters/{monsterId} {
allow list;
}
}
}
見てわかるように、 /monsters/{monsterId}
の list
のみ許可しています。
データはすべて私がコンソール上から手動で登録しています(フィールド名を間違えたりと、けっこう大変でミスが多い、、)。
Firestoreの設計については体系的に学んでいないため、さらにいい設計があるかもしれません。
例えば、キャラクターの表示順を order
で表していますが、手動で採番するのが大変なので、もっといい方法があれば知りたいです。
セキュリティルールのテスト観点を考える
先ほど紹介した動画ではTODOアプリの開発にFirestoreを使っており、 「ユーザーにおかしなデータを登録させない」という観点 で create
と update
のバリデーションのテストを書いていました。
例えば、「TODOのタイトルが文字列かつ20文字以下だと登録でき、21文字以上だと登録できない」のようなテストです。
Firestoreのセキュリティルールは、RDBでいう「スキーマ定義」に近いと思うので、このようなテストは仕様を把握する上でも有用です。
ただ、ウホーイ図鑑はユーザーがデータを操作できないため、TODOアプリと同様の観点ではテストできません。
「ユーザーがデータを追加・更新・削除できず、一覧取得のみ行える」という観点 でテストを書きました。
list
のテストケースを洗い出す
(「オペレーション」という言葉が長いので、ここからは単に「操作」と呼びます)
まず、 list
に必要なテストケースを洗い出します。
「 /monsters
コレクションに対する list
のみ成功し、他の操作および他のコレクションに対するすべての操作が失敗する」ことが確認できればいいはずであり、「他の操作」と「他のコレクション」を洗い出せば必要なテストケースがわかります。
「他の操作」は、先述した通り create
・ update
・ delete
・ get
の4つです。
「他のコレクション」は、「まったく別のコレクション」と「 /monsters
コレクション内のドキュメントのサブコレクション」の2つに分けて考えます。
理由として、セキュリティルールの書き方によっては操作を再帰的に許可するためです。
当初、セキュリティルールで match /monsters/{document=**} { allow list; }
のように書いていました。しかし、このようにワイルドカードを使うと、 /monsters
コレクションで list
を再帰的に許可します。
今回は /monsters
コレクション直下のみ list
を許可したいので、 match /monsters/{monsterId} { allow list; }
と書き、「サブコレクションに対するすべての操作が失敗する」ことをテストで確認します。
「他の操作」と「他のコレクション」を洗い出せたので、テストケースに漏れとダブりがないか(MECEになっているか)確認するため、洗い出したことを表に起こします。
サブコレクションを /monsters/document/subcollection
、まったく別のコレクションを /ohters
で表しています。
コレクション | create |
update |
delete |
get |
list |
---|---|---|---|---|---|
/monsters |
× | × | × | × | ○ |
/monsters/document/subcollection |
× | × | × | × | × |
/ohters |
× | × | × | × | × |
これで網羅できているかわかりやすくなりました。
私は網羅できていると思ったので(できていなかったら教えてください)、3×5=15つのテストケースを書いていきます。
セキュリティルールのテストを書く
動画を投稿された方がGitHubでテンプレートを公開している ので、こちらと公式ドキュメントなどを参考にテストを書きました。
GitHubで公開し、READMEに沿えばローカルでテストを実行できるので、ぜひ試してみてください。
https://github.com/uhooi/UhooiPicBook-Firebase/blob/develop/tests/monsters-tests.ts
GitHubで公開しているテストコードをそのまま載せます。
長いし、本記事ではコードを解説しない(しようと思ったのですが、 疲れてきた 記事が長くなり過ぎたのと、私がGitHubのREADMEで上げている参考資料を見た方がわかりやすいので、しないことにします)ので、折りたたんでおきます。
TypeScript + Jestで書いています。 私が実装で苦戦したポイントを箇条書きにします。セキュリティルールのテストコード
BDDのテストフレームワークはあまり使ったことがないのですが、仕様書代わりになるのがいいと思いました。
Firestoreのセキュリティルールだけだと仕様が見えづらいので、特に有用です。import * as firebase from '@firebase/testing'
import * as fs from 'fs'
//#region Types
type Auth = {
uid?: string,
[key: string]: any
}
type Monster = {
name: string,
description: string,
base_color: string,
icon_url: string,
dancing_url: string,
order: number
}
//#endregion
//#region Consts
const projectId = 'uhooipicbook'
const databaseName = 'uhooipicbook'
const rules = fs.readFileSync('./firestore.rules', 'utf8')
const authedApp = (auth?: Auth) => firebase.initializeTestApp({ projectId: projectId, databaseName, auth }).firestore()
const adminApp =firebase.initializeAdminApp({ projectId: projectId, databaseName }).firestore()
const coverageUrl = `http://localhost:8080/emulator/v1/projects/${projectId}:ruleCoverage.html`
//#endregion
//#region TestCase Life-Cycle Methods
beforeAll(async () => {
await firebase.loadFirestoreRules({ projectId: projectId, rules })
})
beforeEach(async () => {
await firebase.clearFirestoreData({ projectId: projectId })
})
afterAll(async () => {
await Promise.all(firebase.apps().map(app => app.delete()))
console.log(`View rule coverage information at ${coverageUrl}\n`)
})
//#endregion
//#region Test Methods
describe('/monsters', () => {
describe('create', () => {
it('can not create', async () => {
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('monsters').doc('uhooi').set(monster))
})
})
describe('update', () => {
it('can not update', async () => {
await configureTestData('monsters', 'uhooi')
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('monsters').doc('uhooi').set(monster))
})
})
describe('delete', () => {
it('can not delete', async () => {
await configureTestData('monsters', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('monsters').doc('uhooi').delete())
})
})
describe('get', () => {
it('can not get', async () => {
await configureTestData('monsters', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('monsters').doc('uhooi').get())
})
})
describe('list', () => {
it('can get list', async () => {
await configureTestData('monsters', 'uhooi')
const db = authedApp(null)
await firebase.assertSucceeds(db.collection('monsters').get())
})
})
describe('/document/subcollection', () => {
describe('create', () => {
it('can not create', async () => {
await configureTestData('monsters', 'document')
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').set(monster))
})
})
describe('update', () => {
it('can not update', async () => {
await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').set(monster))
})
})
describe('delete', () => {
it('can not delete', async () => {
await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').delete())
})
})
describe('get', () => {
it('can not get', async () => {
await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').get())
})
})
describe('list', () => {
it('can not get list', async () => {
await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').get())
})
})
})
})
describe('/others', () => {
describe('create', () => {
it('can not create', async () => {
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('others').doc('uhooi').set(monster))
})
})
describe('update', () => {
it('can not update', async () => {
await configureTestData('others', 'uhooi')
const db = authedApp(null)
const monster = createTestMonster()
await firebase.assertFails(db.collection('others').doc('uhooi').set(monster))
})
})
describe('delete', () => {
it('can not delete', async () => {
await configureTestData('others', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('others').doc('uhooi').delete())
})
})
describe('get', () => {
it('can not get', async () => {
await configureTestData('others', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('others').doc('uhooi').get())
})
})
describe('list', () => {
it('can not get list', async () => {
await configureTestData('others', 'uhooi')
const db = authedApp(null)
await firebase.assertFails(db.collection('others').get())
})
})
})
//#endregion
//#region Other Methods
function configureTestData(collectionId: string, documentId: string) {
const db = adminApp
return db.collection(collectionId).doc(documentId).set(createTestMonster())
}
function configureSubCollectionTestData(collectionId: string, documentId: string, subCollectionId: string, subDocumentId: string) {
const db = adminApp
return db.collection(collectionId).doc(documentId).collection(subCollectionId).doc(subDocumentId).set(createTestMonster())
}
function createTestMonster(): Monster {
return {
name: 'uhooi',
description: 'ゆかいな みどりの せいぶつ。\nわるそうに みえるが むがい。',
base_color: '#FFFFFF',
icon_url: 'https://example.com/example.png',
dancing_url: 'https://example.com/example.gif',
order: 1
}
}
//#endregion
projectId
と databaseName
に何を指定すればいいかわからない
私は本番環境に繋がずローカルでテストを実行しているので、何を指定しても問題ないようです
注意として、 projectId
に大文字を含むとエラーになります
(エラーログから原因がわからないので、だいぶ苦戦しました…)create
を許可していないので、テストデータを作成できない
adminApp
を使うことでテストデータを作成できましたupdate
のテストが create
扱いになる
私がasync/awaitを正しく理解していないからでした、、上記のコードは修正済みです
セキュリティルールを変更してテストを実行し、テストが失敗するかも確認しました
例えば allow create, list;
に変更して create
のテストが失敗するなどです
オペレーションの理解も深まったので、おすすめです
テスト終了時にHTMLで出力されるのですが、すぐにエミュレータがシャットダウンするため、確認する前に消えてしまいます
解決方法を知っている方がいたら教えてほしいです
テストの実行結果です。
BDDでは、 description
や it
を使ってテストを階層化することで、テスト結果の可読性が上がります。
私はBDDに慣れていないので、自分では読みやすいと思って書きましたが、このような階層の分け方が適切なのかはわかりません。
もし他に適切な分け方があれば教えていただけると嬉しいです。
おわりに
セキュリティルールのテストの書き方について、少しでも参考になれば幸いです。
慣れてくるとここまでテストを書く必要がなくなるかもしれませんが、「ユーザーはデータをまとめて取得することのみでき、追加・更新・削除・単体での取得はできない」ことが保証できたので、私にとっては必要でした。
みなさんはどのような観点でセキュリティルールのテストを書いているでしょうか?
もし今回紹介した観点以外で書いていたら、ぜひ教えていただきたいです。