nodeJS初心者がレガシーコードのnodeJSに単体テストを入れてCIしてみた

この記事は Akerunのカレンダー | Advent Calendar 2022 - Qiita の 14日目の記事です。 はじめまして、nodeJS初心者の Esperna - Qiitaです(2022年6月からnodeJS触り始めました)。

背景

自動テストがない(手動の実機テストのみの)レガシーコードで、ちょっとした修正をするのにもバグが出やすく、 リファクタリングをするのが大変という状況ってありますよね。 そのような状況で今年自分が取り組んだことを書いてみます。 nodeJSにはJest*1があるのでそのチュートリアルを見ながら 次の2つを意識して単体テストを書きました。

  • 単体テストを書くことで既存のソフトウェアの仕様を明確にする
  • 単体テストをCIに組み込むことで仕様が明確な部分に関しては振る舞いが変わった時は直ちに検知できるようにする

やったこと

実際にやったことは以下です(いわゆるテスト駆動開発)。

  • 主要なロジックや過去に不具合が出た箇所に関して、仕様を調べた
  • 上記を元に単体テストを書くことで既存の振舞いが変わったを検知できるようにした(ソフトウェア万力)*2
    • 現行のソフトの振る舞いを明らかにした
    • 後述のCIと組み合わせることで既存の振舞いが変わった場合にテストがFAILするようにした
  • 新規ソースコードを書く際は失敗するテストコードを書いてからそれをPASSさせるようにした(Red Green Refactor)*3
    • 先にソースコードを書いて、後からテストコードを書くとテストがFAILした場合にデバッグしているという感覚になるのでできるだけ避けたい
    • 逆に先にテストコードを書くと(テストファースト)*4、テストがFAILした場合もテストをPASSさせるためにソースコードを書いているという感覚を維持できる
  • 最低限のCI(Continuous Integration)ツールを設定した
    • eslintの実行(lint, formatterの設定)
      • 可読性の観点からチーム内でソースコードのフォーマットが共通になるよう強制した
      • 実動作上問題がなくてもソースコードにwarningがある状態になっているとバグが入り込み易く・見落とし易くなるのでwarningがない状態を維持した
    • レポジトリへのPushをトリガに単体テスト(リグレッションテスト)を実施した

CIの設定抜粋

image: node:xx.x.xx

stages:
  - lint
  - test

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/

lint:
  before_script:
    - npm ci --cache .npm --prefer-offline
  stage: lint
  script:
    - npx eslint src

test:
  stage: test
  script:
    - npm ci --cache ../.npm --prefer-offline
    - npm run test test
  • Jestを実行しカバレッジを出力した
    • カバレッジは数値そのものよりもどのパスが通ってないか知ることを意識した。通ってないパスにバグが潜んでいる可能性があるため
  • 依存関係が複雑でテストが書きにくいものに対してJestによりmockを設定し、test及びmockフォルダをsrcと分離した(テストダブルによる依存関係の分離)*5
  • 単体テストを書くためにrewire*6__set__を使ってconst(再代入できない変数)を書き換えた
    • constにしているものを無理矢理書き換えるのはコードの複雑さやバグを招くため一般にアンチプラクティスである
    • constにしているものがファイルパスであったため、今回は一種のテストダブルのような扱いで変数の書き換えを行った
    • constを参照するのをやめて、テスト対象のメソッドの引数に参照先を追加するというリファクタリング方法が考えられる
  • nock*7を使ってhttpレスポンスをmockした

テストとソースコードの典型例は以下です。

ソースコード

class Sample {
  constructor() {
    this.client = null;
  }
  method1() {
    const res = {
      isSuccess: false,
      body: "",
    };
    if (this.client === null) {
      res.isSuccess = false;
      return res;
    }
    res = doSomething();
    return res;
  }
  method2(a, b) {
    return a + b;
  }
}

module.exports = new Sample();

テストコード

const target = require("./sample");

describe("Sample Class test", () => {
  //Single function test
  test("func1 shall return when client is null", () => {
    target.__set__({ client: null });
    const result = target.method1();
    expect(result.isSuccess).toBe(false);
  });

  //Parameterized test
  it.each([
    ["method2 shall return sum if A,B are default", 10, 10, 20],
    ["method2 shall return sum if A,B are more than default", 20, 20, 40],
    ["method2 shall return sum if A,B are less than default", 5, 5, 10],
    ["method2 shall return sum if A is more than default and B is less than default", 20, 5, 25],
    ["method2 shall return sum if A is less than default and B is more than default", 5, 20, 25],
  ])("%s", (testcase, A, B, want) => {
    const result = target.method2(A, B);
    expect(result).toBe(want);
  });
});

テストの結果の例は以下です。 Uncovered Lineから通ってないパスが分かると思います(もちろんvisualizeすることも可能です)。 今回の場合はclientがnullでない場合のreturn resです。ここでは書きませんが実際はここのパスを通すテストを追加していきます。

% npm test           

> test
> jest --runInBand --coverage

 PASS  ./sample.test.js
  Sample Class test
    ✓ func1 shall return when param is null (1 ms)
    ✓ method2 shall return sum if paramA,B are default
    ✓ method2 shall return sum if paramA,B are more than default
    ✓ method2 shall return sum if paramA,B are less than default
    ✓ method2 shall return sum if paramA is more than default and paramB is less than default
    ✓ method2 shall return sum if paramA is less than default and paramB is more than default

-----------|---------|----------|---------|---------|-------------------
File       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-----------|---------|----------|---------|---------|-------------------
All files  |    87.5 |       50 |     100 |    87.5 |                   
 sample.js |    87.5 |       50 |     100 |    87.5 | 15                
-----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        0.572 s, estimated 1 s
Ran all test suites.

今後の課題

  • 現行のソフトの振る舞いが明らかになったがエラーチェックなど仕様として足りてない部分がある
  • テストの書き方が洗練されていない
    • テストコードが長く複雑で読みにくくなってしまうことがあった
  • テストの量が増えてくると実行時間がボトルネックになるのが目に見えているので並列化なども追々考えたい

所感

こうして書いてみると、当たり前のことばかりで、技術的に地味ですね。 でも、当たり前のことを当たり前にやることが難しく、 一見地味でもやっていくと効率が上がると思うので続けていこうと思います。

参考文献

jestjs.io www.shoeisha.co.jp shop.ohmsha.co.jp yshibata.blog.ss-blog.jp xunitpatterns.com www.oreilly.co.jp github.com github.com


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

*1:Javascripとのテストフレームワーク

*2:「レガシーコード改善ガイド」に> 変更を見つけるためにテストを用意することは、コードを万力で固定するのと同様の効果があるという記載がある

*3:テスト駆動開発」にRed Green Refactorに関する記載があるが、「テスト駆動開発による組み込みプログラミング」で知った

*4:テスト駆動開発」にテストファーストに関する記載があるが、そちらの内容よりは柴田芳樹さんのブログに記載されている内容を意識している

*5:「xUnit Testing Pattern」で提唱された用語だが「テスト駆動開発による組み込みプログラミング」で知った

*6:nodeJSの単体テストにsetter/getterを追加するパッケージ

*7:nodeJSでhttpサーバをmockするライブラリ