MNTSQ Techブログ

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

差分指向テスト(DOT: Difference Oriented Testing)という考え方

MNTSQ Tech Blog TOP > 記事一覧 > 差分指向テスト(DOT: Difference Oriented Testing)という考え方

はじめに

MNTSQ(モンテスキュー)株式会社 フロントエンド担当の安積です。
入社して4ヶ月とちょっと。
コードに取り組もうと入社して、まさに日々格闘しております。

私の後ろの席にはこんなバズ記事書く人や、こんなイカつい記事書く人が座ってまして、そんなプレッシャー期待の中からお送りいたします。

tech.mntsq.co.jp

tech.mntsq.co.jp

昨日はこんな記事も公開されています。

tech.mntsq.co.jp

現在のステータス またはMNTSQ考古学

コードベースから見たMNTSQのフロントエンドは、0->1 のフェーズにおいて「アーキテクチャの精査」とか「クリーンなコードを書く」というよりかは様々な要素をとにかく形にして使ってみて、という繰り返しだった事が伺えるものになっていて、Githubの中で考古学的考察が必要な場面がかなりあります。

コードの行間から感じる事、色々あって解ってきた事かなりあるのですが、 MNTSQのコードは最初からきちんと管理されていて履歴を全て追える状態である、という事もあり考古学的見地から背景を読み解くのもなかなか面白みがある、って書いたら不謹慎でしょうか。

山積している課題も今までフロントエンド専任の方が居なかった事もあり、今までの経験で新たなPJに参画した時にはよく感じる事で、あとは程度問題、みたいに捉えています。

で、今進行している新機能の開発と平行して、既存のコードに対しての改善計画のロードマップを策定中です。
その一環としてリファクタリングを行おうとしています。

リファクタリングやるぜっっ!

方針を幾つか書くと、

  • 全体スコープでのリプレース、書き直しはしない
    MNTSQ入社以前に、リプレースの案件もそこそこ経験しているのですがリプレースが成功するには幾つかの条件があり、かなりハードルが高いです。
    ネット上で探せば成功例が出てきますが、そういった成功例に価値があるのは失敗例が多いという事の裏返しでもあるのは皆さんご存知だと思います。
    (この辺の話もいつか書きたいのですがここでは割愛)
  • コーディングは命令的なスタイルから宣言的なスタイルに
    命令的なスタイルは、その結果までが当該コードの関心事となる事から肥大化しがちです。
    宣言的に書くことでイベントの連鎖からはある程度は解放されて、リアクティブなフロントエンドフレームワークの恩恵を最大限受ける事ができるようになります。
  • 膨大なスタイルシートに立ち向かう
    上記の歴史からコード上では局所戦の跡がそこかしこに存在し、局所的に解決しようとすることでVueComponentではグローバルスコープのCSSへの上書き、重複したスタイル定義等が多数あり、コードの肥大の原因となっています。
    ここが自分にとっても主戦場の一つになると想定しています。
    スタイルシートって実はとても難しく、プロフェッショナルの戦場なのです。 進化が速いのにデバッグが面倒、おまけに全てグローバルスコープで定義されるものなので。

一言でいうと部分的に置き換えを進め、「テセウスの船」みたいな事をやろうと思っています。

テセウスの船(テセウスのふね、英: Ship of Theseus)はパラドックスの一つであり、テセウスパラドックスとも呼ばれる。ある物体において、それを構成するパーツが全て置き換えられたとき、過去のそれと現在のそれは「同じそれ」だと言えるのか否か、という問題(同一性の問題)をさす。
テセウスの船 - Wikipedia

その位、リファクタリングの結果としての見た目は変えたくないと考えています。
本線は開発がどんどん進む中、「動く標的を撃つ」ような側面もあり、課題感あるのですがその話は別途。

ここで問題になるのが、デグレーションをどうやって防ぐかという点です。

仕様書大事だよね

例えばTDD(テスト駆動開発、Test Driven Development)においては、

  • まずテストを書く
  • テストが通るような、固定値を返すコードを書く
  • [Red] ロジック実装、テスト実行すると全ては通らない状態
  • [Green] テストが通るところまで実装
  • [Refactor] テストが通る状態をキープしながら、ブラッシュアップする

という繰り返しで、コードを書く作業と並行してテストコードが蓄積されるように開発を進める手法が知られています。
テストを書くには当然ながら、テストが書けるようにケースが出せる状態まで仕様が落とせている事が必要となります。

そしてテストの関心事はこの「仕様が満たせているか」という所になります。

MNTSQの社内には"SSoT"という概念が浸透していて、仕様についてもSSoT化されてメンテナンスされています。

