Google Apps Script の開発環境構築方法

# はじめに
Google Apps Scriptを開発するときにclaspを導入してソース管理し始めた後、もっと色々できないかと調べた結果を備忘録として記載します。

# GOAL
開発環境として最終的に下記を利用して開発できるようになります。

- VSCode: コードエディター
- clasp: Google Apps Scriptをローカルで開発できるようにするもの
- TypeScript: プレーンJavaScriptにコンパイルされるJavaScriptの型付きスーパーセット
- ESLint: JavaScriptコードの問題を見つけて修正することができる
- Jest: シンプルさを重視した楽しいJavaScriptテストフレームワーク

# claspのインストール〜プロジェクト作成
https://github.com/google/clasp
上記ページのInstallセクションの記載通りに実行します。

```shell-session
$ npm install -g @google/clasp
```

ログインします。初回だとブラウザが起動してclaspへのアクセス許可をもとめられるので許可すると`~/.clasprc.json`に認証情報が保存されます。

```shell-session
$ clasp login
```

プロジェクトを作成します。今回は、standaloneプロジェクトを作成します。

```shell-session
$ mkdir gas-example
$ cd gas-example
$ clasp create --type standalone
Created new standalone script: https://script.google.com/d/xxxxxxxxxx/edit
Warning: files in subfolder are not accounted for unless you set a '.claspignore' file.
Cloned 1 file.
└─ appsscript.json
```

# Typescriptのインストール
最初にpackage.jsonを作成します。色々質問されますが、とりあえず、全部EnterでOK。

```shell-session
$ npm init
```

typescript関連のパッケージをインストールします。

```shell-session
$ npm install --save-dev typescript ts-node @types/node
```

https://github.com/google/clasp/blob/master/docs/typescript.md
上記ページのPrerequisitesセクションの記載通りに実行します。

```shell-session
$ npm i -S @types/google-apps-script
```

tsconfig.jsonというファイルを作成して、TypeScript機能を有効にします。
ソースコードは、`src/`に配置し、テストコードは`test/`に配置することにします。

```json:tsconfig.json
{
  "compilerOptions": {
    "lib": ["esnext"],
    "experimentalDecorators": true
  },
  "include": [
    "src/**/*",
    "test/**/*"
  ]
}
```

`.clasp.json`も下記の通りに修正します。

```json:.clasp.json
{
    "scriptId":"xxxxxxxxx",
    "rootDir": "src/",
    "fileExtension": "ts"
}
```

`appsscript.json`ファイルも`src/`に移動します。

```shell-session
$ mkdir src test
$ mv appsscript.json src/
```

# ESLint & Prettierのインストール
https://eslint.org/docs/user-guide/getting-started
上記ページのInstallation and Usageセクションの記載通りに実行します。

```shell-session
$ npm install eslint --save-dev
```

次に`.eslintrc.json`をセットアップします。

```shell-session
$ npx eslint --init
? How would you like to use ESLint? 
To check syntax, find problems, and enforce code style

? What type of modules does your project use? 
None of these

? Which framework does your project use? 
None of these

? Does your project use TypeScript? 
Yes

? Where does your code run? 
Node

? How would you like to define a style for your project? 
Use a popular style guide

? Which style guide do you want to follow? 
Standard: https://github.com/standard/standard

? What format do you want your config file to be in? 
JSON
Checking peerDependencies of eslint-config-standard@latest
The config that you've selected requires the following dependencies:
@typescript-eslint/eslint-plugin@latest eslint-config-standard@latest eslint@>=6.2.2 eslint-plugin-import@>=2.18.0 eslint-plugin-node@>=9.1.0 eslint-plugin-promise@>=4.2.1 eslint-plugin-standard@>=4.0.0 @typescript-eslint/parser@latest

? Would you like to install them now with npm? 
Yes

Installing @typescript-eslint/eslint-plugin@latest, eslint-config-standard@latest, eslint@>=6.2.2, eslint-plugin-import@>=2.18.0, eslint-plugin-node@>=9.1.0, eslint-plugin-promise@>=4.2.1, eslint-plugin-standard@>=4.0.0, @typescript-eslint/parser@latest
```

https://prettier.io/docs/en/install.html
https://github.com/prettier/prettier-eslint
https://github.com/prettier/eslint-config-prettier
https://github.com/prettier/eslint-plugin-prettier
上記ページを参考にしながらprettier関連パッケージをインストールします。

```shell-session
$ npm install --save-dev --save-exact prettier
$ npm install --save-dev prettier-eslint eslint-config-prettier eslint-plugin-prettier
```

`.eslintrc.json`のextendsの部分を修正します。

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

# Jest & ts-jestのインストール
https://jestjs.io/docs/ja/getting-started
上記ページのGetting Startedセクションの記載通りに実行します。

```shell-session
$ npm install --save-dev jest
```

