[React + TypeScript] カバレッジレポートの取得方法

**🚨react-scripts@<wbr>2.xを使ってTypeScript環境を構築する場合は、以下の手順は不要である。**

# 概要
JestにてTypeScriptで書かれたReactアプリケーションのカバレッジを計測すると、内部でJavaScriptにトランスパイルされたソースコードに対するレポートが出力される。これをTypeScriptで書かれたソースコードに対応させるための対策を記載する。

## 従来の対策
Jest（から呼び出されるIstanbul）が出力するJSON形式のレポートにはソースマップが含まれる。これを用いてTypeScriptのソースコードにマッピング（Remap）するため、これまではremap-istanbulというツールが利用されてきた。自分の環境では今も正常に動作するが、以下の理由から別の対策を検討した。

* 内部で使用しているIstanbulのバージョンがDeprecatedとなっている0.xである。
* 最新版のIstanbulがRemapをサポートしているため、remap-istanbulを用いる必要が無い。
* 古いバージョンのIstanbulを用いる必要がある場合のみremap-istanbulを使うよう、開発者がコメントしている。[^1]

[^1]: https://github.com/SitePen/remap-istanbul/issues/177#issuecomment-438677707

## 今回の対策
Istanbulの開発元が公開しているnycというツールを用いる。従来通りにJSON形式のレポートを取得したのち、nyc経由でIstanbulにてRemapを行う。

# 環境
- macOS 10.14.2
- Node v11.1.0
- React 16.7.0
- create-react-app 2.1.2

```bash
$ create-react-app correct-coverage --scripts-version=react-scripts-ts
$ cd correct-coverage
$ yarn add -D nyc
```

# Jest
## レポート形式の指定
Jest実行後のレポートとして`coverage-final.json`のみ利用するため、不要なレポートを出力しないようレポートの種類を指定する。

```json:package.json
  "jest": {
    "coverageReporters": [
      "json"
    ]
  }
```

# tsconfig
## コンパイルの設定
compilerOptionsのtargetを、TypeScriptソースコードで用いている記法に合わせる。今回は`src/App.tsx`がes2015で書かれているため、同様にtargetをes2015とした。

```json:tsconfig.test.json
  "compilerOptions": {
    "target": "es2015"
  }
```

## コンソール上への出力を抑制
レポート形式に`text` `text-summary`のいずれも指定されない場合、Jestは自動的に`text-summary`を追加してレポートを出力する。これを抑制するため、当該処理のコードを下記のようにコメントアウトする。

```javascript:node_modules/jest-cli/build/reporters/CoverageReporter.js
        // if (
        // !_this._globalConfig.useStderr &&
        // coverageReporters.length &&
        // coverageReporters.indexOf('text') === -1)
        // {
        //   coverageReporters = coverageReporters.concat(['text-summary']);
        // }
```

# nyc
## 設定ファイルの作成
プロジェクトのルートディレクトリに`.nycrc`を作成し、以下の設定を記述する。

```json:.nycrc
{
    "reporter": [
        "text-summary",
        "json",
        "html"
    ],
    "temp-dir": "./coverage"
}
```

# package.json
## scriptsの追加
最終的なカバレッジは、Jestとnycを用いて以下の手順で取得する。

1. **Jest**: テストを実行 → カバレッジを計測 → レポートを出力
2. **nyc**: レポートをRemap

それぞれコマンドを1回ずつ実行することになるが、簡略化のためpackage.jsonのscriptsにコマンドをまとめる。古いレポートが残らないよう、coverageディレクトリの削除も組み込んだ。

```json:package.json
  "scripts": {
    "coverage": "rm -rf coverage && yarn test --coverage && nyc report"
  }
```

# 動作確認
## カバレッジ計測
コンソール上、HTML形式ともにTypeScriptソースコードに対応したレポートが出力される。JSON形式のレポートは長いためここに記載しないが、同様に正しく出力されていた。

```bash
$ yarn coverage
yarn run v1.10.1
$ rm -rf coverage && yarn test --coverage && nyc report
$ node scripts/test.js --env=jsdom --coverage
 PASS  src/App.test.tsx
  ✓ renders without crashing (22ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.618s
Ran all test suites.

=============================== Coverage summary ===============================
Statements   : 100% ( 5/5 )
Branches     : 100% ( 0/0 )
Functions    : 100% ( 1/1 )
Lines        : 100% ( 5/5 )
================================================================================
✨  Done in 3.73s.
```

<img width="1016" alt="Screen Shot 2018-12-06 at 8.13.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/286440/f6c4e414-2a5b-007c-865f-7e4c4d7588c9.png">