信頼できる唯一の情報源 (Single Source of Truth; SSOT) とは、情報システムの設計と理論においては、すべてのデータが1か所でのみ作成、あるいは編集されるように、情報モデルと関連するデータスキーマとを構造化する方法である。
信頼できる唯一の情報源 - Wikipedia

ところが、MNTSQは「破壊的PDCA」を回すことを旨としており、やりたいことがコロコロ変わるということを前提とする必要があってですね。

Bizサイドからのリクエストを都度仕様に落とすとしても比較的荒い解像度のものとなり、個別のケースについての仕様を全て落とし切るというよりかは、どんどん作る事を可能にしたい訳です。

一方、MNTSQはエンタープライズSaaSで、扱っているデータの重要度の高さは言うまでもなく、要求される信頼性も並大抵のものではない訳です。

どうする、俺。

そこで、一旦現在の動作を正として、今後の改修のステップ毎に発生する「差分」に注目しようと考えました。

差分指向テストとは

エンジニア観点では書いたコードが仕様を満たしているか担保したいのは当然なのですが(バックエンドチームはちゃんとやっているし、私自身もユニットテストの粒度でTDDするとフロー状態になってキモチイイのも知ってますが)、刻々と変わるフロントエンドについては、テスト項目をすべてコード化してメンテナンスし続けるよりは

  • 改修と新機能追加の結果、変わった所はどこなのか
  • 意図しない部分が変わっていないか(どちらかといえばこちらが重要)

という辺りにフォーカスしようと考えました。
そして本当にクリティカルで動作を担保したい所だけテスト項目としてGreen/Redをチェック
なおかつ、エンジニアがPullRequestを上げる前に手元で実行できるようになっていれば尚可、という方針としました。

つまり、差分指向テスト とは

開発作業の前後の出力の差分を比較することで、ケースまで落ちない粗い粒度の仕様からの実装であっても開発作業の結果を判りやすく、かつ不要な影響が出ていない事を確認するテスト

というイメージです。

えっ、そんな、と思った方は詳しくお話聞かせて下さい!
カジュアルに面談でお話しましょう!

今回は画面スクリーンショットを例に取りますが、別にDOMでも良いしJSONでもdiffは取れます。 私は行動解析の経験もあるので、そういった辺りも差分は発生するので追って対象にしたいと考えています。 とにかく開発作業の結果、「変わった点」と「変わっていない点」にフォーカスします。

ちなみに呼び名は私が勝手につけたものです。(ここ重要)

テスト環境の概要

テストデータ

コードの出力の差分にフォーカスするので、それ以外の特に入力データは毎回同じものを利用する必要があります。
また、内容としても実際に使われるデータに近いものでないと意味は半減します。
(「ああああ」なんて文字列入れてテストしても気持ちが持てない、と思いませんか?)
幸い、MNTSQでは個々の開発者のローカル環境用にステージング環境のデータを取り込む機構が整備されており、これを利用します。

ブラウザ操作自動化

自動でブラウザを操作してログイン、シナリオに沿って自動で操作して目的の画面でスクリーンショットを次々に撮る形とします。

こういった用途にはSeleniumやPuppeteerが有名ですが、今回はPlaywrightを使います。

github.com

MNTSQは対象ブラウザをChromeChromium版Edgeに限定しており、本記事でもChromeを操作するのですがこのツールはMicrosoft製です。
世の中変わったよなぁ・・・と思います。

ブラウザの操作と状態取得は全てPromiseベースで、なおかつ画面を開いているブラウザに外からJavaScriptを挿入し実行させて何かするという事も比較的簡単に出来ます。
ChromeDevToolにアクセスすることも出来ます。

スクリーンショットの取得もPlaywriteのコマンドで行います。

ページ全体に限らず、DOMの中の或るHtmlElementだけ指定して部分的に撮るという事も出来ます。
新しい機能の開発についてはStorybookを利用しているので、そちらの方で差分を確認する方法もあります。
ですが諸般の事情にて現時点では自前で書いている関係で、この部分的にスクリーンショットを撮れる機能、なかなか便利です。

大筋として管理側と利用者側、独立した2つのコンテキストでそれぞれページを開いて管理側での操作が利用者画面にどのように影響するかという観点でもテストを行います。

シナリオのプログラミング環境としてはTypeScript, JavaScript, Python, .NET, Java が利用可能で、今回はTypeScriptで書くことにしました。
(ここも様々な手段があるようですがフロントエンド担当ですし、細かな操作があることもあってこうしています。)

スクリーンショット比較

