Ionic AngularでCI/CD (GitHub Actions, Firebase Hosting)
Ionicを勉強し始めました。メモがてらGitHub ActionsとFirebase Hostingを使ってCI/CDする手順を書きます。
Ionicをセットアップ
Ionicのインストールに関しては公式ドキュメントを参照してください。
ionicコマンドを使用できるようになったら、プロジェクトの作成を行います。ionic-cicdはプロジェクト名なので、任意の文字列で構いません。
ionic start ionic-cicd blank --type=angular --capacitor
次に、プロジェクトのディレクトリに移動して、起動確認を行います。
cd ionic-cicd ionic serve
以下のように画面が表示されればOKです。

起動確認ができたら、GitHub Repositoryを作成してpushしておきます。
CIを構築
次にCIを構築していきます。
.github/workflows/main.yml を以下のように作成します。
name: CI
on:
push:
branches:
- master
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm install
- run: npm run test:unwatch
test:unwatch はpackage.jsonで以下のように定義しておきます。watchをfalseにしておかないと、GitHub Actionsで実行した時にずっと終わらない状態になってしまいます。
...
"test": "ng test",
"test:unwatch": "ng test --watch false --browsers ChromeHeadless",
"lint": "ng lint",
...
一応ローカルでも以下を実行して自動生成で作成される2つのテストがSUCCESSすることを確認しておきます。
npm run test:unwatch
これで変更をpushします。https://github.com/ユーザ名/Repository名/actions にアクセスしてCIが正しく実行されていればOKです。
Firebase Hostingにデプロイ
Firebase Hostingへのデプロイは公式ドキュメントが詳しいのでこちらを参照してください。
大まかには、Firebaseのプロジェクトを作成して、firebase-toolsをインストールして、ionic build --prodしてwwwディレクトリをデプロイすれば大丈夫です。
CDを構築
プロジェクトのルートで、以下を実行してGitHubと連携をします。
firebase init hosting:github
For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) は今回作成したRepositoryを入れます。(Enterでいいはず)
Uploaded service account JSON to GitHub as secret FIREBASE_SERVICE_ACCOUNT_XXXX.と表示されるはずなのでこれをメモしておきます。(後からGitHub上でも確認できます)
Set up the workflow to run a build script before every deploy?とSet up automatic deployment to your site's live channel when a PR is merged?はnにしておいてOKです。
.github/workflows/firebase-hosting-pull-request.yml というファイルが生成されるかもしれませんが、不要なので削除して問題ありません。
最後に、.github/workflows/main.yml を以下のように変更します。
name: CI & CD
on:
push:
branches:
- master
workflow_dispatch:
jobs:
test_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm install
- run: npm run test:unwatch
- uses: coturiv/setup-ionic@v1
- run: ionic build --prod
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_XXXX }}'
channelId: live
projectId: ionic-cicd(作成したFirebase ProjectのプロジェクトID)
Angularの開発をCircleCIとNowでCI/CDする
Angular標準のJasmine & KarmaのテストはGoogleChromeが必要で、NowのCIだとそれができなさそうなのでCircleCIを使用しましたが、Now単体でもできるかもしれません。
Angular をinitする
Angular 日本語ドキュメンテーション
を見てAngularプロジェクトを作成します
npm install -g @angular/cli ng new your-project-name
テストは
cd your-project-name npm run test
で確認できると思います。自動のGoogleChromeが立ち上がってテストしてくれればOK。
CircleCIをセットアップする
Automate your Nuxt.js app deployment | CircleCI
CircleCIのBlogでNuxtでCIするサンプルがあるので、参考になると思います(こちらはGitHub Pagesにデプロイしているので少し違いますが)
CIまでやる設定は以下のようになります。.circleci/config.ymlとして作成します。
version: 2.1
jobs:
build:
working_directory: ~/repo
docker:
- image: circleci/node:10.16.3-browsers
steps:
- checkout
- run:
name: update-npm
command: "sudo npm install -g npm@5"
- restore_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: install-packages
command: npm install
- save_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
paths:
- ./node_modules
- run:
name: test
command: npm run test
ポイントはimage: circleci/node:10.16.3-browsersです。-browsersを付けるとChrome、Firefox、Java 8、および Geckodriver が含まれたimageで実行することができます。
他にも色々imageが用意されているので、詳しくはドキュメントを参照してください。
Pre-Built CircleCI Docker Images - CircleCI
また、testが終わった後にコンソールに戻ってくれないとCircleCIで続きが実行できないので、
package.jsonのscriptsのtestを以下のように書き換えます。
"scripts": {
...
"test": "ng test --watch=false",
...
},
最後に、以下の手順にしたがってRepositoryをCIするようにすれば、以降はpushごとにCIしてくれます。
Getting Started Introduction - CircleCI
Nowにデプロイする
Nowの説明はこの記事などを参考にしてください。
史上最速デプロイ! nowを使ってみた - 筋肉エンジニアの備忘録
CircleCIからNowにデプロイするにはTOKENが必要になるので、GitHubなどでNowにログイン後、
https://zeit.co/account/tokens
からTOKENを取得して、
環境変数の使い方 - CircleCI
などを参考に、環境変数に設定します。
プロジェクトへのプライベート環境変数の追加は、CircleCI 上のプロジェクトごとの設定ページにある、Environment Variables で行えます。
最近CircleCIのUIがコロコロ変わっているのでどこまで参考になるか分かりませんが、以下のような設定画面です。

