はじめに
こんにちは。debiru です。
スプレッドシート上で key-value 形式のデータを管理して、それを JSON として吐き出し、JSON を利用して各種アプリケーションにデータを Import するというアプローチを採用してシステム開発を行っています。
デザインシステムのデザイントークンをこれで管理して Figma の Local Variables にデータを Import するための Figma プラグインを作ったという記事を前回書きましたが、今回はスプレッドシート + GAS 側の開発手法についてのお話です。
TL;DR
- スプレッドシートを作成して
SPREADSHEET_ID
とGAS_ID
を控える -
git clone git@github.com:debiru/clasp.git
を実行する - 中にある
.clasp.json
の ID を控えたものに差し替える - https://script.google.com/home/usersettings をオンにする
-
npm install
してnpx clasp login
してnpm run push
する - JSON Web API を使いたければ (6.) の手順を実行する
開発手順
- (1.) Git (GitHub) リポジトリを用意する
- (2.) スプレッドシートを用意する
- (3.) clasp コマンドを使って GAS を clone する
- (4.) TypeScript をトランスパイルできるようにする
- (5.)
project.ts
を実装する - (6.)
project.js
を push して初期デプロイする - (7.) Jest を導入し
package.json
を整備する - (8.) Jest のテストを実装する
1. Git (GitHub) リポジトリを用意する
まず、Git リポジトリを用意します。
- https://github.com/debiru/clasp
git@github.com:debiru/clasp.git
このリポジトリを手元で clone して、作業ディレクトリとして使っていきます。
$ cd /path/to
$ git clone git@github.com:debiru/clasp.git
$ cd clasp
後述する作業に合わせて、適宜 commit していきます。
.gitignore
を用意しておく
Thumbs.db
.DS_Store
node_modules
package-lock.json
coverage
-
package-lock.json
- npm install 時にパッケージのバージョンを固定する必要がないので、最新版を導入できるように
package-lock.json
は管理しないでおきます。
- npm install 時にパッケージのバージョンを固定する必要がないので、最新版を導入できるように
-
coverage
- jest のテスト実行時に生成されるディレクトリです。バージョン管理する必要がないので無視しておきます。
2. スプレッドシートを用意する
自身の Google アカウント上でスプレッドシートを作成します。
ここでは Google Workspace(旧 G Suite, 旧 Google Apps)のアカウントではなく、無料の個人アカウントで作業している前提で説明します(Google Workspace アカウントの場合、Web API の URL の形式が少し変わります)。
- スプレッドシート
- https://docs.google.com/spreadsheets/d/1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80/edit?usp=sharing
-
/d/
と/edit
の間の文字列が__SPREADSHEET_ID__
です
- GAS
- https://script.google.com/u/0/home/projects/1-_Han3yPynNQM8XWEuA7VQEhoVTWRBduXAq4EF4_1UWPDRFUpei5Iafo/edit
-
/projects/
と/edit
の間の文字列が__GAS_ID__
です
GAS はスプレッドシート上の「拡張機能」から「Apps Script」を選択して表示されるページです。
スプレッドシートの初期設定
スプレッドシートを初期設定しておきます。以下は今回のサンプルの例です。
- スプレッドシート名を設定:Clasp Example
- シート名を設定:scripts
- セル内容:1行目にキー名、2行目以降にレコード(値)を記述しておく
GAS の初期設定
GAS を初期設定しておきます。
- GAS 名を設定:Clasp Example
- スクリプトファイル名を設定:project.gs
- スクリプトの初期化:内容を空にして保存しておく
3. clasp コマンドを使って GAS を clone する
- Google Apps Script API を使えるようにします
git clone したディレクトリに移動して作業します。
$ npm install -D @google/clasp
$ ls
README.md node_modules package-lock.json package.json
$ npx clasp login
# ブラウザが起動してログイン処理が実行されます
$ npx clasp login --status
You are logged in as YOUR_NAME@gmail.com
$ mkdir dst
$ clasp clone __GAS_ID__ --rootDir dst
# dst ディレクトリ下に .clasp.json と appsscript.json が生成されます
# .clasp.json はプロジェクトルートに設置します
$ mv dst/.clasp.json .
.clasp.json
に __SPREADSHEET_ID__
を追記する
-
"spreadsheetId"
キーを追加します - ついでに JSON を改行して見やすくしておきます
{
"spreadsheetId": "1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80",
"scriptId": "1-_Han3yPynNQM8XWEuA7VQEhoVTWRBduXAq4EF4_1UWPDRFUpei5Iafo",
"rootDir": "dst"
}
dst/appsscript.json
に webapp
キーを追記する
- clasp で webapp デプロイができるように
"webapp"
キーを追記します -
"access"
には Web API を誰でも参照できるように"ANYONE_ANONYMOUS"
を指定しておきます
{
"timeZone": "Asia/Tokyo",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
}
}
clasp から push を試してみる
-
dst
下にproject.js
を設置してclasp push
を実行するとスクリプトの保存ができます -
push
の−f
オプションは、マニフェストファイルであるappsscript.json
がローカルとリモートで異なっているときにManifest file has been updated. Do you want to push and overwrite?
と訊かれるのをスキップするために指定しています
$ touch dst/project.js
# dst/project.js を編集します
$ cat dst/project.js
function hello() {
return 'hello';
}
$ npx clasp push -f
└─ dst/appsscript.json
└─ dst/project.js
Pushed 2 files.
ブラウザから GAS ページをみると、スクリプトが更新されていることが確認できます。
4. TypeScript をトランスパイルできるようにする
実は clasp 自体が TypeScript に対応していて、前述の dst/project.js
を dst/project.ts
として記述するだけで、clasp push
時に自動的にサーバーサイドで JS にトランスパイルしてくれます。しかし、clasp の TypeScript 自動トランスパイルは単一ファイルのみに対応していて、Import 文が使えません。
Import 文を使ってファイル分割して TypeScript で開発するにはローカルで手動トランスパイルする必要があります。ローカルでトランスパイルできるようにするために esbuild
を導入しましょう。
esbuild
を導入する
$ npm install -D esbuild ts-node @types/node
$ mkdir src
$ touch src/project.ts
# src/project.ts を編集します
$ cat src/project.ts
export function hello() {
return 'hello';
}
$ mkdir bin
$ touch bin/build.ts
# bin/build.ts を編集します
$ cat bin/build.ts
#!/usr/bin/env npx ts-node
import { build } from 'esbuild';
build({
entryPoints: ['src/project.ts'],
outfile: 'dst/project.js',
bundle: true,
banner: {
js: [
'// This file was generated by clasp. DO NOT EDIT by hand.',
'// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var',
'',
].join('\n'),
},
footer: {
js: [
'',
'// Copyright @debiru_R from https://github.com/debiru/clasp',
].join('\n'),
},
}).catch(() => process.exit(1));
-
banner
プロパティでファイルの先頭に、footer
プロパティでファイルの末尾に任意の文字列を追加できます
$ chmod +x bin/build.ts
# src/project.ts から dst/project.js を生成します
$ bin/build.ts
$ cat dst/project.js
// This file was generated by clasp. DO NOT EDIT by hand.
// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var
(() => {
// src/project.ts
function hello() {
return "hello";
}
})();
// Copyright @debiru_R from https://github.com/debiru/clasp
-
esbuild
でトランスパイルしたコードは、上記のように即時実行関数式(IIFE)の中に閉じ込められます
tsconfig.json
を設置する
$ touch tsconfig.json
# tsconfig.json を編集します
$ cat tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"typeRoots": ["node_modules/@types", "src/types"],
"esModuleInterop": true,
"resolveJsonModule": true
}
}
5. project.ts
を実装する
- スプレッドシートに記入した内容を JSON で返す Web API を用意します
$ npm install -D @types/google-apps-script
$ tree src
src
├── Entrypoint
│ └── index.ts
├── Sheet
│ └── index.ts
├── Util
│ └── index.ts
├── global
│ ├── export.ts
│ └── index.ts
├── project.ts
└── types
└── index.d.ts
Entrypoint/index.ts
import { JSON_FIELDS, SHEETS } from "../global";
import { Sheet } from "../Sheet";
import { Util } from "../Util";
export const Entrypoint = {
doGet(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
return Entrypoint.doMain(e);
},
doPost(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
return Entrypoint.doMain(e);
},
/**
* the "e" argument represents an event parameter that can contain information about any URL parameters.
* refs. https://developers.google.com/apps-script/guides/web
*/
doMain(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
return Entrypoint.makeContent(
Entrypoint.makeResponse(e, () => Entrypoint.makeFormattedObject(e.parameter.type))
);
},
makeFormattedObject(type: string) {
const keyValueData = Entrypoint.makeObject();
// If you want another JSON format, convert the data structure here.
// (ex.) if (type === 'figma') return Formatter.figmaFormatter(keyValueData);
return keyValueData;
},
makeObject() {
const jsonObj = Util.objMap(JSON_FIELDS, (value: string) => Sheet.getRecords(value));
return jsonObj;
},
makeResponse(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent, callback: CallableFunction) {
const data = callback();
const json = Util.toJSON(data);
const useJsonp = !Util.empty(e.parameter.callback);
const response = {
mime: useJsonp ? ContentService.MimeType.JAVASCRIPT : ContentService.MimeType.JSON,
content: (useJsonp ? Util.sprintf('%s(%s);', e.parameter.callback, json) : json) + '\n',
};
return response;
},
makeContent(response: { mime: GoogleAppsScript.Content.MimeType, content: string }) {
return ContentService.createTextOutput(response.content).setMimeType(response.mime);
},
};
Sheet/index.ts
import { SID } from '../global';
export const Sheet = {
get(sheetName: string) {
return SpreadsheetApp.openById(SID).getSheetByName(sheetName);
},
getCells(sheetName: string) {
const sheet = Sheet.get(sheetName);
if (sheet == null) return null;
return sheet.getDataRange().getValues() as Array<Array<CellValue>>;
},
getRecords(sheetName: string) {
const cells = Sheet.getCells(sheetName);
if (cells == null) return null;
// values[row][col] を records[row][key] のオブジェクト配列に変換
const keys = cells.shift();
const records = cells.map((row: Array<CellValue>) => {
const obj = {};
row.forEach((cell: CellValue, i: number) => {
obj[String(keys[i])] = cell;
});
return obj;
});
// 値が全て空の列があれば key から除外する
keys.forEach((key: string) => {
const set = new Set();
records.forEach((record: DataRecord) => set.add(record[key]));
if (set.size === 1 && set.has('')) {
records.map((record: DataRecord) => {
delete(record[key]);
return record;
});
}
});
return records as DataRecords;
},
getHeaders(sheetName: string) {
const cells = Sheet.getCells(sheetName);
if (cells == null) return null;
return cells.shift().map((v: CellValue) => String(v));
},
};
Util/index.ts
export const Util = {
/**
* 値を JSON 文字列に変換する。
*/
toJSON(arg: unknown) {
return JSON.stringify(arg, null, 2);
},
/**
* null または undefined または空文字列であれば true を返す。
*/
empty(arg: unknown) {
return arg == null || arg === '';
},
/**
* %s プレースホルダーのみに対応した sprintf 関数。
*/
sprintf(format: string, ...args: any) {
let p = 0;
return format.replace(/%./g, function(m) {
if (m === '%%') return '%';
if (m === '%s') return args[p++];
return m;
});
},
/**
* Object の key-value を新しい値で更新する。
* obj の他に valueCallback と keyCallback を引数に取る。
* callback 関数は引数に元の value と key をこの順に取る。
*/
objMap(obj: PlainObject<any>, valueCallback?: CallableFunction, keyCallback?: CallableFunction) {
if (valueCallback == null) valueCallback = (value: any) => value;
if (keyCallback == null) keyCallback = (value: any, key: ObjectKey) => key;
const ret: typeof obj = {};
Object.entries(obj).forEach(([key, value]: [ObjectKey, any]) => ret[keyCallback(value, key)] = valueCallback(value, key));
return ret;
},
/**
* keyArray と valueArray から Object を作成する。
* valueArray を指定しない場合、keyArray が使われる。
*/
arrayCombine(keyArray: Array<any>, valueArray?: Array<any>) {
if (valueArray == null) {
valueArray = keyArray;
keyArray = Object.keys(keyArray);
}
const ret: PlainObject<any> = {};
for (let i = 0; i < keyArray.length; ++i) ret[keyArray[i]] = valueArray[i];
return ret;
},
};
global/export.ts
export const Export: Record<string, Array<string>> = {
Entrypoint: ['doGet', 'doPost'],
};
global/index.ts
import clasp from '../../.clasp.json';
export const SID = clasp.spreadsheetId;
export const SHEETS = {
SCRIPTS: 'scripts',
UNKNOWN: 'unknown',
};
export const COL = {
SCRIPT_NAME: 'scriptName',
COMMAND: 'command',
UNKNOWN: 'unknown',
};
export const JSON_FIELDS = {
[SHEETS.SCRIPTS]: SHEETS.SCRIPTS,
};
project.ts
export * from './global';
import { Entrypoint } from './Entrypoint';
import { Sheet } from './Sheet';
import { Util } from './Util';
// Export Functions to Global
const variables = { Entrypoint, Sheet, Util };
import { Export } from './global/export';
Object.entries(Export).forEach(([namespace, functionNameList]: [string, Array<string>]) => {
functionNameList.forEach((functionName: string) => {
globalThis[functionName] = variables[namespace][functionName];
});
});
types/index.d.ts
type PlainObject<T> = Record<string, T>;
type ObjectKey = string | number;
type CellValue = string | number | boolean;
type DataRecordValue = CellValue;
type DataRecord = PlainObject<DataRecordValue>;
type DataRecords = Array<DataRecord>;
bin/build.ts
#!/usr/bin/env npx ts-node
import { build } from 'esbuild';
import { Export } from '../src/global/export';
const globalFunctions: Array<string> = [];
Object.entries(Export).forEach(([namespace, functionNameList]: [string, Array<string>]) => {
functionNameList.forEach((functionName: string) => {
globalFunctions.push(`function ${functionName}() {}`);
});
});
build({
entryPoints: ['src/project.ts'],
outfile: 'dst/project.js',
bundle: true,
banner: {
js: [
'// This file was generated by clasp. DO NOT EDIT by hand.',
'// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var',
'',
].join('\n'),
},
footer: {
js: [
'',
...globalFunctions,
'',
'// Copyright @debiru_R from https://github.com/debiru/clasp',
].join('\n'),
},
}).catch(() => process.exit(1));
6. project.js
を push して初期デプロイする
$ bin/build.ts
$ npx clasp push -f
- ブラウザ上から GAS ページを開いて、デプロイ(新しいデプロイ)を実行します
- 「新しい説明文」には
Initial deployment
とでも記述しておきます - デプロイを実行します
- スクリプトコード中で
SpreadsheetApp.openById(SID)
などの API を使用している箇所に応じて、権限が求められます - 「アクセスを承認」を選択して、自身のアカウントでこのアプリケーションに対して権限を付与します
- Web API のエンドポイントが生成されます
- これ以降は clasp からコマンドラインでデプロイができるようになります
更新デプロイをする方法
新規デプロイを新たに行ってしまうと Web API の URL が変わってしまいます。これを防ぐためには、ブラウザ上で操作する場合は「デプロイの管理」から編集を行い「新バージョン」でデプロイを実行します。clasp から操作する場合は、deploymentId
オプションを付与してデプロイを実行します。
7. Jest を導入し package.json
を整備する
$ npm install -D jest ts-jest @types/jest
-
package.json
を編集します-
"name"
,"version"
,"scripts"
を追記します -
"name"
の値には大文字が使えないため kebab-case で記述します -
"scripts"
内に定義したキーはnpm run deployments
のようにnpm run
コマンドとして実行できるようになります
-
{
"name": "clasp-example",
"version": "1.0.0",
"devDependencies": {
"@google/clasp": "^2.4.2",
"@types/google-apps-script": "^1.0.83",
"@types/jest": "^29.5.12",
"@types/node": "^22.2.0",
"esbuild": "^0.23.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.4",
"ts-node": "^10.9.2"
},
"scripts": {
"deployments": "clasp deployments",
"deployment": "bin/getDeploymentId.sh",
"show": "bin/show.sh",
"test": "jest --passWithNoTests",
"prebuild": "npm run test",
"build": "bin/build.ts",
"prepush": "npm run build",
"push": "clasp push -f",
"preforce-push": "bin/build.ts",
"force-push": "clasp push -f",
"deploy": "bin/deploy.sh"
}
}
-
bin
以下にシェルスクリプトを作成します
touch bin/deploy.sh bin/getDeploymentId.sh bin/show.sh
chmod +x bin/*.sh
bin/deploy.sh
#!/bin/bash
cd $(dirname $0)
ask_yes_no() {
echo -n "$* [Y/n]: "
read ANS
case $ANS in
[Yy]*)
return 0
;;
*)
return 1
;;
esac
}
DEPLOYMENT_ID="$(./getDeploymentId.sh)"
DATETIME="$(date "+%Y-%m-%d %H:%M:%S")"
NAME="$(git config user.name)"
if [ -z "$DEPLOYMENT_ID" ]; then
echo "初回は手動で webapp デプロイを実行する必要があります"
exit 1
fi
if ask_yes_no "最後に push されたコードでデプロイを実行しますか?"; then
npx clasp deploy --deploymentId "${DEPLOYMENT_ID}" --description "by ${NAME} at ${DATETIME}"
else
echo "Aborted."
fi
bin/getDeploymentId.sh
#!/bin/bash
cd $(dirname $0)
DEPLOYMENTS="$(npx clasp deployments)"
LAST_DEPLOYMENT_ID=$(echo "$DEPLOYMENTS" | grep '^-' | awk '{print $3, $2}' | grep -v '^@HEAD' | sort -V | tail -1 | awk '{print $2}')
echo "$LAST_DEPLOYMENT_ID"
bin/show.sh
#!/bin/bash
cd $(dirname $0)
CLASP_JSON="$(cat ../.clasp.json)"
SPREADSHEET_ID="$(echo $CLASP_JSON | xargs -0 -i node -pe '({}).spreadsheetId')"
DEPLOYMENT_ID="$(./getDeploymentId.sh)"
webapp_url() {
URL="https://script.google.com/macros/s/${DEPLOYMENT_ID}/exec"
if [ $# -ge 1 ]; then
URL="${URL}?type=${1}"
fi
echo "$URL"
}
echo "- Spreadsheet URL:"
echo "https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/edit"
echo ""
echo "- Key-Value JSON:"
echo "$(webapp_url)"
echo ""
jest.config.ts
を生成する
$ npx jest --init
The following questions will help Jest to create a suitable configuration for your project
✔ Would you like to use Jest when running "test" script in "package.json"? … no
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls, instances, contexts and results before every test? … yes
📝 Configuration file created at /path/to/clasp/jest.config.ts
- 更に、以下の変更を加える
-
preset
の値を'ts-jest'
にする -
transformIgnorePatterns
の値をアンコメントする
-
diff --git a/jest.config.ts b/jest.config.ts
index 04d4c9c..9b4c34a 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -102,7 +102,7 @@ const config: Config = {
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
- // preset: undefined,
+ preset: 'ts-jest',
// Run tests from one or more projects
// projects: undefined,
@@ -178,10 +178,10 @@ const config: Config = {
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
- // transformIgnorePatterns: [
- // "/node_modules/",
- // "\\.pnp\\.[^\\/]+$"
- // ],
+ transformIgnorePatterns: [
+ "/node_modules/",
+ "\\.pnp\\.[^\\/]+$"
+ ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
8. Jest のテストを実装する
$ tree src
src
├── Entrypoint
│ ├── index.spec.ts
│ └── index.ts
├── Sheet
│ ├── index.spec.ts
│ └── index.ts
├── Util
│ ├── index.spec.ts
│ └── index.ts
├── global
│ ├── export.ts
│ ├── index.spec.ts
│ ├── index.ts
│ ├── mock.spec.ts
│ └── mock.ts
├── project.ts
└── types
└── index.d.ts
Entrypoint/index.spec.ts
import '../global/mock';
import { Mock } from '../global/mock';
import { Entrypoint } from '.';
import { Util } from '../Util';
describe('Entrypoint.spec', () => {
describe('Entrypoint Methods', () => {
it('doGet() returns jestResult', () => {
const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
expect(typeof result.jestResult.content).toBe('string');
});
it('doPost() returns jestResult', () => {
const result = Entrypoint.doPost(globalThis.AppsScriptHttpRequestEvent) as any;
expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
expect(typeof result.jestResult.content).toBe('string');
});
});
describe('doMain', () => {
it('doMain returns Key-Value JSON', () => {
const result = Entrypoint.doMain(globalThis.AppsScriptHttpRequestEvent) as any;
expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
expect(JSON.parse(result.jestResult.content)).toEqual({
scripts: [
{
scriptName: '1scriptName/scripts',
command: '1command/scripts',
},
],
});
});
});
describe('makeResponse', () => {
it('makeResponse supports JSONP', () => {
const callbackName = 'jsonpCallbackName';
const content = { a: 'hello', b: 'world' };
globalThis.AppsScriptHttpRequestEvent.setParam('callback', callbackName);
const result = Entrypoint.makeResponse(globalThis.AppsScriptHttpRequestEvent, () => content);
expect(result.mime).toBe(globalThis.ContentService.MimeType.JAVASCRIPT);
expect(result.content).toBe(`${callbackName}(${Util.toJSON(content)});\n`);
});
});
});
Sheet/index.spec.ts
import '../global/mock';
import { extJest, Mock } from '../global/mock';
import { Sheet } from '.';
import { SHEETS } from '../global';
describe('Sheet.spec', () => {
describe('Success of Sheet methods', () => {
it('getCells() has exist sheetName', () => {
const result = Sheet.getCells(SHEETS.SCRIPTS);
expect(result).not.toBeNull();
});
it('getRecords() has exist sheetName', () => {
const result = Sheet.getRecords(SHEETS.SCRIPTS);
expect(Array.isArray(result)).toBeTruthy();
expect(result.length).toBeGreaterThanOrEqual(1);
});
it('getHeaders() has exist sheetName', () => {
const result = Sheet.getHeaders(SHEETS.SCRIPTS);
expect(Array.isArray(result)).toBeTruthy();
expect(result.length).toBeGreaterThanOrEqual(1);
});
});
describe('Special cases', () => {
it('getRecords() removes empty columns', () => {
const originalGetValuesBySheet = Mock.getValuesBySheet;
extJest.makeMock(Mock, 'getValuesBySheet').mockImplementation((sheetName: string) => {
const values = originalGetValuesBySheet(sheetName);
return values.map(row => row.map(col => col.replace(/\d+command\/scripts/, '')));
});
const result = Sheet.getRecords(SHEETS.SCRIPTS);
expect(result).toEqual([{ scriptName: '1scriptName/scripts' }]);
});
});
describe('Exception of Sheet methods', () => {
it('getCells() does not have exist sheetName', () => {
const result = Sheet.getCells(SHEETS.UNKNOWN);
expect(result).toBeNull();
});
it('getRecords() does not have exist sheetName', () => {
const result = Sheet.getRecords(SHEETS.UNKNOWN);
expect(result).toBeNull();
});
it('getHeaders() does not have exist sheetName', () => {
const result = Sheet.getHeaders(SHEETS.UNKNOWN);
expect(result).toBeNull();
});
});
});
Util/index.spec.ts
import '../global/mock';
import { Util } from '.';
describe('Util.spec', () => {
const valuesTable = [null, undefined, '', false, true, 0, '0', () => 0, [], {}];
describe('toJSON', () => {
it('success', () => {
expect(Util.toJSON({ a: 1, b: 'b' })).toBe('{\n "a": 1,\n "b": "b"\n}');
});
});
describe('empty', () => {
it('success', () => {
const result = valuesTable.map(v => Util.empty(v));
const expectResult = [true, true, true, false, false, false, false, false, false, false];
expect(result).toEqual(expectResult);
});
});
describe('sprintf', () => {
it('assign arguments', () => {
expect(Util.sprintf('%s,%s', 1, 2)).toBe('1,2');
});
it('escape character', () => {
expect(Util.sprintf('%%s', 1)).toBe('%s');
});
it('undefined placeholder', () => {
expect(Util.sprintf('%d,%s', 1, 2)).toBe('%d,1');
});
});
describe('objMap', () => {
it('valueCallback', () => {
const obj = {
one: 1,
two: 2,
three: 3,
};
expect(Util.objMap(obj, (v: number) => v * 2)).toEqual({ one: 2, two: 4, three: 6 });
});
it('keyCallback', () => {
const obj = {
one: 1,
two: 2,
three: 3,
};
expect(Util.objMap(obj, null, (v: number, k: string) => `${k}_${v}`)).toEqual({ one_1: 1, two_2: 2, three_3: 3 });
});
});
describe('arrayCombine', () => {
it('1 argument', () => {
expect(Util.arrayCombine(['a', 'b', 'c'])).toEqual({ 0: 'a', 1: 'b', 2: 'c' });
});
it('2 arguments', () => {
expect(Util.arrayCombine(['a', 'b', 'c'], ['x', 'y', 'z'])).toEqual({ a: 'x', b: 'y', c: 'z' });
});
});
});
global/index.spec.ts
import './mock';
import { SID } from '.';
describe('Global Definitions', () => {
it('SID is not empty', () => {
expect(SID).not.toBeFalsy();
});
});
global/mock.spec.ts
import './mock';
import { extJest, Mock } from './mock';
import { SHEETS } from '.';
import { Entrypoint } from '../Entrypoint';
import { Sheet } from '../Sheet';
describe('mock.spec', () => {
describe('Mocks are unmocked for each test case', () => {
it('Mock doMain', () => {
const doMain = extJest.makeMock(Entrypoint, 'doMain');
const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent);
expect(result).toBeUndefined();
});
it('Subsequent tests', () => {
const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent);
expect(result).not.toBeUndefined();
});
});
describe('Query parameters are reset for each test case', () => {
it('Set query parameter', () => {
globalThis.AppsScriptHttpRequestEvent.setParam('callback', 'jsonp');
const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JAVASCRIPT);
});
it('Subsequent tests', () => {
const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
});
});
describe('ActiveSheet is reset for each test case', () => {
it('Set activeSheetName', () => {
(globalThis.SpreadsheetApp as any).setActiveSheetName(SHEETS.UNKNOWN);
const result = (globalThis.SpreadsheetApp as any).getActiveSheetName();
expect(result).toBe(SHEETS.UNKNOWN);
});
it('Subsequent tests', () => {
const result = (globalThis.SpreadsheetApp as any).getActiveSheetName();
expect(result).toBe(SHEETS.SCRIPTS);
});
});
describe('Check to see if Mock is broken', () => {
it('Sheet.getActiveSheetInfo.cells', () => {
for (let i = 0; i <= 10; ++i) {
(globalThis.SpreadsheetApp as any).setRecordsNumber(i);
const records = Sheet.getRecords(SHEETS.SCRIPTS);
expect(records.length).toBe(i);
}
});
});
describe('Exception of methods', () => {
it('SpreadsheetApp.getSheetByName', () => {
expect((globalThis.SpreadsheetApp as any).openById(null).getSheetByName(SHEETS.UNKNOWN)).toBeNull();
});
it('Mock.getValuesBySheet', () => {
expect(Mock.getValuesBySheet(SHEETS.UNKNOWN)).toEqual([[]]);
});
});
});
global/mock.ts
import { COL, SHEETS } from ".";
export const extJest = {
mocks: [],
makeMock: (parent: Object, prop: string): jest.Mock => {
const bak = Object.assign({}, parent)[prop];
extJest.mocks.push({ parent, prop, bak });
parent[prop] = jest.fn();
return parent[prop];
},
};
afterEach(() => {
extJest.mocks.reverse().forEach((mock) => mock.parent[mock.prop] = mock.bak);
extJest.mocks = [];
globalThis.AppsScriptHttpRequestEvent.reset();
(globalThis.SpreadsheetApp as any).reset();
});
globalThis.AppsScriptHttpRequestEvent = {
parameter: {},
setParam: (key: string, value: string) => {
globalThis.AppsScriptHttpRequestEvent.parameter[key] = value;
},
reset: () => globalThis.AppsScriptHttpRequestEvent.parameter = {},
} as any;
globalThis.ContentService = {
jestResult: {},
MimeType: {
JAVASCRIPT: 'JAVASCRIPT',
JSON: 'JSON',
},
createTextOutput: (content: string) => {
((globalThis.ContentService) as any).jestResult.content = content;
return globalThis.ContentService;
},
setMimeType: (mime: string) => {
((globalThis.ContentService) as any).jestResult.mime = mime;
return globalThis.ContentService;
},
} as any;
globalThis.SpreadsheetApp = {
openById: (spreadsheetId: string) => ({
getSheetByName: (sheetName: string) => {
(globalThis.SpreadsheetApp as any).setActiveSheetName(sheetName);
if (Mock.getBaseRecords(sheetName).length === 0) return null;
return (globalThis.SpreadsheetApp as any).getSheetByName;
},
}),
getSheetByName: {
getDataRange: () => (globalThis.SpreadsheetApp as any).getDataRange,
},
getDataRange: {
getValues: () => Mock.getValuesBySheet((globalThis.SpreadsheetApp as any).getActiveSheetName()),
},
activeSheetName: null,
getActiveSheetName: () => (globalThis.SpreadsheetApp as any).activeSheetName ?? SHEETS.SCRIPTS,
setActiveSheetName: (sheetName: string) => (globalThis.SpreadsheetApp as any).activeSheetName = sheetName,
recordsNumber: null,
getRecordsNumber: () => (globalThis.SpreadsheetApp as any).recordsNumber ?? 1,
setRecordsNumber: (number: number) => (globalThis.SpreadsheetApp as any).recordsNumber = number,
reset: () => {
(globalThis.SpreadsheetApp as any).activeSheetName = null;
(globalThis.SpreadsheetApp as any).recordsNumber = null;
},
} as any;
export const Mock = {
getBaseRecords: (sheetName?: string): Array<string> => {
const map: any = {
[SHEETS.SCRIPTS]: [COL.SCRIPT_NAME, COL.COMMAND],
};
return sheetName == null ? map : map[sheetName] ?? [];
},
getValuesBySheet: (sheetName: string): Array<Array<string>> => {
const map = Mock.getBaseRecords();
if (map[sheetName] == null) return [[]];
const values = (keys: Array<string>) => {
const records = [keys];
const recordsNumber = (globalThis.SpreadsheetApp as any).getRecordsNumber();
for (let i = 1; i <= recordsNumber; ++i) {
records.push(keys.map((key: string) => `${i}${key}/${sheetName}`));
}
return records;
};
return values(Mock.getBaseRecords(sheetName));
},
};
テクニカルノート
コマンド一覧
npm run deployments
GAS のデプロイされている情報を確認します。
> clasp-example@1.0.0 deployments
> clasp deployments
2 Deployments.
- AKfycbxAEGZSTBDADb6h5gfA-NM6JxAmZ3_i1BbIm7ibB5g @HEAD
- AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh @1 - Initial deployment
npm run deployment
最新の DEPLOYMENT_ID を確認します。
> clasp-example@1.0.0 deployment
> bin/getDeploymentId.sh
AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh
npm run show
スプレッドシートや Web API の URL を確認します。
> clasp-example@1.0.0 show
> bin/show.sh
- Spreadsheet URL:
https://docs.google.com/spreadsheets/d/1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80/edit
- Key-Value JSON:
https://script.google.com/macros/s/AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh/exec
npm run test
jest のテストを実行します。
> clasp-example@1.0.0 test
> jest --passWithNoTests
PASS src/Entrypoint/index.spec.ts
PASS src/Sheet/index.spec.ts
PASS src/global/index.spec.ts
PASS src/Util/index.spec.ts
PASS src/global/mock.spec.ts
------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
Entrypoint | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
Sheet | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
Util | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
global | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
mock.ts | 100 | 100 | 100 | 100 |
------------|---------|----------|---------|---------|-------------------
Test Suites: 5 passed, 5 total
Tests: 30 passed, 30 total
Snapshots: 0 total
Time: 3.376 s, estimated 4 s
Ran all test suites.
npm run build
テストの実行後に src/project.ts
を dst/project.js
にビルドします。
npm run push
テストの実行後にビルドをして、ビルド結果を GAS に反映させます。
npm run force-push
テストをせずにビルドして、ビルド結果を GAS に反映させます。
一時的なデバッグ用途で用います。
npm run deploy
デプロイ操作のみを実行します。
push
は事前に行っておく必要があります。
npx clasp
clasp
コマンドを直接実行する場合は npx
経由で実行できます。
-
npx clasp login --status
- ログイン状態を確認する
-
npx clasp login
- ログインする
-
npx clasp logout
- ログアウトする
project.js
でグローバル関数を定義するテクニック
通常、GAS ではトップレベルに function 文で関数を定義することによりグローバル関数を定義します。
function hello() {
return "hello";
}
トップレベルに定義されたグローバル関数は、GAS エディタ上で実行可能な関数として選択できたり、スプレッドシート上でカスタム関数として利用することができます。
しかし、esbuild
を経由してトランスパイルすると、以下のように実装が即時実行関数式(IIFE)の中に閉じ込められます。
(() => {
// src/project.ts
function hello() {
return "hello";
}
})();
doGet()
や onEdit()
のようなイベントハンドラ関数を参照できるようにするためには、トップレベルに function 文でグローバル関数を定義せずとも、グローバルスコープに関数を定義することによって認識されるようになります。
(() => {
// src/project.ts
function hello() {
return "hello";
}
globalThis.hello = hello;
})();
グローバルスコープに関数を定義するには、上記のように globalThis
オブジェクトのプロパティとして関数をセットします。
なお、グローバルスコープを参照するためにトップレベルに let global = this;
という一文を出力する esbuild-gas-plugin
というプラグインがありますが、これを利用する必要はありません。そのような出力がしたければ esbuild
のオプションである banner
プロパティを利用すればよいですし、上記の通り globalThis
がグローバル変数として既に定義されているのでそもそもそのような出力をする必要がありません。
ただし、スプレッドシート上で利用可能なカスタム関数として定義するには、グローバルスコープに関数を定義するだけでなくトップレベルに function 文で関数を定義する必要があります。これを実現するため、今回の実装では次のようなハックを行っています。
(() => {
// src/project.ts
function hello() {
return "hello";
}
globalThis.hello = hello;
})();
function hello() {}
これによりトップレベルにグローバル関数を定義しつつ、その内部実装は IIFE 内で定義するということを行っています。前者は bin/build.ts
で、後者は src/project.ts
で実装しています。
おわりに
Clasp + esbuild + TypeScript + Jest + Git での GAS 管理手法について、実装コードも全て含めて解説しました。今回のサンプルコードは以下のリポジトリから参照できます。
この記事があなたの GAS + Clasp 開発の参考になれば幸いです。