Cypress ノート

Cypress を少しさわった後に知っておくと捗りそうなもの。Cypress v5.3.0 で動作確認済みです。(v6 でも問題はなさそうでした。)

Cypress が何を利用しているか

f:id:ryotah:20201125175811p:plain

Cypress はテスト実行時に複数のツールを利用しています。何かにつまづいた時、それが「Cypress の話」なのか「Cypress が利用しているツールの話」なのか切り分けて考えられると適切な情報を探しやすいかもしれません。

  • Cypress Driver
    • Driver というライブラリがブラウザにロードされます。cy オブジェクトなどはこのパッケージで定義されています。
  • Mocha
    • テストランナーライブラリ
    • before, beforeEach などのフックについて知りたい場合は Mocha のドキュメント で確認できます
  • Chai
  • chai-jquery

さらに詳しく

要素のクエリ

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 される結果だけでなくリトライの挙動も変わるのですが、リトライに関しては別のセクションで取り上げます。

アサーション

f:id:ryotah:20201125175912p:plain

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') がスキップされる仕様になっているからです。

さらに詳しく

アサーションを書く

デフォルトアサーションは便利ですが、それだけでは求めるテストが書けない場合もあります。そのような場合は shoud, and, expect を利用します。(andshouldエイリアスです。)

公式ドキュメントでは shouldand を Implicit (暗黙的な) Subjects、expect を Explicit (明示的な) Subjects をという言葉を使って説明しています。

Implicit Subjects

shouldand を利用してアサーションを記述します。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 を利用してアサーションを記述します。この expectChaiexpect です。以下のサンプルコードのように 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);
});

さらに詳しく

閑話: 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() と同じようにサブジェクトは変更されない。
リトライに関する注意 コールバック関数内のアサーションが失敗した場合、コールバック関数を再実行する。コールバック関数では、複数回実行してほしくない副作用に注意する必要がある。 コールバック関数内のアサーションが失敗しても、コールバック関数は再実行されない。

さらに詳しく

コマンドのリトライ

f:id:ryotah:20201125180014p:plain

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(...);
    });
  });
  // ...
});

さらに詳しく

cy.clock と Vue

Cypress には時間の関係するグローバル関数を上書きする clock というコマンドがあります。

例えば、ブラウザ内の時間を常に 2020 年 10 月 10 日 にしたい場合 cy.clock(Date.UTC(2020, 10, 10), ['Date']); のように記述します。clockDate 関数だけでなく 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 のテストの方針、お作法、ベストプラクティスといわれているものを簡単に紹介します。詳細は各リンク先でご確認ください。

  • Best Practices | Cypress Documentation
    • テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば cy.request を利用して処理をショートカットをする。
    • 要素の選択には data-* 属性やテキストなど、壊れにくいものを優先して利用する
    • const, let, var に Cypress コマンドの戻り値を割り当てない。コマンドが Yield する値にアクセスあるいは値を保持したい場合は then() を利用する。
    • コントロールできない外部サイトを利用することを避ける。外部サービスの利用が必要な場合は、提供されている APIcy.request() を利用して呼びだす。
    • テストは常に独立して実行可能で、前のテストに依存しないようにする
    • ユニットテストのようにテストを小さく(One Assert Per Test)書かず、複数のアサーションを記述する
    • 状態リセットには after, afterEach ではなく before, beforeEach を使う
    • など
  • Testing Your App | Cypress Documentation
    • ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (cy.exec(), cy.task(), cy.request())、サーバーのスタブ、ログインフローのショートカット、などについて書かれています。

その他に読んでおくとよさげなガイドページ

  • Variables and Aliases | Cypress Documentation
    • cy.get('button') が返すオブジェクトは button 要素でも jQuery でラップされた button 要素でもありません。Cypress が Yield する 要素に直接アクセスしたい場合、Cypress のコマンドである then(Promise の then とは別物)を利用する必要があります。このドキュメントでは then の利用ケースや、.as() を利用したエイリアスという機能が説明されています。エイリアスは実行時のデータの共有に使えるだけでなく、DOM 要素, routes, requests に対して利用できます。
  • 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 のスターターキットをつくってみました。

