🐶

【Jest】custom-reporterを使ってテストケースを出力する

2023/04/04に公開

こんにちは。株式会社スペースマーケットのwado63です。

みなさんは開発した内容を仕様として残すとき、どのようにしていますか?

ドキュメントとして残すべきですが、一度ドキュメントとして残すとメンテナンスの必要性が発生します。
コードを変更した後、その内容をドキュメントに反映する必要があるため二度手間になります。

アプリケーションの全体的なアーキテクト構成などであればドキュメントは必要になりますが、特定の機能の詳細な仕様まで毎回メンテナンスするのは大変ですよね。

そこで、テストコードをドキュメントとして残すことを考えました。
テストコードをそのまま読めばいいのでは?という方もいらっしゃると思いますが、今回はそれを補助するスクリプトを作ったというお話です。

custom-reporterとは?

custom-reporter(以後カスタムレポーターと呼びます)を使うことでテスト実行の際にテストの内容を受け取り、好みの形式に変換して出力できます。通常のテスト実行では、defaultのレポーターが使用されコンソールにテスト結果が表示されます。

カスタムレポーターは@jest/reportersに含まれるReporterのinterfaceを実装することで作成できます。

import {
  Reporter,
  Test,
  TestResult,
  AggregatedResult,
} from '@jest/reporters'

// 必要なinterfaceをすべて実装しなくても動作するのでPartialを使用しています
type CustomReporter = Partial<Reporter>

export default class MyCustomTestReporter implements CustomReporter {
  // onTestResultは各テストの結果を受け取るメソッドです
  onTestResult(
    test: Test,
    testResult: TestResult,
    aggregatedResult: AggregatedResult,
  ) {
    // まずはconsole.logで出力してみます
    console.log({
      test,
      testResult,
      aggregatedResult,
    });

    console.log(JSON.stringify(testResult.testResults, null, 2));
  }
}

これだけでテスト名などをlogとして出力するカスタムレポーターを作成できました。

テスト対象のファイルとテストコードを用意しましょう。

コンポーネントの実装とテストコード next.jsのwith-jestのコードを参考に用意しました。

コンポーネントの実装
import Head from 'next/head'
import Image from 'next/image'

import styles from '@/pages/index.module.css'

export default function Home() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1 className={styles.title}>
          Welcome to <a href="https://nextjs.org">Next.js!</a>
        </h1>

        <p className={styles.description}>
          Get started by editing <code>pages/index.js</code>
        </p>

        <div className={styles.grid}>
          <a href="https://nextjs.org/docs" className={styles.card}>
            <h3>Documentation &rarr;</h3>
            <p>Find in-depth information about Next.js features and API.</p>
          </a>

          <a href="https://nextjs.org/learn" className={styles.card}>
            <h3>Learn &rarr;</h3>
            <p>Learn about Next.js in an interactive course with quizzes!</p>
          </a>

          <a
            href="https://github.com/vercel/next.js/tree/canary/examples"
            className={styles.card}
          >
            <h3>Examples &rarr;</h3>
            <p>Discover and deploy boilerplate example Next.js projects.</p>
          </a>

          <a href="https://vercel.com/new" className={styles.card}>
            <h3>Deploy &rarr;</h3>
            <p>
              Instantly deploy your Next.js site to a public URL with Vercel.
            </p>
          </a>
        </div>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <span className={styles.logo}>
            <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
          </span>
        </a>
      </footer>
    </div>
  )
}
テストコード
import { render, screen } from "@testing-library/react";
import Home from "@/pages/index";

describe("Home", () => {
  it("renders a heading", () => {
    render(<Home />);

    const heading = screen.getByRole("heading", {
      name: /welcome to next\.js!/i,
    });

    expect(heading).toBeInTheDocument();
  });

  it("renders a documentation link", () => {
    render(<Home />);

    const link = screen.getByRole("link", {
      name: /documentation/i,
    });

    expect(link).toBeInTheDocument();
  });
});

カスタムレポーターをJestに組み込む説明は後述するので先に、このレポーターを組み込んだとして、どのような出力になるか見てみましょう。

console.logの内容は以下のようになります。長いので閉じてます。

