かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Jest import 文が SyntaxError: Unexpected identifier になるにハマる

create-react-app を使わずに自分で独自に作成していたプロジェクトにテストとして Jest を導入したけど、テストコマンドを走らせると SyntaxError: Unexpected identifier が出てハマってしまったのでメモ。

構成
|- /tests
|     |- setup.enzyme.js
|     |- App.test.js
|- /src
|- jest.config.js

jest.config.js

module.exports = {
  moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'],
  roots: ['<rootDir>/tests/'],
  setupFilesAfterEnv: ['<rootDir>/tests/setup.enzyme.js'],
  transformIgnorePatterns: [
    "/node_modules/.+.(js|jsx|ts|tsx)$",
  ],
}

この状態でテストを実行すると次のようなエラーが表示されました。

$ yarn jest 
    /Users/.../MyAPP/tests/setup.enzyme.js:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import Enzyme from 'enzyme';
                                                                                                    ^^^^^^
    SyntaxError: Unexpected identifier

import Enzyme from 'enzyme; でエラーが出ているもよう。なんもわからん…

Jest はデフォルトでは import が使えない?

JavaScript のモジュール周りは CommonJS (CJS) と ESModule (ESM) があり、node.js 上ではまだ ESM の import が上手く使えなず、それが原因で Jest でもデフォルトの設定では import 文が上手く扱えないっぽい。 (雰囲気理解なので間違えていたらスミマセン。)

babel を使って ESM の import を CJS に変換できれば良さそう。
検索すると "transform-es2015-modules-commonjs@babel/plugin-transform-modules-commonjs プラグインを使う方法も出てきたのですが、一旦は Jest のドキュメントにある babel の導入を参考にすることにしました。

babel パッケージのインストールと設定ファイルの作成

パッケージをインストールします。 babel-jest を使って変換するのですが、babel-jest は Jest インストール時に付いてきていて babel の設定があれば自動的に babel-jest で変換してくれるようなので、corepreset-env だけインストールすれば良さそう。

$ yarn add -D @babel/core @babel/preset-env

次に babel の 設定ファイルを作成します。

$ touch babel.config.js

babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current'
        },
      }
    ],
  ],
};

これでテストを走らせます

$ yarn test
PASS  tests/App.test.js
...
✨  Done in 4.46s.

問題なく test ができるようになりました! ₍ᐢ •̥ ̫ •̥ ᐢ₎‪ やったねグーン✨

JSX がエラーになる

テストの中身を書いていくと、今度は JSX でエラーが表示されてしまいました。

$ yarn test
    Jest encountered an unexpected token
    ...
    SyntaxError: /Users/.../MyApp/tests/App.test.js: Unexpected token (12:14)
    ...
    > 12 |       return (<div>{value}</div>);

React のテストを行うには @babel/preset-react が必要

Create React Appを使わないセットアップ
既存のアプリケーションがある場合は、いくつかのパッケージをインストールしてうまく機能するようにする必要があります。 babel-jest パッケージと react の babel preset をテスト環境内のコードを変換するのに利用しています。
cf Create React Appを使わないセットアップ Testing React Apps | JEST

JSX だけなら @babel/preset-react を snapshot テストも行うなら react-test-renderer をインストールします。

$ yarn add -D @babel/preset-react

babel.config.js に設定を追加します。

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      '@babel/preset-react', // <- 追加
      {
        targets: {
          node: 'current'
        },
      }
    ],
  ],
};

これで JSX のあるテストも通るようになりました!

$ yarn test
PASS  tests/App.test.js
...
✨  Done in 4.46s.

✌️₍ ᐢ. ̫ .ᐢ ₎✌️ イェイイェイイェイ!!!

所感

Jest の導入で今までハマったことがなかったので、あれ?こんなに大変だったかな???ってなりました。
英語のドキュメントがサクッと読める英語力を付けることが問題解決のスピードアップに繋がると痛感しました。学んでいこう!
そして、CJS と ESM まわり理解が足りてないので、実験してちゃんと理解しておこうと、やることタスクをそっと心に積んだのでした。。。