GitHub - ryotah/webpack-starter-basic: A simple webpack starter kit. Babel, TypeScript, ESLint, PostCSS, Jest, Environment variables, Git hooks, etc.

その時に調べたことをメモしておきます。

ちな、いつもは Nuxt などを使っているので、ゼロから用意するのはほぼ初めてでした。何が言いたかというと、そのくらいの人が書いている記事だよということです 😇

必要だったこと:

  • 複数の HTML ページ
  • TypeScript
  • JavaScript polyfills
  • 各種 Lint
  • Autoprefixer (PostCSS)
  • Unit Testing
  • 環境変数
  • SPA は不要

使い方

リポジトリREADME を読んでください 🙇

メモ

以下、調べたことのメモです。

以下のバージョンを前提としています。
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

{
  "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 の設定

$ 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 の設定

.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
  }
}

ビルド時のファイル名をどうするか

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 にも同様の設定が必要になります。 (tsconfigbaseUrlpaths)。

紛らわしいエイリアス名をつけてしまいましたが css ファイル内の @import '~normalize.css'; に使われている ~ とは別ものです。

https://webpack.js.org/loaders/css-loader/

To import styles from a node_modules path (include resolve.modules) and for alias, 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),
    }),
  ],
};

その他

npm のコマンド `docs` `repo` `view`

パッケージのドキュメントやソースコードを簡単に確認するために npm docsnpm repo コマンドをよく使います。(最近知りました)

例えば @storybook/addon-docs のドキュメントやリポジトリを確認したい場合、以下のコマンドを入力します。
そうすると npm に登録されている @storybook/addon-docs の情報をもとに、docshomepage を、reorepository をブラウザで開いてくれます。

$ npm docs @storybook/addon-docs
$ npm repo @storybook/addon-docs

便利ですね。ありがたいありがたい。

AtCoder を JavaScript で

GitHub - ryotah/atcoder-js

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 execinput.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-standardeslint:recommendedeslint-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 and max-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-lenmax-statements-per-line は今回の対象から外しました。この辺りは Prettier に任せます。

ついでに max-lines も外すことにしました。Vue を単一ファイルコンポーネントで作成すると テンプレートや CSS が含まれるためどうしても一般的な JS ファイルより行数が増えてしまうからです。
(.vue ファイルを Lint の対象から外してもいいんですが、まあいいかなあという気持ち。)

残ったのが以下 5 ルールです。

次に、試しにこれらのルールを手元のコードに適用させてみました。 (max-params だけ設定をデフォルト値から変更しました。3 から 4 に変更しています。Vuex の getter など、引数が 4 つある関数がライブラリベースで存在していたためです。)

1年近く運用している開発者がばらばらな 5 つのプロダクトで試してみましたが、レポートされたのは max-lines-per-functionmax-statements がそこそこ、max-params が 1 つだけ、max-depthmax-nested-callbacks は 0 でした。

どんなプロダクトでどれほどのコード量なのかわからなければあまり意味のない数字ですが、一応このような結果でした。

レビュー

ほぼ違反がなかった max-params, max-depth, max-nested-callbacks は利用しないことにしました。(嘘です。max-depth だけは利用することにしました。) この期間開発したコードで違反がでないなら、別に入れなくてもいいかなと。
未来の負債を産まないために適用させておくという考えもあるかもしれませんが、できるだけルールを (設定ファイルを) シンプルにしたい気持ちの方が強いので。

というわけで残りの max-lines-per-functionmax-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 elseelse を続けているコードです。

コードのネストが深いと読み手は『精神的スタック』に条件をプッシュしなければならない

リーダブルコード

といわれているように、不要なネストをつくりたくありません。no-else-return は ESLint の --fix オプションで自動修正が可能なので、このルールも適用させることにしました。

結論

最終的に採用したルールはあまり多くありませんでした。no-else-returnmax-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 ルールを自身のコードに適用して、有効そうなルールをピックアップするという作業はなかなか面白いと思います。

その他