⌨️

Vitest でインタラクティブな CLI のテスト

2024/09/01に公開

はじめに

Node 環境の CLI にはテキストの入力や選択肢からの選択などをインタラクティブに行えるものがよくある
インタラクション部分は InquirerEnquirer などで実装できる

このような CLI をテストする場合、テキストを入力したり選択肢を矢印キーで選択するようなユーザ操作をどのように再現するか試行錯誤した

以下のような関数を例とする

cli.ts
import { input, select } from "@inquirer/prompts";

const greet = async () => {
  const name = await input({
    message: "Your Name:",
  });
  return `Hello, ${name}.`;
};

const order = async () => {
  const answer = await select({
    message: "Which one do you want?",
    choices: [{ value: "apple" }, { value: "orange" }, { value: "banana" }],
  });
  return `Here's your ${answer}!`;
};

標準入力のモック

CLI におけるユーザ操作とはつまるところ標準入力なので、標準入力をモックする必要がある
今回は mock-stdin を使った

npm install --save-dev mock-stdin
cli.test.ts
import { type MockSTDIN, stdin as mockStdin } from "mock-stdin";
import { greet, order } from "./cli.js";

const keys = {
  up: "\x1B\x5B\x41",
  down: "\x1B\x5B\x42",
};

describe("cli", () => {
  let stdin: MockSTDIN | null = null;

  beforeEach(async () => {
    stdin = mockStdin();
  });

  afterAll(() => {
    stdin?.restore();
  });

  describe(greet, () => {
    it("greets", async () => {
      setTimeout(() => {
        // テキスト入力
        stdin?.send("John Doe");
        // 確定の Enter
        stdin?.send("\n");
      }, 10);

      const greeting = await greet();
      expect(greeting).toBe("Hello, John Doe.");
    });
  });

  describe(order, () => {
    it("handles order", async () => {
      setTimeout(() => {
        // 1つ下の選択肢を選択
        stdin?.send(keys.down);
        // 確定の Enter
        stdin?.send("\n");
      }, 10);

      const orderedItem = await order();
      expect(orderedItem).toBe("Here's your orange!");
    });
  });
});

setTimeout でユーザ操作をさせつつ CLI プログラムを await で実行するのがミソ

標準入力をモックしているところは多数のテストファイルで同じ処理が必要になったりすると思うので、以下のように切り出しておくと良い

mockStdin.ts
import { type MockSTDIN, stdin as mockStdin } from "mock-stdin";

export const mockStdin = (): (() => MockSTDIN | null) => {
  let stdin: MockSTDIN | null = null;

  beforeEach(() => {
    stdin = mockStdin();
  });

  afterAll(() => {
    stdin?.restore();
  });

  return () => stdin;
};
cli.test.ts
describe("cli", () => {
+ const getStdin = mockStdin();
- let stdin: MockSTDIN | null = null;
-
- beforeEach(async () => {
-   stdin = mockStdin();
- });
-
- afterAll(() => {
-   stdin?.restore();
- });

  // ...

  describe(greet, () => {
    it("greets", async () => {
      setTimeout(() => {
        // テキスト入力
+       getStdin()?.send("John Doe");
-       stdin?.send("John Doe");
        // 確定の Enter
+       getStdin()?.send("\n");
-       stdin?.send("\n");
      }, 10);

      const greeting = await greet();
      expect(greeting).toBe("Hello, John Doe.");
    });
  });
});

あとがき

Inquirer はテキスト入力や選択式など多数の入力方式を提供してくれるのが便利な反面、新バージョンがリリースされた際実際に使っている入力方式への影響の有無がわかりづらいので上げづらいのがちょっとつらい
CHANGELOG がなく GitHub 上の release も各パッケージが混ざってるせいで読み取りづらいし…

実際に挙動を確認するテストさえ用意しておけば、CI が通っている限り安心して上げられるのでかなり扱いやすくなった

Discussion