[参考]

推し武道マジいいからみんな見て…

Jest + Enzyme config で adapter を自動的に読み込ませたい

React のテストの定番 Jest + Enzyme の構成で、React 16.x 系だと Enzyme の Adapter を読み込ませる必要がり、今までは下記の用な感じで Adapter を読み込ませるファイルを作りテストファイルで都度 import をしていました。

setup.enzyme.js

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

xxxx.test.js

import React from 'react';
import './enzyme.setup.js';
import { shallow, mount } from 'enzyme';
...

毎回設定ファイルを import するのが面倒!

Jest の setupFilesAfterEnv 設定を使うと都度 import しなくても済む!

setupFilesAfterEnv
default: []

A list of paths to modules that run some code to configure or set up the testing framework before each test. Since setupFiles executes before the test framework is installed in the environment, this script file presents you the opportunity of running some code immediately after the test framework has been installed in the environment.

If you want a path to be relative to the root directory of your project, please include <rootDir> inside a path's string, like "<rootDir>/a-configs-folder".
cf. Configuring Jest · Jest

setupFilesAfterEnv: [] に渡されたファイルがテスト実行前に読み込まれるみたいです!
ここに Enzyme の Adapter の読み込みをしているファイルのパスを渡せば、テストファイルで都度 import を書く必要がなくなります!!

jest.config.js の設定に追加します

module.exports = {// A list of paths to modules that run some code to configure or set up the testing framework before each test
  setupFilesAfterEnv: [
    '<rootDir>/enzyme.setup.js',
  ],
  …
}

これで、テストファイルは自動的に enzyme.setup.js を読み込むようになりました〜!
オプションサクット読める英語力…切実に必要性を感じてきています…


[参考]

空調のせいか手荒れが酷かったけど、ハンドクリーム導入したらQOL上がりました。(安上がり)

TypeScript オブジェクトのデフォルト値を Array.reduce で設定しようとしたら型指定でハマった

APIからあるオブジェクトが渡され、フロントでデータが無ければデフォルト値を付けるような処理を TypeScript で書いていてハマったのでメモ。
※ フロントは React です

API から渡されるデータ

InitUnitData = {
  first: {
    id: 1, name: '春風 わかば', type: 'cute',
  },
  second: {
    id: 2, name: '姫石 らき'
  }
}

type が無ければデフォルト値を付けたい

Array.reduce でオブジェクトをフォーマットする

JavaScript で書くとこんな感じ。

const DefaultType = 'cute';

function Unit({ InitUnitData }) {
  return <UnitContainer {...formatUnitData(InitUnitData)} />
}

function formatUnitData(unitData) {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = unitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {});
}

Array.reduce の初期値を空オブジェクト {} にして、それにデフォルト値を設定したデータを追加していけばOKって感じ。至ってフツーの書き方かな〜と思っています。

この処理を TypeScript で書こうとしてハマっていました。

TypeScript で Array.reduce でオブジェクトのデフォルト値を設定する

APIから渡されるデータの型と、コンポーネントで使用するデータの型

APIから渡されるデータは type があったりなかったりするので type?: string ですが、コンポーネントで使用する型は type が設定されている前提なので type: string になります。

フォーマットされたデータを扱うコンポーネント

// UnitContainer.tsx
export type IdolDataType = {
  id: number;
  name: string;
  type: string;
};

export type UnitDataType = {
  first: IdolDataType;
  second: IdolDataType;
};

type UnitContainerProps = {
  unitData: UnitDataType
};

export default function UnitContainer({ unitData }: UnitContainerProps) {...}

APIからデータを受け取って整形してコンポーネントに流し込む箇所

// Unit.tsx
import UnitContainer, {
  IdolDataType,
  UnitDataType,
} from './UnitContainer';

type InitIdolDataType = Omit<IdolDataType, 'type'> & {
  type?: string;
};

