Google Apps Script のローカル開発環境を学びながら構築する

## はじめに
[clasp](https://github.com/google/clasp)によってGoogle Apps Scriptのローカル開発は非常に快適になったわけですが、claspだけではまだクリアできない課題もあります。
というわけで、GASプロジェクトのローカル開発を更に快適にするための環境を『学びながら』構築します。

今回の環境構築に際して実現したいことは↓これらです。

- npmパッケージを使えるようにする
- TypeScriptで書けるようにする
- ESLintで構文チェックする
- Prettierでコード整形する
- テストを書けるようにする
- Renovateで自動アップグレード（おまけ）

では、順番に課題をクリアしていきましょう。

## clasp
[clasp](https://github.com/google/clasp)はGASをローカル開発するためのCLIです。
`clasp pull`、 `clasp push`、 `clasp clone`などのコマンドでローカルの`*.js` ファイルとGoogle Drive上のGASプロジェクトが連携できます。

`clasp`の導入は公式のREADMEに従えばOKです（なげやり）

### サンプルプロジェクトをclone
予めGASのプロジェクトを作り、`clasp clone`します。

```
$ clasp clone <sciptID>
```

### .clasp.jsonを追記
後々Webpackでバンドルしたファイルを`clasp push`したいので、`rootDir`を設定しておきます。

```json:./.clasp.json
{
  "scriptId": "***********",
  "rootDir": "dist/"
}
```

これでひとまずローカルで開発するスタート地点に立てました。

## Webpack
まず最初に**npmパッケージを使えるようにする**という課題をクリアするために、[Webpack](https://webpack.js.org/)を導入します。
Webpackはモジュールの依存関係を解決して1つの(あるいはいくつかの)`*.js`ファイルにまとめてくれる優れものです。以下の記事が大変参考になりました。
[webpack 4 入門](https://qiita.com/soarflat/items/28bf799f7e0335b68186)

では早速インストールしましょう。
Webpack本体だけではなく、`webpack-cli`という`webpack`コマンドを実行するためのパッケージもインストールします。

```
$ yarn add webpack webpack-cli --dev
```

無事Webpackをインストールできたらディレクトリを整理します。

```
root
├── node_modules
├── dist
│   └── appsscript.json
├── src
│   └── index.js
├── .clasp.json
├── .gitignore
├── package.json
└── yarn.lock
```

ここで今更ですが、これから構築したい開発環境の方針の確認です。
今回は、./src内でアプリケーションを作る → それらをWebpackでまとめたものを./distに出力 → まとめたものを`clasp push`する、という流れで開発できる環境を整えていきます。

ではそんな環境のためにWebpackの設定を続けます。



### gas-webpack-pluginを導入

Webpackの設定ファイルを作成する前に、もう一つ[gas-webpack-plugin](https://github.com/fossamagna/gas-webpack-plugin)というパッケージが必要なので、これもインストールします。

```
$ yarn add gas-webpack-plugin --dev
```



このプラグインは何者なのか。READMEにはこう記述されています。

>In Google Apps Script, it must be top level function declaration that entry point called from [google.script.run](https://developers.google.com/apps-script/guides/html/reference/run). When `gas-webpack-plugin` detect a function assignment expression to `global` object. it generate a top level function declaration statement.

GASではgoogle.script.runから関数を呼び出すためにトップレベルに定義している必要があるのですが、Webpackでバンドルすると、私達が記述した関数はWebpackの関数の中で定義されてしまいます。

gas-webpack-pluginを使うと、`global`オブジェクトのメソッドとして定義した関数をバンドルしたファイルのトップレベルで宣言してくれます。

このプラグインも踏まえて、`webpack.config.js`を作成します。

```javascript:./webpack.config.js
const path = require("path");
const GasPlugin = require("gas-webpack-plugin");

module.exports = {
  mode: "development",
  devtool: "none",
  entry: {
    app: "./src/index.js"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js"
  },
  plugins: [new GasPlugin()]
};
```

Webpackの最低限の設定はこれでできているので、GASのプログラムを作成して試してみます。

```javascript:./src/index.js
import { say } from "./say";

global.myFunction = () => {
  say("Hello");
};
```

```javascript:./src/say.js
export const say = str => {
  Logger.log(str);
};
```

ではWebpackでバンドルします。

```
$ yarn webpack
```

これで./dist/app.jsが出力されました！続けて、

```
$ clasp push
```

`clasp push`も上手く行ったら、`clasp open`を実行、ブラウザでGASの関数を実行してログを確認します。
↓こんなログが出力されていれば成功です！

```
[20-03-11 07:40:24:187 PDT] Hello
```

### npm scriptsを作成
いちいち`yarn webpack`と`clasp push`を入力するのは面倒なので、`package.json`の`scripts`にコマンドを登録しておきます。

```json:./package.json
{
  "devDependencies": {
    "gas-webpack-plugin": "^1.0.2",
    "webpack": "^4.42.0",
    "webpack-cli": "^3.3.11"
  },
  "scripts": {
    "build": "yarn webpack && clasp push"
  }
}
```

これで今後は`yarn build`一発でビルドしてプッシュまでしてくれます。

### 余談

`yarn webpack`したら`package.json`にライセンス書いてないよ！って怒られた。
公開する気がないなら、`"license": "UNLICENSED"`にしておくといいらしい。

```json
{
  "license": "UNLICENSED",
  // 略
}
```



## npmパッケージを使ってみる
実際にnpmパッケージを使えるか、[lodash](https://lodash.com/)で試してみます。
まずはインストール。

```
$ yarn add lodash --dev 
```

lodashの`chunk`メソッドを使った簡単なプログラムを作ります。

```javascript:./src/index.js
import { say } from "./say";
import { chunkSample } from "./chunk";

global.myFunction = () => {
  say("Hello");
};

global.chunkFunction = () => {
  chunkSample();
};
```

```javascript:./src/chunk.js
import _ from "lodash";

export const chunkSample = () => {
  Logger.log(_.chunk(["a", "b", "c", "d", "e"], 2));
}; // [[a, b], [c, d], [e]] というログが表示されるはず
```

ではビルドしてプッシュします。

```
$ yarn build
```

ではブラウザでGASの関数を実行してログを確認します。
↓期待通りのログが出力されました。成功ですね！

```
[20-03-11 14:45:53:177 PDT] [[a, b], [c, d], [e]]
```

## TypeScript
次はTypeScriptを使えるようにします。

実はclaspがTypeScriptに対応していて、`*.ts`を`clasp push`すると自動で`*.js`(というか`*.gs`)にトランスパイルしてくれるんですが、今回せっかくなので(？)、[ts-loader](https://github.com/TypeStrong/ts-loader)でjsにしてから`clasp push`します。
というわけで必要なパッケージをインストールします。

```
$ yarn add typescript ts-loader @types/google-apps-script --dev
```



ではts-loderのREADMEを見つつ、設定を追加していきます。
エントリーポイントのファイルの拡張子もちゃんと`.ts`に書き換えましょう。私は最初忘れてました。

```javascript:./webpack.config.js
const path = require("path");
const GasPlugin = require("gas-webpack-plugin");

module.exports = {
  mode: "development",
  devtool: "none",
  entry: {
    app: "./src/index.ts"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js"
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: "ts-loader"
      }
    ]
  },
  plugins: [new GasPlugin()],
  resolve: {
    extensions: [".ts", ".js"]
  }
};
```

続いてTypeScriptをトランスパイルする際の設定ファイル、`tsconfig.json`も作ります。
この設定は以下の記事を主に参考にさせていただきました。
[tsconfig.jsonの全オプションを理解する（随時追加中）](https://qiita.com/ryokkkke/items/390647a7c26933940470)

```json:./tsconfig.json
{
  "compilerOptions": {
    "sourceMap": false,
    "target": "es2017",
    "module": "es2015",
    "typeRoots": ["./node_modules/@types/"],
    "moduleResolution": "node"
  },
  "include": ["src/**/*.ts"]
}
```
ここまで設定したら、./src内の`*.js`ファイルを`*.ts`に書き換えます。
とりあえず拡張子を変えるだけでいいんですが、`index.ts`の`global`が定義されていないよ、と怒られます。

```typescript:./src/index.ts
import { say } from "./say";
import { chunkSample } from "./chunk";

// ↓ここが怒られる
global.myFunction = () => {
  say("Hello");
};
// 略
```

これの対処として、独自の型ファイルを作成してみます。

### index.d.tsを作る
どこに配置するのが最良なのかわからないですが、とりあえず./src/typings内に配置。
`declare`文で変数を宣言します。

```typescript:./src/typings/index.d.ts
declare const global: any;
```

これで怒られなくなりました。では`yarn build`します。

```
$ yarn build
```

無事にビルドとプッシュができました。

### 余談
Webpackでモジュールをバンドルする際に**Tree Shaking**というイカしたテクニックがあります。
これは、使われていないコード(デッドコード)をバンドルせず、出力されるファイルをスリムにする手法です。以下の記事が大変参考になりました。
[webpackのTree Shakingを理解して不要なコードがバンドルされるのを防ぐ](https://qiita.com/soarflat/items/755bbbcd6eb81bd128c4)

今回の私のように`*.ts`を`*.js`にトランスパイルしている場合は、`tsconfig.json`で`"module": "es2015"`と設定し、`webpack`を`production`モードで実行すればTree Shakingされるはず。後で試してみたい。

## ESLint
続いて、JavaScriptの構文をチェックしてくれる[ESLint](https://eslint.org/)を導入します。
例によってまずは必要なパッケージをインストールします。

```
$ yarn add eslint eslint-loader --dev
```

`eslint-loader`はWebpackでバンドルする際にコードを検証するためのローダーです。


ESLintの設定ファイルも作るのですが、今回は`yarn eslint --init`コマンドで設定ファイルを作成しました。
ESLintの詳細な設定をまだわかっていないので、「とりあえずお任せで」って感じです。

出来上がったのがこちら。

```json:./.eslintrc.json
{
  "env": {
    "es6": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended"
  ],
  "globals": {
    "Atomics": "readonly",
    "SharedArrayBuffer": "readonly"
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {}
}
```

`webpack.config.js`にも`eslint-loader`の設定を追加します。

```javascript:./webpack.config.js
const path = require("path");
const GasPlugin = require("gas-webpack-plugin");

module.exports = {
  
  // 略
  
  module: {
    rules: [

      // 略

      {
        enforce: "pre",
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: "eslint-loader"
      }
    ]
  },
  
  // 略

};
```

Webpackでは、`module.rules`の配列に記述したローダーを下から（配列の後ろから）順番に実行していきます。なので`ts-loader`でトランスパイルするより先に構文チェックしたいなら`eslint-loader`を配列の後ろに書かなければいけません。
また、`enforce`というプロパティに`"pre"`を設定しておくと、配列の順番に関係なく先に実行させることも可能です。今回はこの書き方にします。

導入したESLintがちゃんと構文エラーを検出してくれるのか確認します。
そのために`.eslintrc.json`と`index.ts`を少し書き換えます。

```json:./.eslintrc.json
{

  // 略

  "rules": {
    "no-unused-vars": "error"
  }
}

```

```javascript:./src/index.ts
// 略

const unusedVars = "error";
```

`"no-unused-vars"`というルールを`"error"`に設定しました。これは文字通り、定義して使われていない変数をチェックするルールです。
そして`index.ts`にこのルールを犯すようなコードを記述しました。これで`yarn webpack`を実行します。

```
$ yarn webpack
yarn run v1.22.0
$ /Users/********/node_modules/.bin/webpack

// 略

ERROR in ./src/index.ts
Module Error (from ./node_modules/eslint-loader/dist/cjs.js):

/Users/********/src/index.ts
  12:7  error  'unusedVars' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```

期待通り、構文エラーを検出してくれるようになりました！

## Prettier
続いて大人気のコードフォーマッター[Prettier](https://prettier.io/)を導入します。
改行やインデントを整えたり、シングルクォーテーションとダブルクォーテーションどちらにするか等のルールを設定してコードをきれいにしてくれます。
まずはインストールします。

```
$ yarn add prettier --dev
```

次に設定ファイルを作ります。ルートディレクトリに`.prettierrc`を作り、中身は[公式のBasic Configuration](https://prettier.io/docs/en/configuration.html)をそのまま使おう、と思いましたが個人的な好みにあまり合わない設定だったので色々と編集してこんな感じにした。

```json:./.prettierrc
{
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": false,
  "bracketSpacing": true
}
```

Prettier自体の導入はこれで完了なんですが、ESLintと併用するためのパッケージをインストールします。以下の記事を参考にさせていただきました。
[Prettier 入門 ～ESLintとの違いを理解して併用する～](https://qiita.com/soarflat/items/06377f3b96964964a65d)

```
$ yarn add eslint-config-prettier eslint-plugin-prettier --dev
```

ESLintとPrettierを併用するために`.eslintrc.json`も追記します。

```json:./.eslintrc.json
{
  "env": {
    "es6": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:prettier/recommended"
  ],
  "globals": {
    "Atomics": "readonly",
    "SharedArrayBuffer": "readonly"
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {
    "prettier/prettier": ["error"]
  }
}

```

これで`eslint --fix`を実行した時にPrettierによるコードをきれいにしてくれます。

```
$ yarn eslint --fix ./src/**/*.ts
```

せっかくコマンドで整形できるようにしましたが、私はエディタでコード保存時に整形してくれるようにします。(以下の設定はVScodeを使っている方向けです)
ルートディレクトリに`.vscode`というディレクトリを作り、`settings.json`の中にエディタの設定を記述します。
Prettierに関係ない設定もたくさん書いていますが。

```json:./.vscode/settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "editor.tabSize": 2,
  "editor.wordWrap": "on",
  "eslint.enable": true,
  "eslint.packageManager": "yarn",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,
  "typescript.tsdk": "node_modules/typescript/lib",
  "[markdown]": {
    "files.trimTrailingWhitespace": false
  },
  "editor.fontSize": 14,
  "editor.formatOnSave": true
}
```

これでコードを保存するたびにきれいに整形してくれるようになりました。やったー！

## Jest
続きまして、JavaScript用テストフレームワークの[Jest](https://jestjs.io/ja/)を導入します。
まずは必要なパッケージをインストール。

```
$ yarn add jest @types/jest @types/node --dev
```

例によって、Jestの設定ファイルを作りたいところですが、JestのTypeScript対応について少し考えます。
公式ドキュメントではBabel使ってTypeScriptもサポートしてるよ！と書かれていますが、同時に以下のような記述もあります。
>Because TypeScript support in Babel is transpilation, Jest will not type-check your tests as they are run. If you want that, you can use **ts-jest**.

つまりBabel使うとテストコードの型チェックはしないということっぽいです。
型チェックもしたければ[ts-jest](https://github.com/kulshekhar/ts-jest)を使えということなので、ts-jest公式も参考にしながら続きの設定もしていきます。

まずはts-jestをインストールします。

```
$ yarn add ts-jest --dev
```

続いて、以下のドキュメントを参考にしつつ、Jestの設定ファイル`jest.config.js`を作成します。
[Configuration | ts-jest](https://kulshekhar.github.io/ts-jest/user/config/)
[Configuring Jest](https://jestjs.io/docs/ja/configuration#modulefileextensions-arraystring)
[Jest - TypeScript Deep Dive 日本語版](https://typescript-jp.gitbook.io/deep-dive/intro-1/jest)

```javascript:./jest.config.js 
module.exports = {
  moduleFileExtensions: ["ts", "tsx", "js", "json", "jsx", "node"],
  roots: ["./src"],
  preset: "ts-jest",
};
```

### テストを書いて実行してみる
Jestの[Getting Started](https://jestjs.io/docs/ja/getting-started)を参考に、簡単なテストを書いて実行してみます。

```typescript:./src/sum.ts
export const sum = (a: number, b: number): number => a + b;
```

```typescript:./src/sum.test.s
import { sum } from "./sum";

describe("sum test", () => {
  test("adds 1 + 2 to equal 3", () => {
    expect(sum(1, 2)).toBe(3);
  });
});
```

ご覧の通り、2つの引数を足すだけの非常に簡単な関数です。
ついでに、`package.json`の`"scripts"`にコマンドを追加します。

```json:./package.json
{
  // 略

  "scripts": {
    "test": "jest",
    "build": "yarn webpack && clasp push"
  }
}
```

ではテストを実行してみます。

```
$ yarn test
yarn run v1.22.4

// 略

 PASS  src/sum.test.ts
  sum test
    ✓ adds 1 + 2 to equal 3 (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.021s
Ran all test suites.
✨  Done in 1.82s.
```

無事にパスできました！

### 余談
テストを実行した際に以下の警告が出ました。

message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.

モジュールのインポートに関する課題があるなら、`tsconfig.json`の`esModuleInterop`を`true`にするのも検討してごらん？
って意味っぽいので少し調べたところ、CommonJSモジュールとESモジュール間の相互運用を可能にするオプションのようです。以下の記事を参考にさせていただきました。
 [esModuleInterop オプションの必要性について](https://qiita.com/karak/items/29ff148788f5abb15331)

## Renovate（おまけ）
せっかく作ったボイラープレートですから、いざ使おうとしたときにパッケージを最新の状態にしておくたいものです。
というわけで[Renovate](https://renovate.whitesourcesoftware.com/)を使ってみます。

導入についてはこちらの記事が大変参考になります。
[フロントエンド開発でパッケージのアップデートを継続的におこなう - Renovate](https://qiita.com/kasaharu/items/1af74b49e98658cf9a8e)

かなり柔軟に設定できるようですが、今のところはpatchを自動でマージする設定だけしておきます。

```json:renovate.json
{
  "extends": ["config:base"],
  "patch": { "automerge": true }
}
```

これでパッケージが更新されたときにプルリクエストが来るようになりました。

## おわりに
これでかなり(個人的には)快適にGASのローカル開発ができるようになりました:grinning:
[kozukata1993/gas-boilerplate](https://github.com/kozukata1993/gas-boilerplate)

今回の記事を執筆するにあたって、以下の記事を参考にさせていただきました。
[Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました](https://qiita.com/howdy39/items/0e799a9bfc1d3bccf6e5)
[GAS を npm パッケージ + Webpack + TypeScript で開発する
](https://qiita.com/shohei_ot/items/7b26461359068a192b96)