https://github.com/kulshekhar/ts-jest
上記ページのGetting Started - Installingセクションを参考にし、下記を実行します。

```shell-session
$ npm i -D ts-jest @types/jest
```

次に`jest.config.js`ファイルを生成します。

```shell-session
$ npx ts-jest config:init
```

# hello.ts & hello.test.tsの作成

https://github.com/google/clasp/blob/master/docs/typescript.md
の`hello.ts`をベースに`src/hello.ts`、`test/hello.test.ts`を作成します。

```typescript:src/hello.ts
const greeter = (person: string): string => {
  return `Hello, ${person}!`;
};
function testGreeter(): string {
  const user = "Grant";
  const msg = greeter(user);
  Logger.log(msg);
  return msg;
}
export { greeter, testGreeter };
```

```typescript:test/hello.test.ts
import { greeter, testGreeter } from "../src/hello";
describe("test.ts test", () => {
  beforeAll(() => {
    Logger.log = jest.fn().mockImplementation(msg => {
      return console.log(msg);
    });
    jest.spyOn(Logger, "log");
  });
  test("greeter", () => {
    const person = "World";
    const expected = greeter(person);
    expect(expected).toBe("Hello, World!");
  });
  test("testGreeter", () => {
    const expected = testGreeter();
    expect(Logger.log).toBeCalled();
    expect(expected).toBe("Hello, Grant!");
  });
});
```

GoogleAppScriptで定義されている`Logger.log`をjestでモック化するために`jest.config.js`を下記の通り修正します。

```javascript:jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  globals: {
    Logger: {}
  }
};
```

# テストコードの実行
下記コマンドでテストコードが実行できます。
`Logger.log`の代わりに`console.log`が実行されていることがわかります。

```shell-session
$ npx jest
 PASS  test/hello.test.ts
  test.ts test
    ✓ greeter (2ms)
    ✓ testGreeter (8ms)

  console.log test/hello.test.ts:6
    Hello, Grant!

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.821s
Ran all test suites.
```

`console.log`を出力したくない場合

```shell-session
$ npx jest --silent=true
 PASS  test/hello.test.ts

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.991s, estimated 3s
```

カバレッジを出力したい場合

```shell-session
$ npx jest --silent=true --coverage
 PASS  test/hello.test.ts
----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 hello.ts |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.7s
```

# Apps Scriptを実行する
typescriptファイルをapps scriptサーバにpushします。

```shell-session
$ clasp push
└─ src/appsscript.json
└─ src/hello.ts
Pushed 2 files.
```

script.google.comでApps Scriptプロジェクトを開きます。

```shell-session
$ clasp open
Opening script: https://script.google.com/d/xxxxxxxxxx/edit
```

ブラウザが開くので`testGreeter（）`を実行し、[表示]> [ログ]を押してログを表示することで、結果を確認できます。

`hello.ts`は、下記のようにGoogle Apps Scirptに変換されていることがわかります。

```javascript:hello.gs
// Compiled using ts2gas 3.4.4 (TypeScript 3.7.2)
var exports = exports || {};
var module = module || { exports: exports };
var greeter = function (person) {
    return "Hello, " + person + "!";
};
exports.greeter = greeter;
function testGreeter() {
    var user = "Grant";
    var msg = greeter(user);
    Logger.log(msg);
    return msg;
}
exports.testGreeter = testGreeter;
```

# おまけ：VSCodeの設定
ファイルセーブ時に自動的にESLintで自動整形できるように設定する。

```json:.vscode/settings.json
{
    "eslint.alwaysShowStatus": true,
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
}
```

デバッグ実行の設定

```json:.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "args": [
                "${relativeFile}"
            ],
            "runtimeArgs": [
                "--nolazy",
                "-r",
                "ts-node/register"
            ],
            "sourceMaps": true,
            "cwd": "${workspaceRoot}",
            "protocol": "inspector"
        },
        {
            "type": "node",
            "request": "launch",
            "name": "Debug Tests",
            "args": [
                "${relativeFile}"
            ],
            "runtimeArgs": [
                "--inspect-brk",
                "${workspaceRoot}/node_modules/jest/bin/jest.js",
                "--runInBand",
                "--silent=true",
                "-o"
            ],
            "console": "integratedTerminal",
            "internalConsoleOptions": "neverOpen",
            "port": 9229
        }
    ]
}
```

#おまけ２：package.jsonの設定
`package.json`を下記のように修正することで`npm run`で実行できるようになります。

```json:package.json
〜省略〜
  "scripts": {
    "ts-node": "npx ts-node",
    "test": "npx jest --silent=true --coverage",
    "test-only": "npx jest --silent=true -o"
  },
〜省略〜
```

テスト実行

```shell-session
$ npm run test

> gas-example@1.0.0 test /Users/xxxxx/gas-example
> npx jest --silent=true --coverage

 PASS  test/hello.test.ts
----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 hello.ts |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.227s
```