console.logの内容
{
  test: {
    context: {
      config: [Object],
      hasteFS: [HasteFS],
      moduleMap: [ModuleMap],
      resolver: [Resolver]
    },
    duration: 390,
    path: '/Users/user_name/sample/with-jest-app/__tests__/index.test.tsx'
  },
  testResult: {
    leaks: false,
    numFailingTests: 0,
    numPassingTests: 1,
    numPendingTests: 0,
    numTodoTests: 0,
    openHandles: [],
    perfStats: {
      end: 1680232051668,
      runtime: 406,
      slow: false,
      start: 1680232051262
    },
    skipped: false,
    snapshot: {
      added: 0,
      fileDeleted: false,
      matched: 0,
      unchecked: 0,
      uncheckedKeys: [],
      unmatched: 0,
      updated: 0
    },
    testFilePath: '/Users/user_name/sample/with-jest-app/__tests__/index.test.tsx',
    testResults: [ [Object] ],
    console: undefined,
    displayName: undefined,
    failureMessage: null,
    testExecError: undefined
  },
  aggregatedResult: {
    numFailedTestSuites: 0,
    numFailedTests: 0,
    numPassedTestSuites: 1,
    numPassedTests: 1,
    numPendingTestSuites: 0,
    numPendingTests: 0,
    numRuntimeErrorTestSuites: 0,
    numTodoTests: 0,
    numTotalTestSuites: 1,
    numTotalTests: 1,
    openHandles: [],
    snapshot: {
      added: 0,
      didUpdate: false,
      failure: false,
      filesAdded: 0,
      filesRemoved: 0,
      filesRemovedList: [],
      filesUnmatched: 0,
      filesUpdated: 0,
      matched: 0,
      total: 0,
      unchecked: 0,
      uncheckedKeysByFile: [],
      unmatched: 0,
      updated: 0
    },
    startTime: 1680232050970,
    success: false,
    testResults: [ [Object] ],
    wasInterrupted: false
  }
}

今回使用するのはtestResult.testResultsです。
この中にテストの結果が配列で格納されています。

ancestorTitlesはテストのdescribeが配列で格納されており、titleにはitの部分が入ります。
他にもテストに関連する内容が格納されています。

[
  {
    "ancestorTitles": [
      "Home"
    ],
    "duration": 46,
    "failureDetails": [],
    "failureMessages": [],
    "fullName": "Home renders a heading",
    "invocations": 1,
    "location": null,
    "numPassingAsserts": 1,
    "retryReasons": [],
    "status": "passed",
    "title": "renders a heading"
  },
  {
    "ancestorTitles": [
      "Home"
    ],
    "duration": 24,
    "failureDetails": [],
    "failureMessages": [],
    "fullName": "Home renders the docs link",
    "invocations": 1,
    "location": null,
    "numPassingAsserts": 1,
    "retryReasons": [],
    "status": "passed",
    "title": "renders the docs link"
  }
]

Reporterのinterfaceに沿って、テストの内容を受け取ってあとはそれを好きに整形するだけです。思った以上に簡単ですね。

カスタムレポーターをJestに組み込む

先ほどのカスタムレポーターはTypeScriptで書いていますが、JestのカスタムレポーターはJavaScriptとして渡す必要があるので、TypeScriptをJavaScriptに変換します。

色々と方法はありますが、比較的お手軽な方法を紹介します。
(一番手軽なのはJavaScriptで書いてしまうことですが、型のサポートなしにコードを書くのは大変なので考えないとします)

今回はts-nodeを使ってTypeScriptをJavaScriptに変換しつつ実行させるようにします。

こちらの記事を参考にさせていだだきましたmm
https://chanonroy.medium.com/how-to-setup-a-custom-test-reporter-for-jest-f994636073a9

以下の様にindex.jsとmyCustomReporter.tsを作成します。

.jest/
└── customReporters/
    └── myCustomReporter/
        ├── index.js
        └── myCustomReporter.ts // 前の章で作成したカスタムレポーターです

そしてindex.jsは以下のようにします。

const tsNode = require("ts-node");

tsNode.register({
  transpileOnly: true,
  // compilerOptionsは実行環境に合わせて適宜設定してください
  compilerOptions: {
    target: "ES2021",
    module: "CommonJS",
    esModuleInterop: true,
    forceConsistentCasingInFileNames: true,
    strict: true,
    skipLibCheck: true,
  },
});

module.exports = require("./myCustomReporter");

これでTypeScriptで書いたカスタムレポーターをJavaScriptとして読み込むことができます。

