フロントエンドのLinterやCIを改善した話
この記事は 株式会社エス・エム・エス Advent Calendar 2023 の21日目の記事です。
介護事業者向けの経営支援サービス「カイポケ」のリニューアルプロジェクトでフロントエンド開発をしている @hush_in です。
今年の4月にエス・エム・エスに入社しました。
入社してからフロントエンドのLinterやCIを改善した話をします。
忙しい人向けまとめ
- ESLint の recommended 系 extends を追加
- 全般
eslint:recommended
plugin:import/recommended
- TypeScript
plugin:@typescript-eslint/recommended-type-checked
plugin:@typescript-eslint/stylistic-type-checked
plugin:import/typescript
plugin:jest/recommended
plugin:jest/style
plugin:testing-library/react
plugin:jest-dom/recommended
- GraphQL
plugin:@graphql-eslint/schema-recommended
- 全般
- GhatGPTを活用して lint error を自動修正
- コーディングガイドラインのESLint ルール化
-
eslint-plugin-check-file
でファイル名、ディレクトリ名のルール追加 -
no-restricted-syntax
で 独自のルール作成
-
- CI
- テストファイルやStorybookも型チェックするように設定追加
- Jest, ESLint, Prettier, Next.js build のキャッシュ導入
各設定の解説
はじめに
プロジェクトのフロントエンドの技術スタックは主に Next.js, TypeScript, Apollo Client, Storybook, Jest を使用しています。 詳しくは 大規模SaaS 「カイポケ」の未来を支えるフロントエンドの技術選定 をご覧ください。
入社した時点ではESLintの設定は下記のようにそこまで多くのルールは設定されていませんでした。
{
"extends": [
"plugin:storybook/recommended",
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["import", "unused-imports", "@typescript-eslint"],
"rules": {
// ...
}
}
カイポケのリニューアルプロジェクトは長期間続き、開発者も増える想定です。
そこで下記目的でESLint設定を追加していきました。
- 単純な実装ミス、バグを未然に防ぎたい
- コーディングスタイルを統一して書き方のぶれが少なくしたい
設定した順に説明していきます。後で現在の設定を載せます。
基本的な流れ
- extend や rule を追加
- 自動修正できる箇所をコミット
- rule毎エラーになる箇所を修正、もしくは除外したいところを
// eslint-disable-next-line
コメントを追加してコミット - 修正箇所が多い場合はPRを分ける
- 一旦ruleを無効化しておいて、順次有効化して修正するPRを出す
ESLint の recommended 系 extends を追加
eslint:recommended
eslint:recommended
を追加しました。意外にも next/core-web-vitals
には入ってないので別途入れる必要があります。
import 系
次に plugin:import/recommended
, plugin:import/typescript
を追加しました。
import の自動整形が便利です。
例
- import { MouseEvent, SyntheticEvent } from 'react';
- import { KeyboardEvent } from 'react';
+ import { MouseEvent, SyntheticEvent, KeyboardEvent } from 'react';
ただし、実行時間がかかるようになりました。
TIMING=1 npm run lint
で時間を計測すると import/namespace
がかなりの割合を締めていました。(2023年7月時点)
Rule | Time (ms) | Relative
:---------------------------------|----------:|--------:
import/namespace | 14879.360 | 87.8%
next/core-web-vitals
内で設定しているparser 周りのオプションが悪そうだったのでそれを使わないように設定したところ、改善しました。
Rule | Time (ms) | Relative
:---------------------------------|----------:|--------:
import/namespace | 4098.119 | 65.2%
※現時点(2023年12月)で import/namespace
の時間を調べたところ、 next/core-web-vitals
を使って 9.5秒、使わずに対策しても 6.1秒とそこまで差がでませんでした。
typescript-eslint
ちょうど typescript-eslint v6 が出たタイミングで設定しました。
まずは plugin:@typescript-eslint/recommended-type-checked
を追加しました。
型をより厳しくできます。
エラーが100箇所程出るルールがあり、grepや手作業で修正するのが大変だったのでChatGPTにスクリプトを書かせて機械的に置換しました。ChatGPTで yak shaving の時間を短縮できたので良かったです。
no-floating-promises の修正例:
個別の実装で await
をつけるべきかは判断できなかったので、現状の挙動と同じになるように void
をつけました。
ChatGPTへの依頼文
ESLintで @typescript-eslint/no-floating-promises で警告が出る箇所に一括でvoid operator をつけたい。
ESlint のログは 下記のように出ます。
このようなログからファイル名、行数、列数をパースし、void operator を挿入するスクリプトをperlで書いてください。path/hoge/fuga.tsx:80:7: Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator. [Error/@typescript-eslint/no-floating-promises]
path/hoge/piyo.tsx:23:7: Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator. [Error/@typescript-eslint/no-floating-promises]
最終的にできたScript
#!/usr/bin/perl
use strict;
use warnings;
use Tie::File;
my $log = './path/to/eslint_log.txt';
open my $fh, '<', $log or die "Can't open file $!";
my @errors = <$fh>;
close $fh;
for my $error (@errors) {
if ($error =~ m!(.*):(\d+):(\d+):!) {
my ($filename, $line, $col) = ($1, $2, $3);
tie my @lines, 'Tie::File', $filename or die "Can't open file $!";
my $original_line = $lines[$line - 1];
$lines[$line - 1] = substr($original_line, 0, $col - 1) . "void " . substr($original_line, $col - 1);
untie @lines;
}
}
AST操作したほうが確実ですが、これくらいの簡単な修正なら機械的な置換でできました。
次に plugin:@typescript-eslint/stylistic-type-checked
を追加しました。
自動修正でスタイル統一の各種ルールが導入ができます。
テスト系
以下のextends を追加しました。特に良かったルールを箇条書きで載せます。
-
plugin:jest/recommended
-
expect-expect テスト内で
expect
呼び忘れを検出 - valid-expect matcher 関数の呼び忘れを検出
-
expect-expect テスト内で
plugin:jest/style
-
plugin:testing-library/react
-
prefer-presence-queries 適切な
get*
query*
クエリを使う
-
prefer-presence-queries 適切な
-
plugin:jest-dom/recommended
-
prefer-in-document
.toBeNull()
よりも.not.toBeInTheDocument()
を優先する
-
prefer-in-document
Lintエラーを直す最中に、本当はテストが失敗するけど成功してしまうものを発見し、修正することができました。
graphql-eslint
plugin:@graphql-eslint/schema-recommended
を追加しました。
命名規則が厳しくなったり @deprecated
のschema に気づけたりします。
その他
eslint-config-airbnb-base を参考に良さそうなルールを追加しました。
eslint-config-airbnb-base 自体は2年間更新がないので入れるのをやめました。
eslint-config-standard-with-typescript も導入しようかと思いましたが、依存するplugin のバージョンが古かったのでやめました。
コーディングガイドラインのESLint ルール化
eslint-plugin-check-file
でコンポーネントのファイル名はPascalCase、 ディレクトリ名は lowerCamelCase になるようにしました。
no-restricted-syntax で「コンポーネントでchildren を受け取る場合は PropsWithChildren を利用する」という独自ルールを設定しました。
TypeScript ASTは書き慣れていなかったのですが、ChatGPTで草案を作って https://astexplorer.net/ (parser は @typescript-eslint/parser
)で確認して微調整してできました。
{
files: ['*.ts', '*.tsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"TSTypeLiteral > TSPropertySignature[key.name='children'][typeAnnotation.typeAnnotation.typeName.name='ReactNode']",
message: 'Use PropsWithChildren instead of manually typing children.',
},
],
},
},
CI改善
型チェック
Next.js はビルド時にTypeScriptの型チェックできますが、範囲はビルド対象のファイルのみです。
テストファイルやStorybookも型チェックしたいので、
package.json
の scripts に下記コマンドを追加し、CIに含めました。
"typecheck": "tsc --noEmit && echo Done."
キャッシュ導入
下記コマンドのGitHub Actions キャッシュを設定し、実行時間を短縮しました。
- Jest
- ESLint: https://nextjs.org/docs/pages/building-your-application/configuring/eslint#caching
- Prettier: https://prettier.io/docs/en/cli.html#--cache
- Next.js build
(.next/cache): https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching - ※ Storybook は キャッシュの有無で時間変化しなかったので設定なし
現在の設定
`.eslintrc.js`
/** @type {import('eslint').ESLint.ConfigData} */
module.exports = {
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:storybook/recommended',
// next/core-web-vitals を設定すると import/* ルールの実行に時間がかかるので、
// 内部で使っている設定を使用 https://github.com/vercel/next.js/tree/canary/packages/eslint-config-next
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@next/next/core-web-vitals',
'prettier' /* prettierはextendsの最後に指定する https://github.com/prettier/eslint-config-prettier */,
],
plugins: ['unused-imports', 'check-file'],
env: {
browser: true,
node: true,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// next/core-web-vitals の設定を一部copy
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'import/order': [
'error',
{
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'unused-imports/no-unused-imports': 'error',
'@next/next/no-img-element': 'off',
// import 時に名前を強制するため
'import/no-default-export': 'error',
// tsserver に任せるので off
'import/no-unresolved': 'off',
// Component の記法統一のため
'react/self-closing-comp': [
'error',
{
component: true,
},
],
'check-file/filename-naming-convention': [
'error',
{
'src/{lib,services}/**/*.tsx': 'PASCAL_CASE', // Component のファイル名はパスカルケース
},
{
ignoreMiddleExtensions: true,
},
],
'check-file/folder-naming-convention': [
'error',
{
// ディレクトリ名は LowerCamelCase にする。
// Next.js 由来の square brackets を許可するため、custom patterns (glob syntax) で記述
// https://github.com/DukeLuo/eslint-plugin-check-file/blob/main/docs/rules/folder-naming-convention.md#built-in-custom-patterns
'src/{lib,services}/**/': '?(\\[)[a-z]*',
},
],
// airbnb ルールを参考に設定
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/best-practices.js
'array-callback-return': ['error', { allowImplicit: true }],
'no-else-return': ['error', { allowElseIf: false }],
'no-throw-literal': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
radix: 'error',
'prefer-regex-literals': [
'error',
{
disallowRedundantWrapping: true,
},
],
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/es6.js
'prefer-destructuring': [
'error',
{
VariableDeclarator: {
array: false,
object: true,
},
AssignmentExpression: {
array: true,
object: false,
},
},
{
enforceForRenamedProperties: false,
},
],
'object-shorthand': [
'error',
'always',
{
ignoreConstructors: false,
avoidQuotes: true,
},
],
'prefer-template': 'error',
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/style.js
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/imports.js
'import/no-useless-path-segments': ['error', { commonjs: true }],
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/errors.js
'no-promise-executor-return': 'error',
},
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: [
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:import/typescript',
'plugin:jest/recommended',
'plugin:jest/style',
'plugin:testing-library/react',
'plugin:jest-dom/recommended',
'prettier' /* prettierはextendsの最後に指定する https://github.com/prettier/eslint-config-prettier */,
],
parserOptions: {
// lint 対象のファイルに最も近い tsconfig.json を利用する。
// ref: https://typescript-eslint.io/linting/typed-linting/#specifying-tsconfigs
project: true,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
ignoreRestSiblings: true,
caughtErrors: 'all',
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/prefer-nullish-coalescing': [
'error',
{
ignorePrimitives: {
// 論理和 `||` でよく使う 空文字列、false を許可
string: true,
boolean: true,
},
},
],
// () => void を期待する パラメータに () => Promise<void> を渡してもOKとする
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
},
},
],
// 意図しない Declaration Merging を避けるため、Props は interface でなく型エイリアスで実装する
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
},
},
{
files: ['*.ts', '*.tsx'],
excludedFiles: ['src/lib/**/*'],
rules: {
'no-restricted-syntax': [
'error',
{
// Component で子要素 = children を受け取る場合は PropsWithChildren を利用する
selector:
"TSTypeLiteral > TSPropertySignature[key.name='children'][typeAnnotation.typeAnnotation.typeName.name='ReactNode']",
message: 'Use PropsWithChildren instead of manually typing children.',
},
],
},
},
{
files: ['**.stories.tsx', 'src/pages/**/*'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: ['src/**/*.graphql'],
excludedFiles: ['src/**/*.local.graphql'],
extends: ['plugin:@graphql-eslint/operations-recommended'],
parserOptions: {
operations: 'src/**/*.graphql',
schema: ['src/generated/schema.graphql', 'src/**/*.local.graphql'],
},
rules: {
'@graphql-eslint/no-deprecated': 'warn',
},
},
{
files: ['*.test.ts?(x)'],
plugins: ['testing-library'],
rules: {
// userEvent の方が fireEvent よりも忠実にユーザーの操作をエミュレートできるため
'testing-library/prefer-user-event': 'error',
// require('next-router-mock') がanyを返すのを許容
'@typescript-eslint/no-unsafe-return': 'off',
// parameters.msw.handlers 参照時、 composeStories の型推論 がうまく行かないのでoff
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
},
},
{
// Storyの型の明示し型推論を効かせる
files: ['src/**/*.stories.ts?(x)'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'error',
},
},
],
};
`package.json` の CI系のscripts
"scripts": {
"build": "npm run pathpida && next build",
"postbuild": "next export",
"prettier-base": "prettier --ignore-unknown --cache --cache-location=.prettier-cache './**/*.{ts,tsx,graphql}'",
"lint": "next lint -d . --ext .ts --ext .tsx --ext .graphql",
"lint:prettier": "npm run prettier-base -- --check",
"fmt": "npm run fmt:eslint && npm run fmt:prettier",
"fmt:eslint": "npm run lint -- --fix",
"fmt:prettier": "npm run prettier-base -- --write",
"test": "jest",
"typecheck": "tsc --noEmit && echo Done."
},
`.github/workflows/frontend_lint.yml` (CIのうちLint部分)
name: frontend/lint
on:
pull_request:
paths:
- "frontend/**"
- ".github/workflows/frontend_*.yml"
push:
branches:
- 'main'
paths:
- 'frontend/**'
workflow_dispatch:
defaults:
run:
working-directory: frontend
jobs:
run-ci:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: sparse checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
sparse-checkout: frontend
- uses: actions/setup-node@v4
with:
node-version-file: "frontend/.node-version"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Restore cached Next (next lint), prettier
uses: actions/cache/restore@v3
with:
# https://nextjs.org/docs/pages/building-your-application/configuring/eslint#caching
path: |
frontend/.next/cache
frontend/.prettier-cache
key: frontend-next-prettier-v1-${{ github.sha }}
restore-keys: |
frontend-next-prettier-v1-
- run: npm run typecheck
- name: Check the format
run: |
npm run fmt
if ! git diff --exit-code --quiet; then
git status -s
echo フォーマット漏れがあります。Mがついているファイルを修正してください
exit 1
fi
- name: Save Next (next lint), prettier cache
uses: actions/cache/save@v3
if: github.ref == 'refs/heads/main'
with:
path: |
frontend/.next/cache
frontend/.prettier-cache
key: frontend-next-prettier-v1-${{ github.sha }}
おわりに
LinterとCIの改善を通じて、フロントエンドのコードの理解を深め、コード品質の向上に貢献することができました。
入社当初はドメインに知識が少なく、機能実装ではすぐに価値を出すことが難しいと感じていました。
開発環境の改善を並行して行うことで、プロジェクトに貢献している満足感を得ることができ良かったです。
株式会社エス・エム・エスではソフトウェアエンジニアを募集しています。
フロントエンドで開発、解決したい課題がまだたくさんあります!
興味を持った方は エンジニア採用情報 をご覧ください。
Discussion