type InitUnitDataType = {
  first: InitIdolDataType;
  second: InitIdolDataType;
}

const DefaultType = 'cute';

type UnitProps = {
  initUnitData: InitUnitDataType;
};

export default function Unit({ initUnitData }: UnitProps) {
  return <UnitContainer ...formatUnitData(initUnitData) />;
}

つまりデータを整形する formatUnitData 関数は、 InitUnitDataType型を受取り UnitDataType型を返す必要があります。

Array.reduce でオブジェクトを整形する部分の型指定

JavaScript 版ではこうでした

function formatUnitData( initUnitData ) {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = initUnitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {});
}

関数の引数と返る値に型を付けると function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType こうなります。

1. return されるオブジェクトの型が不明

Array.reduce は第二引数 (acc の初期値) が戻り値の型推論されるので、 初期値に空オブジェクト {} を渡すと {} が返されると推論されてしまうので、関数の返すべき型と合ってないとなる。as で初期値の型を指定する

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = initUnitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {} as UnitDataType);
}
2. Array.reduce 内の関数に渡される keyany と判断されるので initUnitData[key] が何か推論できない

initUnitData[key] の部分で Element implicitly has as 'any' type because ... が出されます。
これは TypeScript はオブジェクトのプロパティに変数を使ったブランケット記法 (Object[key]) でアクセスすると、コンパイラは何型が帰ってくるか推定できずエラーとなってしまうようです。

keyof を使って Object.keys(unitData)InitUnitDataType のキーの配列で、key には InitUnitDataType のキーのいずれかも文字列しか入らない事を明示する

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as UnitDataType
  );
}

これで key'first' | 'second' になるので unitData[key] が何型を持つかわからないというエラーは解消されます。

3. Array.reduce 内の acc は初回ループ時に実態と型が合っていない状態になっている。

2.の段階で基本的にコンパイラのエラーは消えているのですが、Array.reduce で渡される acc 空オブジェクトで、acc.first, acc.second は型上は IdolDataType と推論されるけど、実際のデータでは undefind と乖離しているので Partial<UnitDataType> として undefind の含むようにした方が良いと教えてもらいました。

Partial
型 T のすべてのプロパティを省略可能(つまり | undefined )にした新しい型を返す Mapped Type です。

interface Foo {
  bar: number
  baz: boolean
}
type PartialFoo = Partial
// PartialFoo {
//   bar?: number
//   baz?: boolean
// }
cf. TypeScript特有の組み込み型関数 #Partial

Array.reduce の初期値を {} as Partial<UnitDataType> とすれば acc{first?: IdolDataType, second?: IdolDataType} という型として扱われるようになるっぽい。

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as Partial<UnitDataType>
  );
}

こうすると return されるオブジェクトが {} の可能性を含んでしまってコンパイラにエラーを言われるので return されるオブジェクトは UnitDataType 型だと明示します

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as Partial<UnitDataType>
  ) as UnitDataType;
}

これで、TSlint を通るようになりました。

所感

個人的にはただオブジェクトにプロパティが無ければ初期値を付けたかっただけなのに、コンパイラが理解できるように厳密に型を保証しようとすると冗長になってしまうな〜という印象でした。

プロパティを | undefind に変換できる Partial や部分的に除く Omit など TypeScript 特有の関数知らないものが多く、JavaScript で考えて TypeScript に変換しようとしてしまうと今回のようにハマってしまうから、最初からコンパイラの頭で TypeScript で考えれるようにならないと、ロジックを考える時間より、TSlint が通るようにする作業のほうが多く時間がかかってしまって健全じゃないから、ちゃんと TypeScript 学習しておかないと今後キツイな〜と実感しました。

ガンバロ…


[参考]

chaika.hatenablog.com

リベリオン-反逆者- [Blu-ray]

リベリオン-反逆者- [Blu-ray]

  • 発売日: 2009/06/03
  • メディア: Blu-ray
ガンカタ…