あとはこのカスタムレポーターをJestに組み込むだけです。

jestの設定に追加する場合は、jest.config.jsに以下のように記述します。
defaultのレポーターも一緒に使用する例です。

jest.config.js
{
  "jest": {
    "reporters": [
      "default",
      "./.jest/customReporters/myCustomReporter"
    ]
  }
}

npm scriptとして実行する場合は、以下のように記述します。

package.json
{
  "scripts": {
    "test:md": "jest --reporters=default --reporters=./.jest/customReporters/myCustomReporter --coverage=false"
  }
}

--coverageのオプションがないと、npm run test:md ${testFilePath} とした時にカスタムレポーターのパスがなぜか解決されなかったで入れています。ちゃんと調べてなくすみませんmm

カスタムレポーターを使ってテスト結果をMarkdownとして出力する

ここまでカスタムレポーターをの作成とJestに組み込む方法を紹介しました。

では実際にカスタムレポーターを使ってテスト結果をMarkdownとして出力し、ドキュメントとして残してみましょう。

カスタムレポーターの中身は以下のようになります。

testCaseToMarkdownReporter.ts
import {
  Reporter,
  Test,
  TestCaseResult,
  AggregatedResult,
  TestResult,
} from '@jest/reporters'

import fs from 'fs'

type CustomReporter = Partial<Reporter>

function escapeTagString(str: string): string {
  return str.replace(/</g, '\\<').replace(/>/g, '\\>')
}

function convertToMarkdownList(data: TestCaseResult[]): string {
  // テストケースを木構造に整形する
  type Tree = {
    [key: string]: Tree | string
  }
  const tree: Tree = data.reduce((acc, item) => {
    const { ancestorTitles, title, status } = item
    let currentNode = acc
    ancestorTitles.forEach((title) => {
      if (!currentNode[title]) {
        currentNode[title] = {}
      }
      currentNode = currentNode[title] as Tree
    })
    currentNode[title] = status ?? ''
    return acc
  }, {} as Tree)

  // マークダウンのリストに変換する
  let result = ''
  const convertToMarkdown = (obj, level) => {
    Object.entries(obj).forEach(([title, value]) => {
      const prefix = `${'  '.repeat(level)}- `
      const escapedTitle = escapeTagString(title)
      if (typeof value === 'object') {
        const describe = `${prefix}${escapedTitle}`
        result += describe + '\n'

        convertToMarkdown(value, level + 1)
      } else {
        const statusPrefix = value === 'todo' ? '【TODO】' : ''
        const testName = `${prefix}${statusPrefix}${escapedTitle}`
        result += testName + '\n'
      }
    })
  }

  convertToMarkdown(tree, 0)
  return result
}

export default class TestReporter implements CustomReporter {
  onTestResult(
    test: Test,
    testResult: TestResult,
    _results: AggregatedResult,
  ) {
    if (testResult.numFailingTests > 0) return

    const testFilePath = testResult.testFilePath
    const testFileDir = testFilePath.split('/').slice(0, -1).join('/')
    const testFileName = testFilePath.split('/').slice(-1)[0]

    const markdown = convertToMarkdownList(testResult.testResults)

    // テストファイルと同じディレクトリにテストケースのマークダウンを出力する
    fs.promises.writeFile(`${testFileDir}/${testFileName}.report.md`, markdown)
  }
}

マークダウンの形式に整形するところの実装はややこしいので配列で渡されたテストケースを木構造にに直しているんだなぐらいの理解で問題ないです。
正直カスタムレポーターの実装より、配列を木構造に変換するところの実装の方が100倍難しかったです。

このカスタムレポーターを実行すると以下のマークダウンがテストファイルと同階層に出力されます。

index.test.report.md
- Home
  - renders a heading
  - renders a documentation link

これだけです。笑

これだけならテストコードをそのまま読むでもいいのでは?という話になると思いますが、
テストコードにdescribe.eachit.eachが使われてくると、全体像を把握するのが難しくなってくるので、このように人が読む部分だけを抽出しておくと仕様の把握が楽になると思います。

まとめ

  • @jest/reportersのinterfaceに合わせることでカスタムレポーター作成できる
  • ts-nodeを使うとTypeScriptでカスタムレポーターを書ける
  • カスタムレポーターを使うことでテスト結果を好きなように整形して利用することができる
スペースマーケット Engineer Blog

Discussion