概要
個人的な用事でコードを書く時はNode.js(TypeScript)で書いてしまうのですが、気が付いたら1からAPIを5つぐらい作ってました。これはこれで勉強になるので良いのですが、少しは楽をしたい...ってことで環境構築時のメモを残しておきます。雰囲気で理解してる所も多々ある気がします。
IDEはVisual studio Codeを使って行きます。
この記事でやること
以下のことを行います。
- TypeScript環境(TypeScript + TSLint + Pretiier)を構築する
- テスト環境をJestで用意する
- ExpressでAPIサンプル(簡単なCRUD)を用意する
- TypeScriptをES5にトランスパイルする
- APIに対してTypeScriptでテストを書く
- ローカルの環境をDocker化する
少し長くなるかもしれませんが、「書いてある通りやればとりあえず動く状態になる」を目指していこうと思います。一通り読んで雰囲気が伝われば良いなと...思いますねぇ!(突然のケイスケ・ホンダ)。
TypeScriptでテストコードを書くところとかは興味本位でやっているのでスルーしても良いかもしれません。実際にAPIのテストコードをちゃんと書くならTypeScriptではなく制約が緩い環境(ES5/6)で書くことをおすすめします。
この記事でやらないこと
- Cloud Functions for Firebaseを使う
- CircleCIを使ってCI/CD
これらの内容はテンプレートってわけでも無いので別の記事でやっていこうと思います。
環境
- macOS 10.14.6
- node 13.1.0
- npm 6.12.1
- code 1.40.0
- docker 19.03.4
- docker-compose 1.24.1
作業ディレクトリ用意
説明することも無いのでコマンドだけ。
~$ mkdir ts-api-base
~$ cd ts-api-base
~$ npm init -y
ここまでTerminalから実行したらvSCodeでts-api-baseディレクトリを開いておきましょう。
環境構築
TypeScript + TSLint + Prettier
まずは「TypeScript + TSLint + Prettier」の環境を作っていきます。
TypeScript
まずはTypeScript本体をインストールします。
~$ npm install -D typescript
インストールが完了したら以下のコマンドでトランスパイル設定(tsconfig.json)を作成します。ファイルはルートディレクトリに作成されます。
~$ node_modules/.bin/tsc --init
上記コマンドはtscコマンドをパスを追って実行していますが、npxコマンドを使うとインストール済みのPackageはローカル→グローバルと探して実行してくれます。グローバルにも無ければ一時的にダウンロードして、実行できます。コマンドとしてはこのようになります。今後はnpxコマンドを利用して進めていきます。
~$ npx tsc --init
tsconfig.jsonを見るとほとんどがコメントアウトされていると思いますが、いくつかコメントアウトを外して値を書き換えたり、項目を追加していきます。TypeScriptのビルド結果出力先とTypeScriptのコードがどこにあるかを設定します。
/* 以下のコメントを外して、値を書き換える */
"outDir": "./build", /* Redirect output structure to the directory. */
/* 以下は"compilerOptions"を同階層に追加 */
"include": ["src/**/*"]
TSLint
以下のコマンドでTSLint本体をインストールとLint設定(tslint.json)を作成します。
~$ npm i -D tslint
~$ npx tslint --init
tslint.jsonの初期はこのような感じになっていると思います。
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}
何も設定しないのは寂しいので「セミコロンは不要」というルールを追加してみましょう。以下の項目を設定ファイルの"rules"に追加します。
"semicolon": [true, "never"]
TSLintのルール(制約)はTSLint core rulesで確認できます。
インストールとLint設定の作成が完了したらVSCodeのプラグイン「TSLint (deprecated) eg2.tslint」をインストールします。これでLintの準備はOKです。
ルートディレクトリにsrcディレクトリを作成してindex.tsをいうファイルを作成し、以下のコードを貼り付けて保存してみてください。
const hoge: string = "hoge";
console.log(`I'm ${hoge}`)
index.ts
Unnecessary semicolon tslint(semicolon) [1,28]
Calls to 'console.log' are not allowed. tslint(no-console) [2,1]
file should end with a newline tslint(eofline) [2, 27]
Lint設定の通りにエラーが出ていることが確認できれば動作確認OKです!「Lintに設定していないところもエラーが出ているんだが???」と思われるかもしれませんが、Lintにはデフォルト値が存在しており、そのデフォルト値によってLint警告が出ている、となります。
Lint設定のルールに以下を追加して保存してみましょう。
"no-console": [false]
コードを直さなくてもtslint(no-console)に関するエラーが1つ減ったと思います。index.tsの1行目のセミコロンを削除して、最終行に空行を追加してみましょう。エラーが全て消えましたか?こうやってルールを改変したりコードを直すことで制約や規約に準じたコードを書けることがLintを取り入れるメリットですね!
Prettier
Prettier本体をインストールします。
~$ npm i -D prettier
ルートディレクトリに.prettierrcというファイルを作成します。このファイルがPrettierの設定になります。ファイルを作成したら、動作確認のために以下の設定を書いておきます。
この他の設定はOptionsを参照ください。
{
"singleQuote": true
}
動作確認のために先程作ったsrc/index.tsのコードを以下のように書き換えます。
const hoge:string="hoge"
console.log(`I'm ${hoge}`)
const piyo: number[] = [1, 2
,
3,
]
ごちゃごちゃですね。Prettierで整形してみましょう。まずは以下のコマンドを実行してみてください。
npx prettier src/index.ts
以下のようなコードがTerminalに表示されていればOKです。
const hoge: string = 'hoge';
console.log(`I'm ${hoge}`);
const piyo: number[] = [1, 2, 3];
writeオプションを付けるとコードを整形して保存してくれます。試してみましょう。
~$ npx prettier --write src/index.ts
index.tsが以下のようなコードになったらOKです!
const hoge: string = 'hoge';
console.log(`I'm ${hoge}`);
const piyo: number[] = [1, 2, 3];
ここまで確認できたらファイルの作成が完了したらVSCodeプラグイン「Prettier - Code formatter esbenp.prettier-vscode」をインストールしましょう。インストール後、index.tsを再度保存してみてください。prettierを実行した時と同じ結果が得られます。便利ですね!
「あれ?自分の環境だと整形されないけど...」という方はVSCodeの設定(Preferences)のEditor: Format On SaveがOFFになっているかもしれません。この設定をONにすると有効になると思います(User設定かWorkspace設定かもご確認ください)。
...
......
.........「あれれ〜?おかしいな〜??TSLint設定は無視されちゃうのかなぁ〜???」とコ○ンくんも言い出しそうな状態ですね。TSLintとPrettier...仲良くしてもらわないと困ってしまいますね。
tslint-config-prettierとtslint-plugin-prettier
tslint-config-prettierとtslint-config-prettierはTSLintとPrettierの衝突を解消してくれるPackageになります。いつものようにインストールしていきます。configは衝突防止、pluginは解消ではなくTSLintとPrettierを統合するようなイメージです。正しいかどうかは微妙です。詳しい方、教えて下さい...。
ではPackageをインストールしていきます。
~$ npm i -D tslint-config-prettier tslint-plugin-prettier
インストール完了後、tslint.jsonのextendsにtslint-config-prettier、rulesDirectoryにtslint-plugin-prettierを追加します。以下のような形になるかと思います。
{
"defaultSeverity": "error",
"extends": ["tslint:recommended", "tslint-config-prettier"],
"jsRules": {},
"rules": {
"semicolon": [true, "never"],
"no-console": [false]
},
"rulesDirectory": ["tslint-plugin-prettier"]
}
設定が完了したらsrc/index.tsを保存してみましょう。TSLint設定とPrettier設定が同居して整形もされてエラーも無い状態になっているかと思います!
TSLintとPrettierの設定競合はtslint-config-prettierのtslint-config-prettier-checkコマンドで確認できます。
~$ npx tslint-config-prettier-check ./tslint.json
衝突があれば以下のように教えてくれますので設定を見直していきましょう。チームのコーディング規約に照らし合わせてカスタマイズしてください。
Conflict rule(s) detected in ./tslint.json:
semicolon
trailing-comma
Jest
テストツールはJestを利用していきます。TypeScriptでもテストコードが書けるように作っていきます。
まずはJest本体をインストールしていきます。型定義やTypeScript上で動かすためのPackageもインストールしていきます。
~$ npm i -D jest @types/jest ts-jest
インストールが完了したら初期設定を行います。以下のコマンドを実行しましょう。
~$ 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"? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Automatically clear mock calls and instances between every test? … yes
✏️ Modified /Users/.../ts-api-base/package.json
📝 Configuration file created at /Users/.../ts-api-base/jest.config.js
ルートディレクトリにjest.config.jsというファイルができていると思います。このファイルがJestの設定になります。中身を少し修正する必要があるので修正していきます。コメントを外して値の書き換えをしていきましょう。
"roots": [
"<rootDir>/test"
]
"transform": {
"^.+\\.tsx?$": "ts-jest"
}
"testRegex": "(/test/.*|(\\.|/)(test|spec))\\.tsx?$"
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
]
設定が完了したらテストの動作確認用のコードを作成しましょう。srcディレクトリ配下にcalculation.tsというファイルを作成し、以下のコードを貼り付けます。内容としては基本的な四則演算ですね。
export const addition = (i: number, j: number): number => i + j
export const subtraction = (i: number, j: number): number => i - j
export const multiplication = (i: number, j: number): number => i * j
export const division = (i: number, j: number): number => i / j
コードを貼り付けて保存したらルートディレクトリにtestディレクトリを作成します。testディレクトリの中にcalculation.test.tsというファイルを作成し、作成したファイルにテストコードを書いていきます。テストコードは以下の通りです。
import {
addition,
division,
multiplication,
subtraction
} from '../src/calculation'
describe('calculationのテストを実行します!絵文字も使えるよ📚', () => {
describe('addition method', () => {
test('1 + 2', () => {
expect(addition(1, 2)).toBe(3)
})
test('0 + 0', () => {
expect(addition(0, 0)).toBe(0)
})
})
describe('subtraction method', () => {
test('10 - 9', () => {
expect(subtraction(10, 9)).toBe(1)
})
test('0 - 3', () => {
expect(subtraction(0, 3)).toBe(-3)
})
})
describe('multiplication method', () => {
test('3 * 3', () => {
expect(multiplication(3, 3)).toBe(9)
})
test('1 * -1', () => {
expect(multiplication(1, -1)).toBe(-1)
})
})
describe('division method', () => {
test('10 / 2', () => {
expect(division(10, 2)).toBe(5)
})
test('4 / 2', () => {
expect(division(4, 2)).toBe(2)
})
})
})
テストコードの保存が完了したらTerminalからテストコードを実行してみましょう!Jest設定に準じて自動的にテストコードを探して実行してくれます。
~$ npx jest
以下のような結果を得られれば動作確認OKです!
PASS test/calculation.test.ts
calculationのテストを実行します!絵文字も使えるよ📚
addition method
✓ 1 + 2 (2ms)
✓ 0 + 0 (1ms)
subtraction method
✓ 10 - 9
✓ 0 - 3 (1ms)
multiplication method
✓ 3 * 3
✓ 1 * -1
division method
✓ 10 / 2
✓ 4 / 2
Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 0.8s, estimated 2s
Ran all test suites.
もちろん非同期処理のテストもできます。非同期のテストはAPIを軽く作ってから行っていきます。
ここから先が本番みたいなところあります。まずは先程までに作成した3ファイル(index.ts, calculation.ts, calculation.test.ts)はもう使わないので削除しておきます。
Expressで簡単なAPIを作る
まずはExpress本体をインストールします。合わせてTypeScriptをNode.js上で実行するためにts-nodeというPackageも追加しておきます。
~$ npm i express
~$ npm i -D @types/express ts-node
Packageのインストールが完了したら以下の構成で3つのファイルを作成します。中身は後で書きますので、一旦は空でOKです。
<rootDir>
└ src
├ app.ts
├ start.ts
└ route
├ noImpl.ts
└ sample.ts
ファイルを作成したら以下のコードを各ファイルに貼り付けて行きましょう。
// app.ts
import bodyParser from 'body-parser'
import express from 'express'
import noImpl from './route/noImpl'
import sample from './route/sample'
class App {
public app: express.Application
constructor() {
this.app = express()
this.app.use(bodyParser.urlencoded({ extended: false }))
this.app.use(bodyParser.json())
this.routes()
}
public routes() {
this.app.use('/sample', sample)
this.app.use('/noImpl', noImpl)
this.app.use((req: express.Request, res: express.Response) => {
res.status(404)
res.json({
message: 'The specified endpoint cannot be found.',
requestPath: req.path
})
})
}
public start() {
this.app.listen(3000, () => console.log('listening on port 3000!'))
}
}
export default App
// start.ts
import app from './app'
const server = new app()
server.start()
// noImpl.ts
import * as express from 'express'
export default function noImpl(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
res.status(501).json({
message: 'Not Implemented.'
})
}
// sample.ts
import * as express from 'express'
const router = express.Router()
const waitMs: number = 2000
/**
* POST
*/
router.post(
'/',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.body.hoge,
method: 'POST'
}),
waitMs
)
}
)
/**
* GET
*/
router.get(
'/',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
method: 'GET'
}),
waitMs
)
}
)
router.get(
'/:hoge',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.params.hoge,
method: 'GET'
}),
waitMs
)
}
)
/**
* DELETE
*/
router.delete(
'/',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
method: 'DELETE'
}),
waitMs
)
}
)
router.delete(
'/:hoge',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.params.hoge,
method: 'DELETE'
}),
waitMs
)
}
)
/**
* PUT
*/
router.put(
'/',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.body.hoge,
method: 'PUT'
}),
waitMs
)
}
)
router.put(
'/:hoge',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.params.hoge,
method: 'PUT'
}),
waitMs
)
}
)
/**
* PATCH
*/
router.patch(
'/',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.body.hoge,
method: 'PATCH'
}),
waitMs
)
}
)
router.patch(
'/:hoge',
(req: express.Request, res: express.Response, next: express.NextFunction) => {
setTimeout(
() =>
res.status(200).json({
hoge: req.params.hoge,
method: 'PATCH'
}),
waitMs
)
}
)
export default router
ちょっと長かったですが、とりあえずのサンプルなのでコピペでOKです。
貼り付けて保存したらTerminalで以下のコマンドを実行します。
~$ npx ts-node src/start.ts
Terminalに「listening on port 3000!」と表示されていればサーバ起動成功です!起動が上手く行ったらPostman等のツールでCRUDアクセスしてみてください!
本当に最低限しか実装していないので、何かしらDB用意して...mongoDBとか...と考えましたが...それはまたいつか。
TypeScriptをES5に変換して動かす
Webpack + BabelやParcelとかが候補だと思いますが、個人で作る規模のものならtscコマンドで十分かなと思います(個人的に)。という事でTypeScriptで書いたコードをES5にトランスパイルしてES5のコードで実行してみましょう。
~$ npx tsc
トランスパイルが完了したらESのコードを動かしてみましょう。
~$ node build/start.js
ts-nodeで実行した時と同じ結果が得られていればOKです!
エンドポイント(ルーティング)のテストを行う
HTTPのテストになるのでsupertestをインストールして利用していきます。
~$ npm i -D supertest @types/supertest
テストコードはsupertestの使い方とJestの使い方をちょっと把握すればある程度は読み書きできるかと思います。testディレクトリにendpoints.test.tsというファイルを作って以下のコードを貼り付けます。
import request from 'supertest'
import App from '../src/app'
describe('GET /noImpl', () => {
it('return 501', async (done: jest.DoneCallback) => {
const server: App = new App()
const response = await request(server.app).get('/noImpl')
expect(response.status).toBe(501)
expect(response.body.message).toEqual('Not Implemented.')
done()
})
})
describe('POST /sample', () => {
it('return 200', async (done: jest.DoneCallback) => {
const server: App = new App()
const reqBody = { hoge: 'hoge' }
const response = await request(server.app)
.post('/sample')
.send(reqBody)
expect(response.status).toBe(200)
expect(response.body.hoge).toEqual('hoge')
expect(response.body.method).toEqual('P0ST')
done()
})
})
「POST /sample」のテストはFailするように書いてあります。Failする部分を直してみましょう(突然の課題)。
テストコードを普段あまり書かない方は足りないテストコード(たくさん足りてません)を少し付け足して練習してみてはどうでしょうか?(手抜き)
--coverageオプションを付与するとテストカバレッジが出力されます。
npx jest --coverage
開発環境のDocker化
ローカルにある開発環境をDocker化しておこうと思います。まず、今回Docker化する時にやりたいことを整理しておきます。
- docker-compose up時にnpm installが実行されること
- コンテナ内でTypeScriptのコード(「npx ts-node src/start.ts」や「npx jest」、「npx tsc」)が実行可能できること
- 先程まで作ったプロジェクトコードをapiディレクトリに移動し、apiディレクトリをコンテナにマウントする
とりあえずこんな感じを目標に進めていきます。まずはプロジェクトルートにapiディレクトリを作成して「.vscodeディレクトリ」「node_modulesディレクトリ」「buildディレクトリ」以外のディレクトリやフォルダを全てapiディレクトリに移動します。「node_modulesディレクトリ」は一度削除しておきます。移動が完了したらプロジェクトルートディレクトリにdocker-compose.ymlファイルを作成します。docker-compose.ymlはこんな感じでしょうか。imageは何でも良いですが、個人的なアレでcircleci/nodeを指定しました。
version: '2'
services:
myAPI:
image: circleci/node
ports:
- 3000:3000
volumes:
- ./api:/api
command: [sh, -c, cd api && npm install && /bin/sh]
tty: true
Terminalでプロジェクトルートに移動して以下のコマンドでコンテナを起動します。
~$ docker-compose up
少し時間がかかる(環境によりますが60secぐらい)と思いますが、なんやらかんやら動いてコンテナが起動します。コンテナが起動したら必要なPackageがインストールされていると思います。以下のコマンドでコンテナの中に入ってコードをトランスパイルしてサーバを起動してみましょう。
~$ docker-compose exec myAPI /bin/sh
~> cd api
~> npx tsc
~> node build/start.js
サーバが起動したらlocalhost:3000に繋いでレスポンスが返ってくるか確認して、レスポンスが返ってきたら動作確認OKです!動作確認後はctrl+cでサーバを止めてexitコマンドでコンテナからログアウトしましょう。コンテナからログアウトしたら以下のコマンドでコンテナを停止しておきましょう。
~$ docker-compose stop
終わり
1つ1つ整理しながら書いてたら理解してない部分は多いなぁと感じました。node-devやts-node-devを入れてdocker-compose upした時にホットリロードするようにしておいた方が良かった気もします。
社内での役割はEMなので実際の案件でコードを書く機会はほとんどありません。実案件だと先にデプロイ先を加味してプロジェクト作ったりCI/CD環境を作ったりすると思いますが、その辺りはまた別の記事でやってみようと思います。