# 付録（NGパターン）
## Remapなしのカバレッジ
[#環境](#環境)を実施後にカバレッジを計測すると、以下のようなレポートが出力される。

```bash
$ yarn test --coverage
yarn run v1.10.1
$ node scripts/test.js --env=jsdom --coverage
 PASS  src/App.test.tsx
  ✓ renders without crashing (30ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.796s
Ran all test suites.
----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 App.tsx  |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|
✨  Done in 3.40s.
```

<img width="1016" alt="Screen Shot 2018-12-06 at 8.33.01.png" src="https://qiita-image-store.s3.amazonaws.com/0/286440/dec53a29-e37f-35a0-5a00-97b4b7cc2fad.png">

`Statements` `Branches` `Functions` `Lines`の値がいずれも誤っており、`1x`と書かれた場所も滅茶苦茶である。

## コンパイル設定なしのカバレッジ
**※ [#レポート形式の指定](#レポート形式の指定)を実施せずに検証する。**

[#コンパイルの設定](#コンパイルの設定)を実施せずにカバレッジを計測すると、以下のようなレポートが出力される。

```bash
 yarn test --coverage
yarn run v1.10.1
$ node scripts/test.js --env=jsdom --coverage
 PASS  src/App.test.tsx
  ✓ renders without crashing (23ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.491s, estimated 2s
Ran all test suites.
----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 App.tsx  |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|
✨  Done in 2.98s.
```

<img width="1016" alt="Screen Shot 2018-12-06 at 8.52.44.png" src="https://qiita-image-store.s3.amazonaws.com/0/286440/9398d8c7-ae63-b6ee-5c95-ebf2b0b107e6.png">

`Statements` `Functions` `Lines`の値がいずれも誤っており、`1x`と書かれた場所も部分的におかしい。

### 考察
`tsconfig.test.json`は`tsconfig.json`をオーバーライドしているため、`tsconfig.test.json`で`compilerOptions.target`を指定しなければ`tsconfig.json`で指定している`"es5"`が用いられる。

es5にトランスパイルされたソースコードを確認する。以下の行に`filePath.indexOf("src/App.tsx") !== -1`を条件としたブレークポイントをセットし、デバッグする。

```javascript:node_modules/ts-jest/dist/preprocessor.js
    var tsTranspiled = tsc.transpileModule(src, {
        compilerOptions: compilerOptions,
        fileName: filePath,
    });
▶︎   var tsJestConfig = utils_1.getTSJestConfig(jestConfig.globals);
```

`tsTranspiled.outputText`には以下のようなトランスパイル済みのJavaScriptコードが格納される。

```javascript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var React = require("react");
require("./App.css");
var logo_svg_1 = require("./logo.svg");
var App = /** @class */ (function (_super) {
    tslib_1.__extends(App, _super);
    function App() {
        return _super !== null && _super.apply(this, arguments) || this;
    }
    App.prototype.render = function () {
        return (React.createElement("div", { className: "App" },
            React.createElement("header", { className: "App-header" },
                React.createElement("img", { src: logo_svg_1.default, className: "App-logo", alt: "logo" }),
                React.createElement("h1", { className: "App-title" }, "Welcome to React")),
            React.createElement("p", { className: "App-intro" },
                "To get started, edit ",
                React.createElement("code", null, "src/App.tsx"),
                " and save to reload.")));
    };
    return App;
}(React.Component));
exports.default = App;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiQXBwLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiQXBwLnRzeCJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSw2QkFBK0I7QUFDL0IscUJBQW1CO0FBRW5CLHVDQUE4QjtBQUU5QjtJQUFrQiwrQkFBZTtJQUFqQzs7SUFjQSxDQUFDO0lBYlEsb0JBQU0sR0FBYjtRQUNFLE9BQU8sQ0FDTCw2QkFBSyxTQUFTLEVBQUMsS0FBSztZQUNsQixnQ0FBUSxTQUFTLEVBQUMsWUFBWTtnQkFDNUIsNkJBQUssR0FBRyxFQUFFLGtCQUFJLEVBQUUsU0FBUyxFQUFDLFVBQVUsRUFBQyxHQUFHLEVBQUMsTUFBTSxHQUFHO2dCQUNsRCw0QkFBSSxTQUFTLEVBQUMsV0FBVyx1QkFBc0IsQ0FDeEM7WUFDVCwyQkFBRyxTQUFTLEVBQUMsV0FBVzs7Z0JBQ0QsZ0RBQXdCO3VDQUMzQyxDQUNBLENBQ1AsQ0FBQztJQUNKLENBQUM7SUFDSCxVQUFDO0FBQUQsQ0FBQyxBQWRELENBQWtCLEtBQUssQ0FBQyxTQUFTLEdBY2hDO0FBRUQsa0JBQWUsR0FBRyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnO1xuaW1wb3J0ICcuL0FwcC5jc3MnO1xuXG5pbXBvcnQgbG9nbyBmcm9tICcuL2xvZ28uc3ZnJztcblxuY2xhc3MgQXBwIGV4dGVuZHMgUmVhY3QuQ29tcG9uZW50IHtcbiAgcHVibGljIHJlbmRlcigpIHtcbiAgICByZXR1cm4gKFxuICAgICAgPGRpdiBjbGFzc05hbWU9XCJBcHBcIj5cbiAgICAgICAgPGhlYWRlciBjbGFzc05hbWU9XCJBcHAtaGVhZGVyXCI+XG4gICAgICAgICAgPGltZyBzcmM9e2xvZ299IGNsYXNzTmFtZT1cIkFwcC1sb2dvXCIgYWx0PVwibG9nb1wiIC8+XG4gICAgICAgICAgPGgxIGNsYXNzTmFtZT1cIkFwcC10aXRsZVwiPldlbGNvbWUgdG8gUmVhY3Q8L2gxPlxuICAgICAgICA8L2hlYWRlcj5cbiAgICAgICAgPHAgY2xhc3NOYW1lPVwiQXBwLWludHJvXCI+XG4gICAgICAgICAgVG8gZ2V0IHN0YXJ0ZWQsIGVkaXQgPGNvZGU+c3JjL0FwcC50c3g8L2NvZGU+IGFuZCBzYXZlIHRvIHJlbG9hZC5cbiAgICAgICAgPC9wPlxuICAgICAgPC9kaXY+XG4gICAgKTtcbiAgfVxufVxuXG5leHBvcnQgZGVmYXVsdCBBcHA7XG4iXX0=
```

比較のため、Remapの基になる`coverage/coverage-final.json`をJestで出力する。

```bash
$ yarn test --coverage
yarn run v1.10.1
$ node scripts/test.js --env=jsdom --coverage
 PASS  src/App.test.tsx
  ✓ renders without crashing (24ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.492s
Ran all test suites.
✨  Done in 2.71s.
```

```json:coverage/coverage-final.json（一部抜粋）
        "statementMap": {
            "0": {
                "start": {
                    "line": 2,
                    "column": 0
                },
                "end": {
                    "line": 2,
                    "column": 62
                }
            },
```

上記の抜粋部分は、0番目のステートメントの範囲が**2行目**の**0文字目**から**2行目**の**62文字目**までであることを表している。[^2]

[^2]: lineは1-based、columnは0-basedである。

#### 比較: HTML版レポート / JSON版レポート / TypeScriptソースコード / トランスパイル済みJavaScriptソースコード
HTML版レポートに記載された`Statements` `Branches` `Functions` `Lines`をそれぞれ他のレポート、およびソースコードと比較して妥当性を検証する。

##### `Statements`
JSON版レポートの`statementMap`に対応する。今回の例では全ステートメントの開始と終了が同じ行のため、`jq`にて開始位置の行番号を抽出すると以下のような結果になる。

```bash
$ cat coverage/coverage-final.json | jq '.["/Users/thara/Desktop/correct-coverage/src/App.tsx"].statementMap[]' | jq '.start.line'
2
3
4
5
6
7
8
10
12
13
22
24
```

これはトランスパイル済みソースコードにおけるステートメントの位置と合致する。

![Screen Shot 2018-12-06 at 11.03.07.png](https://qiita-image-store.s3.amazonaws.com/0/286440/57c47a84-2e38-14b9-ed60-c8bd595a4a10.png)

HTML版レポートにてステートメントと判断された位置（`1x`と記載されている行）も同様に、JSON版レポートの`statementMap`に合致する。

<img width="1016" alt="Screen Shot 2018-12-06 at 9.46.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/286440/c8786228-ab16-48c5-a569-06bf2f0eea24.png">

##### `Branches`
JSON版レポートの`branchMap`に対応し、トランスパイル済みソースコードにおける10行目の3箇所に合致する。

![Screen Shot 2018-12-06 at 11.10.46.png](https://qiita-image-store.s3.amazonaws.com/0/286440/11e405b8-d425-10f4-9268-aa2468bab490.png)

##### `Functions`
JSON版レポートの`fnMap`に対応し、トランスパイル済みソースコードにおける`function`キーワードの3箇所に合致する。

![Screen Shot 2018-12-06 at 11.18.22.png](https://qiita-image-store.s3.amazonaws.com/0/286440/5e176554-8099-9289-e25e-558e391ab811.png)

##### `Lines`
JSON版レポートに対応する情報はない。1行ごとに1ステートメントになるようトランスパイルされるため、`Statements`と同一である。[^3]

[^3]: 基本的にライン数（行数）とステートメント数は同一だが、同じ行にステートメントが複数存在する場合に差異が生まれる（ステートメントは個数分カウントされるのに対して、ラインは1しかカウントされない）。