📝

テーブル駆動テストを使った QA エンジニアとソフトウェアエンジニアの連携

2023/12/03に公開1

この記事はUbie Engineering Advent Calendar 2023の3日目です。


こんにちは、ユビーでプロダクト開発エンジニアをしている Sosuke Suzuki です。この記事では QA エンジニアと連携するにあたって、テーブル駆動テストがとても役に立ったという話をします。

テーブル駆動テストとは

テーブル駆動テスト(Table-Driven Testing)とはテストを書くときのテクニックの一つです。複数のテストに共通するパターンをテーブルとして抽出することで、コードの記述量を減らすことができ、テストを追加するのが簡単になります。

TypeScriptとJestでテーブル駆動テストを使う例を示します。次のような sum 関数があるとします:

function sum(a: number, b: number): number {
  return a + b;
}

この関数に対して、sum.spec.ts を書きます:

describe("sum", () => {
  test("1と1を足した結果は2", () => {
    const result = sum(1, 1);
    expect(result).toBe(2);
  });
  test("30と5を足した結果は35", () => {
    const result = sum(30, 5);
    expect(result).toBe(35);
  });
});

このままテストケースを増やそうとすると、同じような形を持つ test 関数の呼び出しが増えていきます。テーブル駆動テストを導入することで、そのような重複を減らすことができます:

test.each([
  {a: 1, b: 1, expected: 2},
  {a: 30, b: 5, expected: 25},
])('.sum($a, $b)', ({ a, b, expected }) => {
  expect(sub(a, b).toBe(expected);
});

テーブル駆動テストは Go 言語を使った開発で良く使われるスタイルです。Go 言語の GitHub リポジトリの Wiki にはテーブル駆動テストに関するページがあるので、興味がある人はそちらを読んでみてください。

テーブル駆動テストを使った QA エンジニアとソフトウェアエンジニアの連携

テストがなくリファクタリングが困難なフロントエンド

症状検索エンジン ユビー には、ユビーのビジネスにとって重要な、とあるページがあります。そのページではフロントエンドからロギングサービスに対してたくさんのログを送っています。

そのページのフロントエンドのコードはかなり読みにくい状態になっていたので、大規模なリファクタリングに取り組みたいと思いました。しかし、フロントエンドのコードに対するテストがほとんどなく、そのままリファクタリングに取り組むのは困難でした。

そこで、フロントエンドからログがちゃんと送信されていることを検証するためのテストを書き始めました。

プログラミング経験が豊富な QA エンジニアの入社

それからしばらくして takama さんという QA エンジニアの方がユビーに入社し、自分の所属しているチームにやってきました。takama さんはユビーでは珍しい、プログラミングの経験が豊富な QA エンジニアです。

takama さんの入社エントリはこちら:

https://zenn.dev/ubie_dev/articles/cd37853b798c47

ユビーで QA エンジニアと一緒に働く中で、QA エンジニアの勘所の良さは凄まじく、本当に心強いと思うようになりました。そして、「プログラミング経験が豊富である QA エンジニアが入社する」という話を聞いたときから、QA エンジニアとしての勘所の良さとコードによる自動テストを上手く組み合わせることができれば、ユビーのソフトウェア開発をより安全なものにできるのではないかと考えていました。

そして、そのとき私が取り組んでいたリファクタリングのためのテスト拡充は、その考え方を適用するのにちょうど良い場面だと思いました。

テーブル駆動テストの導入

私たちが導入したテーブル駆動テストの例を示します。

テスト対象となるページのコンポーネントが、以下の PageComponent として定義されているとします:

import logger from "~/infrastructure/logger";

type Props = {
  buttons: { label: string }[]
};

export default function PageComponent({ buttons }: Props) {
  return (
    <div>
      {buttons.map(({ label }, index) =>
        <button
	  id={label}
	  onClick={() => {
	    logger.eventLog("click_event_log", { label, index });
	  }}
	>
	  {label}
	</button>)
      }
    </div>
  )
}

このコンポーネントは、ボタンのラベルについての情報を配列として受け取り、それをそのまま描画します。それぞれのボタンがクリックされると logger.eventLog というログを送信する関数を呼び出します。引数として、そのボタンに表示されている文字と、ボタンが何番目に表示されていたかを渡します。

そして、このようなコンポーネントのボタンをクリックしたときにログが送信されることを検証するテストを、以下のようなテーブル駆動テストとして書くことにしました:

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import PageComponent from "~/components/hoge/PageComponent";
import logger from "~/infrastructure/logger";

describe("クリックのログ", () => {
  test.each([
    {
      name: "2番目のボタンをクリックすると、2番目のボタンについてのログが送信される",
      input: {
        clickedButtonLabel: "second button",
        props: {
          buttons: [
	    { label: "first button" },
	    { label: "second button" }
	  ],
        },
      },
      output: {
        loggerCallArgument: {
          eventName: "click_button_log",
          payload: { label: "second button", index: 1 },
        },
      },
    },
  ])("$name", async ({ input, output }) => {
    const user = userEvent.setup();

    render(<PageComponent {...input.props} />);

    await user.click(screen.getByRole("button", { name: input.clickedButtonLabel }));

    await waitFor(() => {
      const { loggerCallArgument } = output;
      expect(logger.eventLog).toHaveBeenCalledWith(
        loggerCallArgument.eventName,
	loggerCallArgument.payload
      );
    });
  });
});

テーブルには、nameinputoutput というフィールドがあります。name はテストケースの名前です。inputPageComponent の props(つまり表示するボタンの情報)と、クリック対象のボタンを持っています。output はロガー関数の引数として渡されることが期待される値を持っています。

input.propsPageComponent に渡して描画し、input.clickedButtonLabel に対応するボタンをクリックします。そして logger.eventLog 関数が、期待する output の値を引数として呼び出されることを検証します。

input の値を変えることで output と比較される実際の値が変わっていきます。例として簡単なコードを示しましたが、実際のコードはもっと複雑です。多くの inputoutput の組み合わせを定義することによって色々なパターンの挙動をテストできます。

テーブル駆動テストを使った連携

連携するにあたって、QA エンジニアの takama さんにはプログラミングの経験が豊富であるという強みを活かして自動テストのコードを書く作業をしてもらいたいと思いました。

考えた結果、ソフトウェアエンジニアである自分は React コンポーネントの描画やクリックやアサーションなどの部分と例になるようなシンプルなテストケースを記述し、複雑なテストパターンを書く部分は takama さんに書いてもらうという体制をとることにしました。

これによって takama さんは React、testing-library、Jest などのライブラリの使い方や、テスト対象のコンポーネントの詳細などを把握する必要がなくなり、スムーズに作業に取り組めるようになりました。また、自分は仕様から複雑なテストパターンを考えるのは苦手なのですが、QA エンジニアとしてそのスキルがある takama さんにその作業を任せることができるようになりました。

つまり、お互いの得意な部分を活かして、苦手な部分を補い合う形を作ることができたのです。

takama さんは、入社してすぐこの仕事に取り組んでくれました。結果として、事業ドメインや仕様のキャッチアップとしても機能したようです。

今後

この連携により、フロントエンドの重要なログのテストは充実したものになってきました。しかし、肝心のリファクタリングにはまだ取り組めていません。今後は他の仕事との優先度も考えながらにはなりますが、このテストを足がかりにしてリファクタリングに取り組みたいと思っています。

おわりに

今回は、テーブル駆動テストを使って QA エンジニアとソフトウェアエンジニアが連携することによって良い効果がもたらされた例を紹介しました。

成長した多くのスタートアップがそうだと思いますが、ユビーでは、創業当初に書かれた安全性やメンテナビリティがあまり考慮されていないコードや(私もそういうコードをたくさん書いてきました)、ビジネス要件の変化に伴って古くなってしまったコードがたくさんあります。

今のユビーは、ビジネス的な価値を生んでくれたそのようなコードとその作者に敬意を持ちつつ、その価値を今後も失わないために、より安全で安心できるソフトウェア開発を実現するための活動を行うフェーズに入りました。そのためには、まだまだ仲間が必要です。

もしユビーでの仕事に興味を持ってくれた人がいたら、一度カジュアルにお話させてください。Twitter の DM などで連絡をいただけると嬉しいです。私の Twitter は @__sosukesuzuki です。現在オープンなポジションは https://recruit.ubie.life/ から確認できます。

Ubie テックブログ

Discussion

makoto-developermakoto-developer

Gherkin + Cucumberでも条件1*条件2=想定結果などで組み合わせを作ります。それですかね。