Cypress × Firebase × Angular の組み合わせのE2Eをlocalで実行する
AngularとFirebaseを使ったアプリケーションを作成し,CypressでE2Eを回したかったのですが,この構成のE2EをLocalで実行する方法を探すのに苦労したのでまとめようと思います.構成のイメージは以下の通りです.
今回はLocalでFirebaseを起動できるFirebase Emulatorを用いしました.
アプリケーションの用意
Angularアプリケーション
まずはAngularのアプリケーションを作成します.今回はFirestoreから取得したメッセージを表示するだけの単純なアプリケーションを作ります. 詳しくはこちらの公式サイトを見ていただきたいのですが以下のように雛形を作成します.
npm install -g @angular/cli // angular-cliのinstall ng new new-app //アプリケーションの雛形作成
app-component.html
を以下のようにシンプルにします.
<p> メッセージ: 「ここにFirestoreから取得したメッセージを表示できるようにする」 </p>
試しにng serve
でアプリケーションを起動してみてください
angular/fire
こちらを参考にangularのアプリケーションでfirebaseを操作する為にangular/fireをinstallします.
ng add @angular/fire
こちらでerrorが起きてしまう場合はnpmでinstallしてみてください.
npm install --save @angular/fire
次にfirebaseの設定を記述します.Angularではenviroment.prod.ts
に本番用の設定を,envirment.ts
に開発用の設定を記述します.(enviroment.local.tsなど開発でもさらに分けることも可能である)useEmulators
はemulatorを使用するかで設定を書き換えないといけないので,その判断に利用するbooleanです.
enviroment.prod.ts
には,実際にfirebaseのプロジェクトを作成し,以下の値を埋めて下さい.
export const environment = { production: true, useEmulators: false, firebase: { apiKey: '<your-key>', authDomain: '<your-project-authdomain>', databaseURL: '<your-database-URL>', projectId: '<your-project-id>', storageBucket: '<your-storage-bucket>', messagingSenderId: '<your-messaging-sender-id>', appId: '<your-app-id>', measurementId: '<your-measurement-id>' } };
今回はlocalでfirebase emulatorを使うので,envirment.ts
には基本的にダミーの値を入れて頂いて大丈夫ですが,appIdだけは実際のものを使った方が良いです.後でemulatorつなげるときここをダミーにするとうまく繋がりませんでした.
export const environment = { production: false, useEmulators: true, firebase: { apiKey: 'api-key', authDomain: 'domain', databaseURL: 'database-url', projectId: 'new-app', storageBucket: 'storage-bucket', messagingSenderId: 'messaging-sender-id', appId: 'app-id', measurementId: 'measurement-id' } };
そしてapp.module.ts
は以下のようになります
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { AngularFireModule } from '@angular/fire'; import { AngularFirestoreModule } from '@angular/fire/firestore'; import { environment } from 'src/environments/environment'; import { SETTINGS as FIRESTORE_SETTINGS } from '@angular/fire/firestore'; import { USE_EMULATOR as USE_FIRESTORE_EMULATOR } from '@angular/fire/firestore'; @NgModule({ declarations: [ AppComponent, ], imports: [ BrowserModule, AngularFireModule.initializeApp(environment.firebase), AngularFirestoreModule ], // userEmulatorsのときlocalhostに向ける providers: [ { provide: USE_FIRESTORE_EMULATOR, useValue: environment.useEmulators ? ['localhost', 8080] : undefined }, ], bootstrap: [AppComponent] }) export class AppModule { }
この設定の上書きの仕方はどのバージョンのfirebaseを使っているかで変わるので詳しくはドキュメントを確認して下さい.
次に実際にFirestoreに値を取りに行く処理を実装します.
import { Injectable } from "@angular/core"; import { AngularFirestore } from "@angular/fire/firestore"; import { Observable } from "rxjs"; export interface Message { content: string; } @Injectable({ providedIn: 'root' }) export class FirebaseService { constructor(private firestore: AngularFirestore) { } getValueChanges(): Observable<Message[]> { return this.firestore.collection<Message>('messages').valueChanges(); } }
@Injectable({ providedIn: 'root' })
でDIコンテナの管理下にこのクラスを置くことができます.
また今回はcontent
とい文字列を持つmessages
というコレクションを用意することにします.
そしれこれをhtml上で表示できるようにapp.component.ts
とapp.componetn.html
を以下のように書き換えます.
import { OnInit } from '@angular/core'; import { Component } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { FirebaseService } from './firebase-service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { title = 'new-app'; message: Observable<string>; constructor(private firebaseService: FirebaseService) { } ngOnInit(): void { this.message = this.firebaseService.getValueChanges().pipe( map(value => value[0].content) ) } }
<p> メッセージ: 「{{message | async}}」 </p>
| async
はasync pipeというのですが,こうすることで非同期で値が取れたタイミングで値を表示してくれますし,obseableのunsubscribe
をcomponentが破棄されるタイミングで勝手に呼んでくれます.
http-server
Angularのアプリケーションはng serve
でも起動できるのですが,実際の本番環境ではビルド後のアプリケーションをサーバで起動すると思うので,その状態に近づける為ここではhttp-server
を使ってアプリを起動します.http-server
は以下のようにinstallします.
npm install -g http-server
installできたら
ng build npx http-server dist/new-app -p 4200
でアプリを起動することができます.
E2Eの用意
続いてこのメーセージが表示されることをE2Eテストを書いて確かめようと思います.
Cypress
まずはe2eのプロジェクトを作り,そこにCypressをインストールします.参考にしたサイトはこちらです
mkdir e2e cd e2e npm init -y //現在のディレクトリをnpmの管理下にする npm install cypress typescript //cypressのインストール npx tsc --init --types cypress --lib dom,es6 //tsconfig.jsonを作成 echo {} > cypress.json
以上のコマンドを実行したらpacage.json
に以下のscriptを足してください.
"local:open": "cypress open", "local:run": "cypress run"
そして以下のコマンドを実行するとCypressが起動し,cypress
というディレクトリが作成されます.
npm run local:open
cypress/integration
とcypress/fixture
ディレクトリは削除して頂いて大丈夫です.
Cypressはcypress/integration
以下にテストを配置する仕様になっています.そこにディレクトリを作ってテストを分けることもできます.
以下のようなmessage.ts
を作成します.
describe('ホーム画面 ', () => { it('メッセージを見ることができる', () => { cy.visit('http://localhost:4200/'); cy.contains('こんにちは').should('be.visible') // firestoreに「こんにちは」という値を入れてテストをする予定 }) })
アプリケーションをng serve
で起動してテストを実行してみましょう.
以下の画面でファイル名を押すと実行できます.
firebase-emulatorの起動
次にfirebase-emulatorを実行できるようにしましょう. firebase cliをinstallします.
npm i -g firebase-tools
そしてfirebase用の初期化処理を以下で実行します.
firebase init
基本的に案内にそって進んで行くだけです.このときfirebaseのアカウントとプロジェクトが必要となるので,テスト対象のプロジェクトを選択してください.また今回はfirestoreしか使わないのでそこにチェックを入れてinstallしました.
次にfirebaseのfirestoreと管理画面を開くポート番号の設定をfirebase.json
を作成し,記述します.
{ "emulators": { "firestore": { "port": 8080 }, "ui": { "enabled": true, "port": 4000 } } }
package.json
のscriptに下記コマンドを足してください.
"emulators": "firebase emulators:start"
ここまでくれば下記コマンドを実行するとfirebase emulatorが起動します.
npm run emulators
コンソールログにfirestoreとuiのエンドポイントが表示されていると思います.uiのエンドポイントを開くと以下のような画面が表示されると思います.
firebase emulatorはこれを使ってfirestoreのデータを修正したり,authを使っている場合はユーザを作成したりと色々なことができます. 試しにここに値を埋めてみましょう.
こうするとさっき作成したテストが通ると思います. 今ここでは手動で値を入れましたが次はそれをテストの実行前に自動で投入できるようにしましょう.
Custom CommandsのTypescript化
cypress-firebaseというfirebaseのラッパーライブラリをinstallするのですが,その前にこのcypress-firebaseをtypescriptで使用する為の準備をします.普通にテストをtypescriptで書くことはできるのですが,他のライブラリや自分で作成したCustom Commands(cy.~で呼び出すコマンド)を使うときは少し変更が必要です.参考にさせて頂いたサイトはこちらです.
以下の依存関係をインストールします.
npm install --save-dev @babel/core @babel/preset-env babel-loader webpack npm install --save-dev @cypress/webpack-preprocessor npm install --save-dev @bahmutov/add-typescript-to-cypress
これを実行するとplungins
以下にcy-ts-preprocessor.js
が生成され,またindex.js
が書き換わります.
次にcypress.jsonに以下のように記述します.
{ "supportFile": "cypress/support/index.ts" }
そしてsupportFile/command.js
とsupportFile/index.js
をそれぞれsupportFile/command.ts
とsupportFile/index.ts
に変更します.
次にsupportFile/command.ts
にexport {}
を記述しモジュール化します.
これで準備はOKです.
私もここらへん完全に理解はできていないのですが,cypress.json
に "supportFile": "cypress/support/index.ts"
を記述したことにより,supportFile/index.ts
が読み込まれるようになり,supportFile/index.ts
からsupportFile/command.ts
が読み込まれるようになってCustom Commandsが使えるようになります.そして@bahmutov/add-typescript-to-cypress
が
supportFile/index.js
に
on('file:preprocessor', cypressTypeScriptPreprocessor)
という記述や,supportFile/cy-ts-preprocessor.js
を生成し,tsファイルをトランスパイルしれくれる設定を色々やってくれるっぽいです.(参考)
cypress-firebase
いよいよcypress-firebaseをinstallします.
npm i --save-dev cypress-firebase firebase-admin
cypress-firebaseの公式のGitHubはこちらです. また環境変数で設定を切り替える為にcross-envをinstallします.
npm i --save-dev cross-env
support/command.ts
に以下のように記述します.
import firebase from "firebase/app"; import "firebase/auth"; import "firebase/database"; import "firebase/firestore"; import { attachCustomCommands } from "cypress-firebase"; // 自分でCustom Commandsの書かなけらばここは必要はない declare global { namespace Cypress { interface Chainable<Subject> { } } } // angularアプリケーションのenviroment.tsと同じものでOK const fbConfig = { apiKey: "dummy-key", authDomain: "authDomain", projectId: "new-app-da206", storageBucket: "storageBucket", messagingSenderId: "dummyId", appId: "dummyId", measurementId: "dummyId" }; firebase.initializeApp(fbConfig); // Firebase Emulator用の設定 const firestoreEmulatorHost = Cypress.env("FIRESTORE_EMULATOR_HOST"); if (firestoreEmulatorHost) { // Emulatorを使用する際は設定を上書きする firebase.firestore().settings({ host: firestoreEmulatorHost, ssl: false, }); } attachCustomCommands({ Cypress, cy, firebase }); export { }
index.js
を以下のように変更する
const cypressTypeScriptPreprocessor = require('./cy-ts-preprocessor') const admin = require("firebase-admin"); const cypressFirebasePlugin = require("cypress-firebase").plugin module.exports = (on, config) => { on('file:preprocessor', cypressTypeScriptPreprocessor) const extendedConfig = cypressFirebasePlugin(on, config, admin); // Custom Commandsを足すときはここに追加していく return extendedConfig; }
データの初期化
次にテスト用のデータの初期化を実装していきます.
初期化の流れとしては
1. firestoreの messages
のデータを全て削除
2. firestoreの messages
にテスト用のデータを追加
という感じです.これをe2eが始まる一番はじめに1度実行します.途中でデータを書き換えると,副作用を持ってしまい並列化したときにテストが不安定になってしまうので一番はじめにデータをセットしてしまいます. またテスト用のデータはあるディレクトリに追加しておいてそれが自動的に追加されるようにします.
1,2を行う関数はsupport/setup.ts
に記述することとします.
まずはmessagesのデータを全て削除するコードです.
export const clearMessagesData = () => { console.log('Clear messages data...') cy.callFirestore('delete', 'messages', { recursive: true }); }
cy.callFirestore()
を呼び出すことでfirestoreを操作することができます.{ recursive: true }
は再帰的に全て削除するという意味です.
次にファイルと読み込む実装をします.
実装のイメージは
1. ディレクトリ内のファイル名の一覧を取得する
2. そのファイル1つ1つを読み取り,firestoreに追加する
という風にします.
まず以下のようなファイルcypress/resource/message001.json
に作成します.
{ "content": "こんにちは" }
1ではnodeのfs.readdirSync
を使ってフィル名の一覧を取得します.cypressではnodeの関数はtaskとしてのみ実行できます.なのでまずファイル名を取得するtaskをplugins/index.js
に追加します.
・ ・ const fs = require('fs'); module.exports = (on, config) => { ・ ・ on('task', { getFileNames(directoryPath) { return fs.readdirSync(directoryPath) } }); return extendedConfig; }
これを使ってfirestoreにデータを登録するコードは以下のようになります.
export const insertMessagesData = () => { console.log('Insert messages data...') cy.task('getFileNames', `${resoucePath}`) .then(fileNames => { (fileNames as string[]).forEach((f) => { cy.readFile<{ content: string }>(`${resoucePath}/${f}`) .then(message => { cy.callFirestore('set', `messages/${f.replace('.json', '')}`, message); }); }); }) }
ファイル名から.jsonを省いたidのmessageが登録されていきます.
これをcommands.ts
に以下のような記述をすることでテストの最初に呼ぶようにします.
before(() => { clearMessagesData() insertMessagesData() })
実行
それではe2eテストを実行する為にpackage.json
に以下のスクリプトを記述して下さい.
"local:open": "cross-env FIRESTORE_EMULATOR_HOST=\"localhost:$(cat firebase.json | jq .emulators.firestore.port)\" cypress open"
cross-envで環境変数FIRESTORE_EMULATOR_HOST
を定義しています.
またこれを実行すると以下のエラーが起きる場合があります.
> Unable to detect a Project Id in the current environment.
こういうときはスクリプトにGCLOUD_PROJECT=<projectId>
という記述を足してください.cyress-firebaseでemulatorを使うときにはこうやってprojectId
を指定しないとうまく行かないようです.
cliでe2eをrunするスクリプトは以下のようになります.
"local:run": "cross-env FIRESTORE_EMULATOR_HOST=\"localhost:$(cat firebase.json | jq .emulators.firestore.port)\" GCLOUD_PROJECT=new-app-da206 cypress run"
感想
今回はCypress × Firebase × Angularという組み合わせでLocalでE2Eを実行する方法を解説しました.Firebase Emulator周りの設定が色々ややこしいとこがあるので皆様の参考になれば幸いです.こちらのコードは以下にpushしています. https://github.com/Yoshiaki-Harada/angular-firebase-cypress-for-blog-