Cypress ノート
Cypress を少しさわった後に知っておくと捗りそうなもの。Cypress v5.3.0 で動作確認済みです。(v6 でも問題はなさそうでした。)
Cypress が何を利用しているか
Cypress はテスト実行時に複数のツールを利用しています。何かにつまづいた時、それが「Cypress の話」なのか「Cypress が利用しているツールの話」なのか切り分けて考えられると適切な情報を探しやすいかもしれません。
Cypress Driver
- Driver というライブラリがブラウザにロードされます。
cy
オブジェクトなどはこのパッケージで定義されています。
- Driver というライブラリがブラウザにロードされます。
Mocha
- テストランナーライブラリ
before
,beforeEach
などのフックについて知りたい場合は Mocha のドキュメント で確認できます
Chai
- アサーションライブラリ
.should('have.length', 3)
の'have.length'
が意味しているところ、あるいは他のアサーションのパターンについて知りたい場合は Chai のドキュメント で確認できます- オフィシャルガイドの Assertions -> Chai にも一覧があります
chai-jquery
Chai
の エクステンションです.should('not.be.checked')
のように DOM 要素 (厳密には jQuery オブジェクト) のアサーションをしたい場合は chai-jquery のドキュメント から確認できます- オフィシャルガイドの Assertions -> Chai-jQuery にも一覧があります
さらに詳しく
- Bundled Tools | Cypress Documentation
- Trade-offs | Cypress Documentation => Inside the browser
要素のクエリ
jQuery ライクに記述
jQuery と同じような記述で要素をクエリすることが可能です。
下のコードを例にすると、cy.get(...)
が jQuery の $(...)
と同じクエリ動作になります。find
, children
, first
は名前もクエリ動作も jQuery と同じです。
// Each method is equivalent to its jQuery counterpart. Use what you know! cy.get("#main-content") .find(".article") .children('img[src^="/static"]') .first();
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Querying Elements
- API ドキュメント の Commands でクエリ用のコマンド (
closest
,eq
,filter
,find
,parent
parents
, ...) や DOM 操作用のコマンドが確認できます。jQuery に存在しないクエリ用のコマンド (contains
) もあります 。 - Cypress Testing Library を導入することで jQuery ライクにクエリする代わりに DOM Testing Library のメソッド (
findByText
,findByTitle
など) を利用することも可能です。
invoke
利用頻度は低いかもしれませんが invoke というコマンドを紹介します。
cy.get(".modal").invoke("show"); // Invoke the jQuery 'show' function
invoke
自体は jQuery と直接関係ない機能ですが、このように利用することで Cypress が用意していない jQuery のメソッドも簡単に実行できます。以下コードの様に invoke
を利用しないで直接 jQuery オブジェクトを扱うことも可能です。
// Not recommended cy.get(".modal").then(($modal) => $modal.show());
ここでは詳細は省きますが、両者の挙動はまったく同じというわけではありません。例えば involve
を利用した方は Cypress のリトライ機能が働きます。
contains
よく利用すると思われるコマンド、contains も紹介しておきます。
cy.get(".nav").contains("About"); // Yield el in .nav containing 'About' cy.contains("Hello"); // Yield first el in document containing 'Hello'
このように、指定したテキストが含まれている要素を返します。完全一致ではなく「含まれているかどうか」を確認します。
contains
は第 1 引数に selector を指定することもできます。この場合コマンドの結果 Yield される結果が異なります。
<nav class="nav"> <ul> <li>Top</li> <li>About</li> </ul> </nav>; cy.get(".nav").contains("About"); // Yield `<li>About</li>` cy.contains(".nav", "About"); // Yield <nav class="nav">...</nav>
実は Yield される結果だけでなくリトライの挙動も変わるのですが、リトライに関しては別のセクションで取り上げます。
アサーション
Cypress のアサーションは大きく分けると、Cypress が自動で実行するデフォルトアサーションとユーザーが記述するアサーションに分かれます。
デフォルトアサーション
Cypress のコマンドの多くはデフォルトアサーションを持っています。そのため、テストによってはアサーションがまったくない(ように見える)ことがあります。
例えば "New Project"
というテキストが存在することをテストしたい場合以下のように書きます。
- ✅
cy.contains("New Project");
- ⚠️
cy.contains("New Project").should("exist");
.should("exist")
が不要
以下のコード内のコマンド visit
, get
, contains
, click
, type
これらは全てデフォルトアサーションを持っています。
// https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#When-To-Assert cy.visit("/home"); // expects the page to send text/html content with a 200 status code. cy.get(".main-menu") // expects the element to eventually exist in the DOM. .contains("New Project") // expects the element with content to eventually exist in the DOM. .click(); // expects the element to eventually be in an actionable state. cy.get(".title").type("My Awesome Project"); // expects the element to eventually be in a typeable state. cy.get("form").submit();
各コマンドがどのような内容をアサートするかは API ドキュメントの Rules に記述されています。
DOM が存在しなことをテストする
cy.get(".does-not-exist").should("not.exist");
デフォルトアサーションは便利ですが、常にアサートが実行されてしまうと存在しないことのテストができなくなります。例えば上の例では cy.get(".does-not-exist")
の時点でエラーになってしまいます。 .should("not.exist")
に到達することができません。
ですが、実はこのサンプルは問題なく動きます。.should()
コマンドをつなげると、デフォルトアサーション .should('exist')
がスキップされる仕様になっているからです。
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Default Assertions
アサーションを書く
デフォルトアサーションは便利ですが、それだけでは求めるテストが書けない場合もあります。そのような場合は shoud
, and
, expect
を利用します。(and
は should
のエイリアスです。)
公式ドキュメントでは should
と and
を Implicit (暗黙的な) Subjects、expect
を Explicit (明示的な) Subjects をという言葉を使って説明しています。
Implicit Subjects
should
や and
を利用してアサーションを記述します。Cypress で推奨される方法はこちらです。特別な理由がない場合アサーションを記述する場合は should
あるいは and
を利用します。
cy.get("tbody tr:first").should("have.class", "active");
should
の最初に渡す引数は chainers と呼ばれる .
でつなげた文字列です。chainers は Cypress の内部で expect(...).to.have.class('active')
のように変換されます。
記述可能な chainer は以下から確認できます。
- Assertions | Cypress Documentation => Chai
- Assertions | Cypress Documentation => Chai-jQuery
Explicit Subjects
expect
を利用してアサーションを記述します。この expect
は Chai
の expect
です。以下のサンプルコードのように should
, and
, then
のコールバック内でアサーションを記述します。同一サブジェクトに対して複数のアサーションを実行したい場合や、アサートする前に何か処理を追加したい場合に便利です。
cy.get("p").should(($p) => { // massage our subject from a DOM element // into an array of texts from all of the p's let texts = $p.map((el, i) => { return Cypress.$(el).text(); }); // jQuery map returns jQuery object // and .get() converts this to an array texts = texts.get(); // array should have length of 3 expect(texts).to.have.length(3); });
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Writing Assertions
閑話: should()
, and()
, then()
の違い
この 3 つのコマンドの違いを確認します。
should()
と and()
API ドキュメントに書いてありますが and()
は should()
のエイリアスです。
ソースコードではこのように定義されています。
Commands.addAll( { type: "assertion", prevSubject: true }, { should() { // eslint-disable-next-line prefer-rest-params return shouldFn.apply(this, arguments); }, and() { // eslint-disable-next-line prefer-rest-params return shouldFn.apply(this, arguments); }, } );
エイリアスが存在することで、以下のように自然な表現でアサーションを記述することができます。
cy.get("nav").should("be.visible").and("have.class", "open");
should()
と then()
should()
はアサーションを作成できるコマンドで、then()
は前のコマンドから Yield されたサブジェクトを操作するためのコマンドです。
2 つは別の目的をもったコマンドですが、コールバック関数を利用できその中でアサーションを実行できるという点では非常に似ており、混同しやすいように思います。
// should(callbackFn) cy.get(".connectors-list > li").should(($lis) => { expect($lis).to.have.length(3); expect($lis.eq(0)).to.contain("Walk the dog"); expect($lis.eq(1)).to.contain("Feed the cat"); expect($lis.eq(2)).to.contain("Write JavaScript"); }); // then(callbackFn) cy.get("button").then(($btn) => { const cls = $btn.attr("class"); cy.wrap($btn).click().should("not.have.class", cls); });
以下はコールバック関数をした場合の両者の違いです。
- | should() |
then() |
---|---|---|
Yield | コールバック関数で返された結果は無視される。Yield するサブジェクトは変更されない。 | コールバック関数で返された内容が新しいサブジェクトとして Yield される。 undefined を返した場合は should() と同じようにサブジェクトは変更されない。 |
リトライに関する注意 | コールバック関数内のアサーションが失敗した場合、コールバック関数を再実行する。コールバック関数では、複数回実行してほしくない副作用に注意する必要がある。 | コールバック関数内のアサーションが失敗しても、コールバック関数は再実行されない。 |
さらに詳しく
- should | Cypress Documentation
- then | Cypress Documentation
- 実装は packages/driver/src/cy/commands/connectors.js ここで確認できます
コマンドのリトライ
Cypress にはコマンドをリトライする仕組みがあります。
リトライは Cypress のコア機能の一つです。リトライは自動で行われ、そして「感覚的に」正しくテストが進むように働きます。そのため意識することが少ないかもしれません。ですがこの挙動を理解しておくと、意図せずテストが失敗した時などに、その原因に気付きやすくなるかもしれません。
リトライとは何か
簡単説明すると、アサーションが失敗した時に一つ前のコマンドを再試行してくれる機能です。例えば以下のテストで「要素が 2 つ」というアサーションが失敗した場合、一定期間 (初期値は 4 秒です) cy.get
コマンドが再試行されます。
cy.get(".todo-list li") // command .should("have.length", 2); // assertion
なぜリトライするのか
最近の Web アプリケーションでは非同期な処理が一般的なため、アサーション実行時にテスト対象が想定通り描画完了しているとは限りません。描画までタイムラグがあるケースや、描画に必要なデータをバックエンドから取得中の可能性もあります。
こういったケースに、複雑な記述を必要とせず対応するため、Cypress にはリトライ機能が備わっています。
リトライされるのは最後のコマンドだけ
contains
のセクションで「リトライの挙動も変わる」と書きました。それは Cypress の仕様が「Cypress コマンドはアサーションの前の最後のコマンドのみリトライする」ようになっているからです。これが問題になるケースは Only the last command is retried で確認できます。壊れにくいテストを書く方法の一つは以下のようにクエリをマージすることです。
// 🛑 not recommended cy.get(".new-todo").type("todo B{enter}"); cy.get(".todo-list li") // queries immediately, finds 1 <li> .find("label") // retried, retried, retried with 1 <li> .should("contain", "todo B"); // never succeeds with only 1st <li> // ✅ recommended in most cases cy.get(".new-todo").type("todo B{enter}"); cy.get(".todo-list li label") // 1 query command .should("contain", "todo B"); // assertion
さらに詳しく
- 全てのコマンドにリトライ機能が備わっているわけではありません。例えば click コマンドは actionable state になるまで待機するようにできていますが、リトライはしません。デフォルトアサーション同様、各 API ドキュメントの Rules セクションで仕様を確認できます。
- リトライのより詳しい情報は Retry-ability | Cypress Documentation で確認できます
- コマンドのリトライではなく、テストケース自体のリトライ機能もあります
その他
cypress-fiddle
Cypress の動作確認用の環境をすぐにつくれるモジュールです。
https://github.com/cypress-io/cypress-fiddle
const helloTest = { html: ` <div>Hello</div> `, test: ` cy.get('div').should('have.text', 'Hello') `, }; it("tests hello", () => { cy.runExample(helloTest); });
このように HTML とテストコードを同じファイルで記述できます。ちょっとした動作確認にとても便利です。モジュールの設定も簡単です。
ブラウザのデータリセット
Cypress は各テストが実行される前にブラウザのデータをリセットします。つまり、最初のテストでログインが成功して cookie に token がセットされたとしても 2 つ目のテストでそのクッキーを利用することはできないようになっています(デフォルトの挙動では)。
describe('profile', () => { it('foo', () => { // Login cy.request(...).then((response) => { // Set a token cy.setCookie(...); }); cy.visit('/profile/foo'); cy.contains(...); cy.contains(...); }); it('bar', () => { cy.visit('/profile/bar'); // => 🚨 401 Unauthorized Error cy.contains(...); cy.contains(...); }); });
それぞれのテストは独立しているべきという考えに基づき、Cypress は自動でデータを消去してくれます。それぞれのテストで同じ処理が常に必要な場合 beforeEach
を利用することも可能です。
describe('profile', () => { beforeEach(() => { // Login cy.request(...).then((response) => { // Set a token cy.setCookie(...); }); }); // ... });
さらに詳しく
- cookie と localStorage は消去されますが sessionStorage は消去されないようです。(ただし、これに関しては意図された挙動ではないように思えます。)
- How do I preserve cookies / localStorage in between my tests?
- Cypress のドキュメントの FAQ より
cy.clock
と Vue
Cypress には時間の関係するグローバル関数を上書きする clock というコマンドがあります。
例えば、ブラウザ内の時間を常に 2020 年 10 月 10 日 にしたい場合 cy.clock(Date.UTC(2020, 10, 10), ['Date']);
のように記述します。clock
は Date
関数だけでなく setTimeout
, setInterval
など他の時間に関係する関数も上書きするので、日付を上書きしたいだけなら ['Date']
を指定した方が安全のような気がします。
少なくとも Vue 2.x では更新処理にこれらの関数を利用しているようで、['Date']
を追加しないと描画更新が行われない場合がありました。(cy.tick
を利用して時間をすすめることもできますが面倒ですよね)(React も同じかもしれませんが試していません)
beforeEach(() => { // 🛑 not recommended, because setTimeout, setInterval, etc. are also overrided // cy.clock(Date.UTC(2020, 10, 10)); // ✅ recommended cy.clock(Date.UTC(2020, 10, 10), ["Date"]); // 日本時間午前9 });
さらに詳しく
- Cypress でモックをする方法
Cypress のお作法
最後に、Cypress のテストの方針、お作法、ベストプラクティスといわれているものを簡単に紹介します。詳細は各リンク先でご確認ください。
- Best Practices | Cypress Documentation
- テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば
cy.request
を利用して処理をショートカットをする。 - 要素の選択には
data-*
属性やテキストなど、壊れにくいものを優先して利用する const
,let
,var
に Cypress コマンドの戻り値を割り当てない。コマンドが Yield する値にアクセスあるいは値を保持したい場合はthen()
を利用する。- コントロールできない外部サイトを利用することを避ける。外部サービスの利用が必要な場合は、提供されている API を
cy.request()
を利用して呼びだす。 - テストは常に独立して実行可能で、前のテストに依存しないようにする
- ユニットテストのようにテストを小さく(One Assert Per Test)書かず、複数のアサーションを記述する
- 状態リセットには
after
,afterEach
ではなくbefore
,beforeEach
を使う - など
- テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば
- Testing Your App | Cypress Documentation
- ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (
cy.exec()
,cy.task()
,cy.request()
)、サーバーのスタブ、ログインフローのショートカット、などについて書かれています。
- ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (
その他に読んでおくとよさげなガイドページ
- Variables and Aliases | Cypress Documentation
- Interacting with Elements | Cypress Documentation
- クリックなどインタラクティブな動作を行う場合、対象となる要素は操作可能 (actionable) 状態でなければなりません。このドキュメントでは、どういったコマンドが対象になるか、actionable な状態とは具体的にどのように定義されているのか、などが説明されています。
Vue コンポーネントにおけるロジック再利用のサンプル。Mixins から Composition API まで。
^3.0.0-rc.1
で動作確認しています。Composition API パターン以外は 2 系でも基本的には動くと思います。
サンプル
マウスカーソルのポジションを表示するコンポーネントをお題にしています。よくあるやつです。
まずは普通に
<template>{{ x }} {{ y }}</template> <script> export default { data() { return { x: 0, y: 0, }; }, mounted() { window.addEventListener("mousemove", this.update); }, unmounted() { window.removeEventListener("mousemove", this.update); }, methods: { update(e) { this.x = e.pageX; this.y = e.pageY; }, }, }; </script>
Mixins
<template>{{ x }} {{ y }}</template> <script> import mixinMouse from './mixin-mouse'; export default { mixins: [mixinMouse] }; </script>
mixin-mouse.js
export default { data() { return { x: 0, y: 0, }; }, mounted() { window.addEventListener("mousemove", this.update); }, unmounted() { window.removeEventListener("mousemove", this.update); }, methods: { update(e) { this.x = e.pageX; this.y = e.pageY; }, }, };
Higher-Order Components (HOCs)
Position.vue
<template>{{ x }} {{ y }}</template> <script> export default { props: ["x", "y"], }; </script>
with-mouse.js
import { h } from "vue"; export function hoc(inner) { return { data() { return { x: 0, y: 0, }; }, mounted() { window.addEventListener("mousemove", this.update); }, unmounted() { window.removeEventListener("mousemove", this.update); }, methods: { update(e) { this.x = e.pageX; this.y = e.pageY; }, }, render() { return h(inner, { x: this.x, y: this.y, }); }, }; }
import Position from "./components/Position.vue"; import withMouse from "./components/with-mouse"; const MousePosition = withMouse(Position);
Scoped Slots
React でいうところの Render Props
Mouse.vue
<template> <slot :mx="x" :my="y"></slot> </template> <script> export default { data() { return { x: 0, y: 0, }; }, mounted() { window.addEventListener("mousemove", this.update); }, unmounted() { window.removeEventListener("mousemove", this.update); }, methods: { update(e) { this.x = e.pageX; this.y = e.pageY; }, }, }; </script>
<Mouse> <template v-slot="{mx, my}">{{ mx }} {{ my }}</template> </Mouse>
Composition API
<template>{{ x }} {{ y }}</template> <script> import useMouse from "./use-mouse"; export default { setup() { const { x, y } = useMouse(); return { x, y }; }, }; </script>
use-mouse.js
import { ref, onMounted, onUnmounted } from 'vue' export default function useMouse() { const x = ref(0); const y = ref(0); function update(e) { x.value = e.pageX; y.value = e.pageY; } onMounted(() => { window.addEventListener("mousemove", update); }); onUnmounted(() => { window.removeEventListener("mousemove", update); }); return { x, y }; }
AST に変換して HTML の構造を変形する
小ネタです。例えば以下のような HTML の <h3>
ではじまるコンテンツを <section>
で囲みたい時、正規表現だとちょっとやりづらかったりします。
<h2>H2</h2> <p>...</p> <h3>H3</h3> <p>...</p> <h3>H3</h3> <p>...</p>
<h2>H2</h2> <p>...</p> <section> <h3>H3</h3> <p>...</p> </section> <section> <h3>H3</h3> <p>...</p> </section>
そんな時は AST に変換する Parser を利用すると簡単に構造変形をすることができたりします。
AST explorer を使えば簡単に試してみることができます。
以下コードは posthtml-parser
用です。ヘッダーの Transform
メニューから posthtml
を選択すると実行できます。
export default function (tree) { return groupByHeader(tree, 3).map((idx) => { if (!Array.isArray(idx)) { return tree[idx]; } return { tag: "section", content: [idx.map((i) => tree[i])] }; }); } function groupByHeader(tree, level) { return tree.reduce((acc, node, i) => { // If the tag's header level is equal, if (node.tag === `h${level}`) { acc.push([i]); return acc; } // If the tag's header level is higher, if (/^h\d$/.test(node.tag) && Number(node.tag.slice(1)) < level) { acc.push(i); return acc; } const last = acc[acc.length - 1]; if (Array.isArray(last)) { last.push(i); return acc; } acc.push(i); return acc; }, []); }
シンプルな Web サイト用の webpack スターターキット
これは何
仕事で必要だったので webpack のスターターキットをつくってみました。
その時に調べたことをメモしておきます。
ちな、いつもは Nuxt などを使っているので、ゼロから用意するのはほぼ初めてでした。何が言いたかというと、そのくらいの人が書いている記事だよということです 😇
必要だったこと:
- 複数の HTML ページ
- TypeScript
- JavaScript polyfills
- 各種 Lint
- Autoprefixer (PostCSS)
- Unit Testing
- 環境変数
- SPA は不要
使い方
メモ
以下、調べたことのメモです。
以下のバージョンを前提としています。
webpack 4.43.0, TypeScript 3.9.5, Babel 7.10.2, ESLint 7.2.0, Prettier 2.0.5, Jest 26.0.1, core-js 3.6.5
webpack で 複数 HTML ページ
- webpack は HTML ファイルをエントリーに指定できません
- HtmlWebpackPlugin を使いましょう
chunk
の設定をする必要が (きっと) あるので、webpack 用語としての chunk を知っておくと良いかと思います
// https://github.com/jantimon/html-webpack-plugin#generating-multiple-html-files { entry: 'index.js', output: { path: __dirname + '/dist', filename: 'index_bundle.js' }, plugins: [ new HtmlWebpackPlugin(), // Generates default index.html new HtmlWebpackPlugin({ // Also generate a test.html filename: 'test.html', template: 'src/assets/test.html' }) ] }
コミット時に Lint チェックしたい
- 具体的には ESLint, stylelint, コミットメッセージのチェック
- フックに
husky
、コミットメッセージのチェックにはcommitlint
を利用しました
package.json
{ // ... "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", "pre-commit": "lint-staged" } }, "lint-staged": { "*.{js,ts}": [ "eslint --fix" ], "*.css": [ "stylelint --fix" ], "*.html": [ "prettier --write" ] } }
commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'], };
詳細はコミットログからどうぞ
chore: set up commitlint · ryotah/webpack-starter-basic@bd44ce3 · GitHub
Babel + TypeScript の設定
- Babel を利用することで core-js の対応もしたい
ts-loader
=>babel-loader
の順番で処理するようにしました- @babel/preset-env が便利ですね
webpack.config.js
module.exports = { // ... module: { rules: [ { test: /\.ts$/, use: ['babel-loader', 'ts-loader'], exclude: /node_modules/, }, ], }, };
babel.config.json
{ "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 3 } ] ] }
tsconfig.json
- 最終的に Babel がトランスパイルするので、
target
,module
,lib
はesnext
にしています baseUrl
,paths
に関しては パスの解決 で説明しますesModuleInterop
に関しては esModuleInterop オプションの必要性について - Qiita がわかりやすかったです。ありがとうございます。
{ "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "node", "lib": [ "esnext", "dom" ], "esModuleInterop": true, "strict": true, "baseUrl": ".", "paths": { "~/*": [ "src/*" ] } } }
browserslist
@babel/preset-env
がデフォルトでbrowserslist
を利用します- Autoprefixer も
browserslist
を利用するので対象ブラウザはここでしっかり設定しておきましょう
package.json
{ // ... "browserslist": [ "defaults" ] }
ESLint + prettier の設定
- 基本は ESLint の設定 (Nuxt TypeScript) - ryotah’s blog の「前提」と同じです
eslint-plugin-prettier
を利用して ESLint 経由で Prettier を動かします
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettie
.eslintrc
{ "root": true, "env": { "browser": true, "node": true }, "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "prettier/@typescript-eslint" ], "overrides": [ { "files": ["**/*.js"], "rules": { "@typescript-eslint/no-var-requires": 0 } } ] }
.eslintignore
dist
.prettierrc
{ "singleQuote": true }
stylelint の設定
- 特にこだわりはなかったのでルール設定には
stylelint-config-standard
を利用しました
.stylelintrc
{ "extends": "stylelint-config-standard" }
Jest の設定
preset: 'ts-jest'
って前からありましたっけ?簡単ですね。
jest.config.js
module.exports = { preset: 'ts-jest', };
CSS 読み込み
- サンプルでは
css-loader
=>style-loader
をよく見かけますが、style-loader
は実行時に "Inject CSS into the DOM" するとのこと - 少なくともビルド時には別の loader にしたいので、今回は MiniCssExtractPlugin を利用してみました
webpack.config.js
module.exports = { // ... module: { rules: [ { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], exclude: /node_modules/, }, ], }, plugins: [ // ... new MiniCssExtractPlugin({ filename: '[name].[contenthash].css', chunkFilename: '[id].[contenthash].css', }), ], };
Autoprefixer を適用させるために PostCSS も利用します。postcss-loader
が内部で postcss-load-config
を利用しているので今回は .postcssrc
を利用することにしました。
.postcssrc
{ "plugins": { "autoprefixer": true } }
ビルド時のファイル名をどうするか
- chunk 内容のハッシュ (
chunkhash
) にするのがキャッシュの点からよさそうです - https://webpack.js.org/configuration/output/#template-strings
webpack.config.js
module.exports = merge(common, { mode: 'production', devtool: 'source-map', output: { filename: '[name].[chunkhash].js', path: buildPath, }, });
パスの解決
resolve.alias
を利用することでエイリアスを利用できます
例えば以下のような設定をすることで import '~/assets/scripts/utils.ts';
のような作業ディレクトリをベースにした import が可能になります。
webpack.config.js
const path = require('path'); const basePath = path.resolve(__dirname, 'src'); module.exports = { //... resolve: { alias: { '~': basePath, }, } };
webpack のエイリアス設定をした場合、TypeScript にも同様の設定が必要になります。 (tsconfig
の baseUrl
と paths
)。
紛らわしいエイリアス名をつけてしまいましたが css ファイル内の @import '~normalize.css';
に使われている ~
とは別ものです。
https://webpack.js.org/loaders/css-loader/
To import styles from a
node_modules
path (includeresolve.modules
) and foralias
, prefix it with a~
:
環境変数
// Load environment variables const result = require('dotenv').config({ path: `.env.${process.env.APP_ENV}`, }); if (result.error) { throw result.error; } module.exports = { // ... plugins: [ new webpack.DefinePlugin({ 'process.env.GA_TRACKING_ID': JSON.stringify(process.env.GA_TRACKING_ID), }), ], };
その他
- node.js - path.join vs path.resolve with __dirname - Stack Overflow
path.resolve, on the other hand, will resolve to an absolute path.
- webpack の
mode
について- https://webpack.js.org/configuration/mode/#mode-production
Providing the mode configuration option tells webpack to use its built-in optimizations accordingly.
npm のコマンド `docs` `repo` `view`
パッケージのドキュメントやソースコードを簡単に確認するために npm docs
や npm repo
コマンドをよく使います。(最近知りました)
例えば @storybook/addon-docs
のドキュメントやリポジトリを確認したい場合、以下のコマンドを入力します。
そうすると npm に登録されている @storybook/addon-docs
の情報をもとに、docs
は homepage
を、reo
は repository
をブラウザで開いてくれます。
$ npm docs @storybook/addon-docs $ npm repo @storybook/addon-docs
便利ですね。ありがたいありがたい。
AtCoder を JavaScript で
AtCoder の問題を JavaScript (Node.js v5.12.0) で解くための環境を作ってみました。
Node のバージョン
nodenv
で Node のバージョンを v5.12.0 に指定しています。これで、自分の環境では正解しているのに提出したらライタイムエラーになる、といった悲しいことがなくなると思います。
テンプレート
テンプレートを用意しました。npm run new foo
とかでコピーできます。下のような感じでファイルが生成されます。
foo ├── README.md ├── index.js └── input.txt
DIR=foo npm run exec
で input.txt
の内容を読み込んで index.js
を実行します。
BigInt
Number で表現できる最大の数、253 - 1 よりも大きな数値を扱うサンプルを用意しました。GitHub - peterolson/BigInteger.js: An arbitrary length integer library for Javascript を利用しています。examples/bigint
で確認できます。
ESLint ルール追加したいお気持ち
既存の Nuxt プロジェクトに ESLint のルールを追加した時に調べたことを整理しました。
Lint の設定ファイルを秘伝のタレにはしたくないけれども、もう少しだけカスタマイズしたい、特にコードの複雑さを軽減しメンテナンス性を保ちたい、というモチベーションでルールの追加を検討した時の話です。
今のルール
npx create-nuxt-app <project-name>
でプロジェクトを作成して ESLint と Prettier を選択すると以下のような .eslintrc.js
が生成されます (Nuxt v2.11.0)。
自分たちのプロジェクトも概ねこのような感じです。
module.exports = { // ... extends: [ '@nuxtjs', 'prettier', 'prettier/vue', 'plugin:prettier/recommended', 'plugin:nuxt/recommended' ], // ... }
extends している @nuxtjs
、実体は @nuxtjs/eslint-config ですが、これがさらに eslint-config-standard
を extends しています。
eslint-config-standard
は eslint:recommended
や eslint-config-airbnb-base
みたいなあれです。
参考:
追加検討したルール
complexity ってのを見つけたのでこれでおkなのか?と心踊りましたがどうやらそんなに簡単ではないようです。
Why complexity is off? · Issue #1758 · airbnb/javascript · GitHub
For me, more better metric and more quality code you will get if you combine:
max-params
,max-statements
,max-statements-per-line
,max-nested-callbacks
andmax-depth
.Don't know how complexity is actually implemented, but above rules are more fine grained and leads you to great code.
ESLint の List of available rules
から探してみると、complexity
の代わりに以下の 8 つが利用できそうです。
max-depth
max-len
max-lines
max-lines-per-function
max-nested-callbacks
max-params
max-statements
max-statements-per-line
その中から max-len
と max-statements-per-line
は今回の対象から外しました。この辺りは Prettier に任せます。
ついでに max-lines
も外すことにしました。Vue を単一ファイルコンポーネントで作成すると テンプレートや CSS が含まれるためどうしても一般的な JS ファイルより行数が増えてしまうからです。
(.vue
ファイルを Lint の対象から外してもいいんですが、まあいいかなあという気持ち。)
残ったのが以下 5 ルールです。
次に、試しにこれらのルールを手元のコードに適用させてみました。
(max-params
だけ設定をデフォルト値から変更しました。3
から 4
に変更しています。Vuex の getter など、引数が 4 つある関数がライブラリベースで存在していたためです。)
1年近く運用している開発者がばらばらな 5 つのプロダクトで試してみましたが、レポートされたのは max-lines-per-function
と max-statements
がそこそこ、max-params
が 1 つだけ、max-depth
と max-nested-callbacks
は 0 でした。
どんなプロダクトでどれほどのコード量なのかわからなければあまり意味のない数字ですが、一応このような結果でした。
レビュー
ほぼ違反がなかった max-params
, max-depth
, max-nested-callbacks
は利用しないことにしました。(嘘です。max-depth
だけは利用することにしました。) この期間開発したコードで違反がでないなら、別に入れなくてもいいかなと。
未来の負債を産まないために適用させておくという考えもあるかもしれませんが、できるだけルールを (設定ファイルを) シンプルにしたい気持ちの方が強いので。
というわけで残りの max-lines-per-function
と max-statements
をみていきます。
max-lines-per-function
これは function 内の行数を制限するルールです。デフォルトでは 50 行。空行やコメントをカウントさせたくない場合は skipBlankLines
, skipComments
を有効にすることで対応できます。
長すぎる function は全体像を把握しにくく、変数の影響もわかりにくく、テストも書きづらいなどデメリットが多いのです。違反している既存コードもそのようなケースがいくつか見られたため、個人的には有効にしたいルールです。
一方、プロパティ数が多いオブジェクトを利用する場合など、どうしても行数が増えてしまう状況もあり (Prettier が改行してくれるし)、常に有効にしたほうがいいともいえないかもしれません。
(単純なオブジェクトなら function の外に定義すれば解決しますが、そうではない場合を想定。)
ルールは有効にしつつ、特殊な function の場合は eslint-disable-next-line
などで対応するのも手だと思います。
max-statements
先ほどの max-lines-per-function
に多少似たルールに思えますが、これは function 内のステートメントの数を制限するルールです。デフォルト値は 10。
ステートメントの数は、雑に説明すると ;
や if
の数です。
Statements and declarations - JavaScript | MDN
最初はこれも便利に思えたのですが、実際の違反しているコードを確認して考えが変わりました。
例えば、単純に冗長な手続きを書かざるを得ない場合や必要とする変数が多いケースなどは設定値 10 をすぐに超えてしまっていました。
それ以外にも、ステートメントを減らすために「変数を使い回す」といったようなアンチパターンを利用したり (そんな人はいないはずですが)、全体の可読性よりもステートメントを減らすことに意識が向くなど、状況によってはコードが悪化する可能性も感じました。
設定値を変える、そもそも「単純に冗長な手続きを書かざるを得ない場合や必要とする変数が多いケース」この考えが誤っている可能性もありますが、一旦見送りにしました。
max-depth
max-depth
はコードの入れ子レベルを制限するルールです。
「ほぼ違反がなかった max-params
, max-depth
, max-nested-callbacks
は利用しないことにしました。」と途中で書きましたが、これは嘘です。デフォルト値は 4
でしたが 3
に変更して、このルールを採用しました。
この設定の場合以下のコードの Nested 4 deep
が違反することになります。
/*eslint max-depth: ["error", 3]*/ function foo() { for (;;) { // Nested 1 deep while (true) { // Nested 2 deep if (true) { // Nested 3 deep if (true) { // Nested 4 deep } } } } }
設定を変更して再度 Lint を実行したところ、いくつかのコードが違反していました。違反箇所は総じて読みづらく、またリファクタリングも容易だったため、このルールも採用することにしました。
追加ルール no-else-return
max-depth
で入れ子を確認している時にいくつか見つけたのが、if
の中で return
しているがその後に if else
や else
を続けているコードです。
コードのネストが深いと読み手は『精神的スタック』に条件をプッシュしなければならない
リーダブルコード
といわれているように、不要なネストをつくりたくありません。no-else-return
は ESLint の --fix
オプションで自動修正が可能なので、このルールも適用させることにしました。
結論
最終的に採用したルールはあまり多くありませんでした。no-else-return
と max-depth
はオススメですが、max-lines-per-function
はどちらでもいいかなという考えです。
module.exports = { // ... rules: { // ... // https://eslint.org/docs/rules/no-else-return 'no-else-return': [ 2, { allowElseIf: false, }, ], // 利用したくない人は、このルールをオフってね // https://eslint.org/docs/rules/max-lines-per-function 'max-lines-per-function': [ 1, { skipBlankLines: true, skipComments: true, }, ], // https://eslint.org/docs/rules/max-depth 'max-depth': [1, 3], }, overrides: [ { files: ['*.test.{ts,js}'], rules: { 'max-lines-per-function': 0 } } ] }
max-xxx
ルールを自身のコードに適用して、有効そうなルールをピックアップするという作業はなかなか面白いと思います。