Name は ZEIT_TOKEN、 ValueはNowで作成したTOKENの中身を貼り付けましょう。
Nowにデプロイまでする設定は以下のようになります。
version: 2.1
jobs:
build:
working_directory: ~/repo
docker:
- image: circleci/node:10.16.3-browsers
steps:
- checkout
- run:
name: update-npm
command: "sudo npm install -g npm@5"
- restore_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: install-packages
command: npm install
- save_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
paths:
- ./node_modules
- run:
name: test
command: npm run test
# ここから追加
- run:
name: install now
command: sudo npm install -g now
- run:
name: deploy
command: now --name your-project-name --prod --confirm -t ${ZEIT_TOKEN}
--nameはデプロイ先のURLを指定できるので、URLに使用したい文字列を入れても大丈夫です。
正常にデプロイできれば、https://"--nemeで指定した文字列".now.sh のようなURLでAnglarのトップページが表示されていると思います。
TDD でWordCount してみる(Ruby, Kotlin)
TDDの有名なプラクティスの1つである、Word Countをやってみます。RubyもKotlinもめっちゃ得意じゃないので、何か間違っていたらご指摘ください。
Word Countの仕様は以下のようなものです。
与えられた文字列のそれぞれの単語数を数えて返します。
例)
1. "hello" -> {"hello": 1}
2. "hello hello" -> {"hello": 2}
3. "hello world" -> {"hello": 1, "world": 1}
Ruby版
テストから実装していきます。
Ruby: 2.7.0 RSpec: 3.9
RSpec.describe 'Word Count' do it 'input one hello' do expect(WordCount.new.calc('hello')).to eq({'hello' => 1}) end end
これで、RED(エラー)になりますね。
実際にWordCountクラスとcalcメソッドを作成してみます。
class WordCount def calc(input) {'hello' => 1} end end
これでGREENになります。期待値と同じHashを返すようにしたので、当たり前っちゃ当たり前ですね。テストを満たす最低限の実装をして、リファクタリングをして、その最低限の実装ではREDになるようなテストを書いて...と実装をしていきます。
次のテストを書いてみます。
it 'input two hello' do expect(WordCount.new.calc('hello hello')).to eq({'hello' => 2}) end
これはREDになりますね。GREENになるように実装を書いてみます。
class WordCount def calc(input) input.split(' ').tally end end
これは冗談です。Ruby2.7.0のリリースノートを眺めていたら、これを書かずにはいられませんでした。Kotlin版はちゃんと書きます!!
Kotlin版
テストを書きます。
Junit: 4.11 Java: 11.0.4
package test import main.WordCount import org.junit.Test import kotlin.test.assertEquals class WordCountTest { @Test fun InputOneHello() { assertEquals(WordCount().calc("hello"), mapOf("hello" to 1)) } }
Ruby版と変わらないテストですね。REDになることを確認できたら、実装をしてみます。
package main class WordCount { fun calc(input: String): Map<String, Int> { return mapOf("hello" to 1) } }
最低限の実装ですね。
次のテストを書いてみます。
@Test fun InputOneWorld() { assertEquals(WordCount().calc("world"), mapOf("world" to 1)) }
最低限の実装をしたいので、こう実装してみます。
fun calc(input: String): Map<String, Int> { if (input == "hello") { return mapOf("hello" to 1) } else if (input == "world"){ return mapOf("world" to 1) } return mapOf() }
if文で二つのケースに対応しただけなので、GREENになるはずです。これで、リファクタリングをしてみます。
すごく簡単な例ですし、いきなりこうしてみても、問題はないかもしれません。
fun calc(input: String): Map<String, Int> { return mapOf(input to 1) }
しかし、これをbaby stepでリファクタリングしてみる事もできます。if文に使っている文字列とreturnに使っている文字列が同じであることに注目して、以下のようにしてみます。
fun calc(input: String): Map<String, Int> { if (input == "hello") { return mapOf(input to 1) } else if (input == "world"){ return mapOf(input to 1) } return mapOf() }
これは、すごく自信のある変更です、GREENであるはずです。そうしたら、if文で制御しているのに同じ結果を返しているので、if文は削除できることになります。
fun calc(input: String): Map<String, Int> { return mapOf(input to 1) }
とても小さなstepを踏んだだけですが、今度はこの変更にとても自信を持てます。意味のなくなったif文を消しただけですからね。
次のテストを追加してみます。
@Test fun inputHelloWorld() { assertEquals(WordCount().calc("hello world"), mapOf("hello" to 1, "world" to 1)) }
まずは最低限の実装をすることに注力します。
fun calc(input: String): Map<String, Int> { if (input == "hello world") { return mapOf("hello" to 1, "world" to 1) } return mapOf(input to 1) }
これで、GREENのはずです。
次に、リファクタリングをしてみます。テストに頼りながら、inputをsplitしてmapにつめる実装をしてみましょう。
fun calc(input: String): Map<String, Int> { val result = mutableMapOf<String, Int>() for (s in input.split(" ")) { result[s] = 1 } return result }
良さそうな気がしますね。
さらにテストを追加してみます。
@Test fun inputTwoHello() { assertEquals(WordCount().calc("hello hello"), mapOf("hello" to 2)) }
最低限の実装をします。
fun calc(input: String): Map<String, Int> { if (input == "hello hello") { return mapOf("hello" to 2) } val result = mutableMapOf<String, Int>() for (s in input.split(" ")) { result[s] = 1 } return result }
これで、GREENのはずです。
次に、リファクタリングをしてみます。すでにmapに値がある場合には1を足す処理を追加します。
fun calc(input: String): Map<String, Int> { if (input == "hello hello") { return mapOf("hello" to 2) } val result = mutableMapOf<String, Int>() for (s in input.split(" ")) { if (result[s] != null) { result[s] = result[s]!!.plus(1) } else { result[s] = 1 } } return result }
これで、すでにmapに値があるならカウントしていけるようになりました。不要なif文を削除してもGREENであることを確認します。
fun calc(input: String): Map<String, Int> { val result = mutableMapOf<String, Int>() for (s in input.split(" ")) { if (result[s] != null) { result[s] = result[s]!!.plus(1) } else { result[s] = 1 } } return result }
単純な実装としては以上でそんなに問題なさそうです。ここまでくれば、だいぶテストケースが揃っているので、かなり強気なリファクタリングをしても、実装に自信を持つことができます。
fun calc(input: String): Map<String, Int> { return input.split(" ").groupingBy { it }.eachCount() }
ここまでするかは微妙ですし、もっともっとbaby stepでやった方が良いかもしれません。
今回のコードはこちらにあるので、良かったら参考にしてください。 github.com
本当はコードブロックに[RED]とか[GREEN]とか付けたかったんですが、一旦諦めてしまいました。