Cypress × Firebase × Angular の組み合わせのE2Eをlocalで実行する

AngularとFirebaseを使ったアプリケーションを作成し,CypressでE2Eを回したかったのですが,この構成のE2EをLocalで実行する方法を探すのに苦労したのでまとめようと思います.構成のイメージは以下の通りです.

f:id:harada-777:20210131140545p:plain

今回は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.tsapp.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/integrationcypress/fixtureディレクトリは削除して頂いて大丈夫です. Cypressはcypress/integration以下にテストを配置する仕様になっています.そこにディレクトリを作ってテストを分けることもできます. 以下のようなmessage.tsを作成します.

describe('ホーム画面 ', () => {
    it('メッセージを見ることができる', () => {
        cy.visit('http://localhost:4200/');
        cy.contains('こんにちは').should('be.visible') //  firestoreに「こんにちは」という値を入れてテストをする予定
    })
})

アプリケーションをng serveで起動してテストを実行してみましょう. 以下の画面でファイル名を押すと実行できます.

f:id:harada-777:20210131140533p:plain

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のエンドポイントを開くと以下のような画面が表示されると思います. f:id:harada-777:20210131140541p:plain

firebase emulatorはこれを使ってfirestoreのデータを修正したり,authを使っている場合はユーザを作成したりと色々なことができます. 試しにここに値を埋めてみましょう. f:id:harada-777:20210131140536p:plain

こうするとさっき作成したテストが通ると思います. 今ここでは手動で値を入れましたが次はそれをテストの実行前に自動で投入できるようにしましょう.

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.jssupportFile/index.jsをそれぞれsupportFile/command.tssupportFile/index.tsに変更します.

次にsupportFile/command.tsexport {}を記述しモジュール化します. これで準備はOKです.

私もここらへん完全に理解はできていないのですが,cypress.json "supportFile": "cypress/support/index.ts"を記述したことにより,supportFile/index.tsが読み込まれるようになり,supportFile/index.tsからsupportFile/command.tsが読み込まれるようになってCustom Commandsが使えるようになります.そして@bahmutov/add-typescript-to-cypresssupportFile/index.json('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-