今回は
img-diff-js
を利用します。 シンプルなAPIで高速な動作が身の上のようです。

www.npmjs.com

この他、レポート保存にはAPI経由でGoogleSpreadSheetに保存しようかと。
ここはまだ後回しです。
取りたいのはエラーの有無、画像のリスト (w/サイズとHTTPレスポンスステータスコード)、リンクのリスト(w/有効or無効)、スタイルシートのリストとカバレッジ等です。
結構データ量があるので後処理も楽しそう大変そうなのでスプレッドシートが向いているかと。

実はまだ開発中で実証コードが動いた段階なのですが、「なんとなく」書いた処理シーケンス貼っておきます。

処理シーケンスドラフト

何だよこれ、と思った方は是非お話聞かせて下さい! カジュアル面談でお待ちしております!

Playwriteの操作

PlaywriteにおけるブラウザのAPIは大きく分けて以下の3つとなります。

  • Browser
  • BrowserContext

    • ここちょっと解りづらいかも知れませんが、Contextを別ける事で複数のセッション(ログインセッションとか)を同時に扱うことが出来るようになります。

      BrowserContexts provide a way to operate multiple independent browser sessions.
      BrowserContext | Playwright

  • Page

    • これがブラウザの一つのウインドウです。複数ページを同時に開く場合BrowserContextからPageのインスタンスを複数生成する形となります。

ちょっとコードのサンプル

Browser生成

import {Browser, BrowserContext, Page, chromium} from 'playwright';

const browser = await chromium.launch({
  channel: 'chrome',
  headless: false,  // ここをfalseにすることで実行中ブラウザ画面が表示されます。デバッグ用途
  args: [
`--window-position=${windowPositionX.toString()},${windowPositionY.toString()}`,
  ],  //PC画面上でブラウザが開く場所を指定できます。複数開いてデバッグするのに便利です。
});

BrowserContext生成

const browserContext = await browser.newContext({
  // ここで指定しておくことで、後のページ遷移はpathで指定できるようになります
  baseURL: 'http://localhost:8080'
  //.ここでviewportのサイズも指定できます。
  viewport: {
    width: 1280,
    height: 800,
  },
});

Page生成

const page = await browserContext.newPage();

Pageでページをpath指定して開く

await page.goto(path);

ページ内のあるテーブルの2列目のセルから文字列(foobar)を検索して何行目にあるかを返す

const searchNeedle: SearchNeedle = {
  needleText: 'foobar',
};

/**
 * スクリプトをPage内で評価、実行して結果を返します。
 */
const rollIdx: number = await page.evaluate((param: SearchNeedle) => {
  // この中がブラウザ側で実行されます。
  const {needleText} = param;
  // 検索結果
  let resultIdx = 0;
  Array.prototype.forEach.call(
    document.querySelectorAll('#target-table tr'),
    (elm, idx) => {
      const cellText = elm.querySelector('td:nth-of-type(2)').textContent
      if (cellText === needleText) {
        resultIdx = idx;
      }
    },
  )
  // ここでブラウザ上での処理結果を返り値とするとPage.evaluate関数の返却値として取得できます。
  return resultIdx;
}, searchNeedle);

ページ上のリンクをクリック、画面遷移を待つ

await page.click('a.target'); // 複数hitした場合、最初の要素がclickされます
await page.waitForLoadState('load'); // 次のページのloadイベントまで待ちます

スクリーンショットを取る(全体)

import path from 'node:path';

await page.screenshot({
  path: path.join(SCREENSHOTS_IMG_DIR, fileName),
  fullPage: true,
});

スクリーンショットを取る(一部エレメント)

const part = await page.locator('div.target');
if (await part.count()) {
  await part.screenshot({
    path: path.join(SCREENSHOTS_IMG_DIR, fileName),
  });
};

画像のdiffを取る

import {imgDiff} from 'img-diff-js';
const result = await imgDiff({
  actualFilename: sourceFilePath,
  expectedFilename: destFilePath),
  diffFilename: diffFilePath, // <- このpathに差分を強調した画像ファイルが出力されます。
});

最後に

駆け足でしたが、いかがだったでしょうか。 今は一人で取り組んでいるのですが、一緒に考えて進める仲間を探しています。 詳しくはページヘッダの採用ページへのリンクから!

この記事を書いた人

安積洋

MNTSQ(モンテスキュー)社のソフトウェアエンジニア。実はギタリスト。 LAMPエンジニアとしてバックエンド担当が長かったがその後O2Oアプリのフロントエンド、アプリPM、行動解析、と渡り歩いてMNTSQではフロントエンド担当。

入社エントリはこちら! note.com