thasmto's blog

フロントエンドエンジニアやってます。プログラマーとして学んだことや考えていることを投稿するブログです。

コンポーネント設計の基礎

ソフトウェアコンポーネントについて、Wikipediaでは以下のように記載されています。

Wikipedia:ソフトウェアコンポーネント

事前に製作されたコンポーネント群を組み合わせて、電気製品や機械製品を作るようにソフトウェアを作れることを強調する。

このようにコンポーネント指向の開発ではいくつかのコンポーネントを組み合わせてサービスを開発します。
そのため、ひとつ一つのコンポーネントは様々な組み合わせに耐えうる設計をしなければいけません。
そのようなコンポーネントの設計を行う上での基礎的な考え方を紹介します。

コンポーネントにマージンやポジションなどの外側のレイアウトを設定しない

コンポーネントが持つ情報はコンポーネントの内側の情報だけにとどめることで、外側がどんな状況でも組み合わせやすくなります。

隣のコンポーネントと何pxスペースをあけるか、親コンポーネントの左端から何pxの位置にコンポーネントを置くかという、マージンやポジションはコンポーネント の外側の情報になるのでコンポーネントに持たせるべきではありません。

コンポーネントに情報を持たせるか迷った時は、情報がコンポーネントの内側に関することなのかという点は一つの指標になると思います。

コンポーネントに親コンポーネントの知識を持たせない

先ほども述べたとおり、コンポーネントが外側を知りすぎると良くありません。
コンポーネント→子コンポーネントの順番でコンポーネント を作成していると、
子が親の知識(コンポーネントの外の知識)を持っていることを前提にした「親<子」の関係になってしまいがちです。
この間違いをすると、特定の親コンポーネント でしか使えないコンポーネントになってしまいます。
コンポーネントを設計する際は、親コンポーネント が子コンポーネントの知識を包括する「親>子」の関係を意識したほうがいいです。

// Bad

const Parent = () => {
  const list = getList();
  const index = getIndex();
  
  return <div><Child list={list} index={index}  /></div>
}

// ✖︎ 子が親のstateを知りすぎている
const Child = ({list, index}) => {
  const item = list[index]
  return <div>{item}</div>
}
// Good

const Parent = () => {
  const list = getList();
  const index = getIndex();

  return <div><Child item={list[index]}  /></div>
}
  
// ○ 子は自分自身の必要最低限の情報のみ知っている
// itemさえあればどこでもコンポーネントを呼び出せる
const Child = ({item}) => {
  return <div>{item}</div>
}

インターフェースを統一する

コンポーネントを利用する人がコンポーネントについて学ばなければいけないことがあまりに多いと、それは利便性に欠けた設計と言えます。
利用者がコンポーネント名や利用シーンからある程度の使い方をイメージできなければ、間違った使われ方をしたり、呼び出されたコードを見ても何を意味しているかわかりづらく可読性の低いコードになってしまうので注意が必要です。

特に、同じような意味合いのデータやプロップスでもコンポーネントによって名前が違ったり、データの型が異なっていると利用者は混乱します。最低限、コンポーネント のインターフェースを統一することを意識しましょう。

簡単に始められることとして、既存のHTMLの要素のインターフェースを真似することをおすすめします。 例えば、ボタンコンポーネントを作成する場合は、HTMLのbutton要素のようにdisabled属性を受け取ってボタンを非活性に出来たり、タグでテキストを囲むとボタン内のテキストとして表示するといった具合です。

// buttonタグのインターフェースを利用できていない例
<Button isDisable text="進む" />
// buttonタグのインターフェースを利用している例
<Button disabled>進む</Button>

また、HTML要素も再利用可能なコンポーネントの一つとして捉えることができ、利用者がそれらを呼び出す際にHTML要素なのか、それとも作成されたコンポーネントなのか意識しなくてもいいような設計が望ましいと考えます。

そういった意味でもHTMLの要素がどういったインターフェースになっているか観察し、真似できるところがないか考えてみることは重要です。

粒度を意識する

コンポーネントはどれだけUIの情報を詰め込むかで粒度が変わってきます。 一つのコンポーネントで細かいUIの情報を多く含めすぎると、違いがほとんどないUIを作りたいとなったときでも、それらを構成するUIを再利用できず、ほとんど内容が変わらないコンポーネントを増やすか、無理矢理拡張性を持たせることになってしまいます。

再利用可能なコンポーネントにするためには、切り分けられる最小の単位までコンポーネント分割することです。小さく分割したコンポーネント群は、そうでないものに比べて多様な組み合わせが可能になります。

f:id:thasmto:20210822234601p:plain
コンポーネントの粒度を意識する

UIを分割したときコンポーネントの最小単位は機能ごとに変わってきます。 例えば、ボタンコンポーネントはbuttonタグとスタイルだけで構築可能ですが、 ヘッダーコンポーネントは各ページへのナビゲーションやロゴ、ハンバーガーメニューなどの構成要素を必要とします。 このコンポーネントごとの粒度の違いを意識し、整理整頓することで再利用可能なコンポーネントを開発しやすくなります。

また、メリットはそれだけではありません。 コンポーネントを適切な粒度に分割すると、それぞれのコンポーネントを修正した際の影響範囲の予測も容易になります。

粒度の小さいコンポーネントは、粒度が大きいコンポーネントの構成要素として再利用される場面が多くなります。それらに比べて粒度が大きいコンポーネントは再利用される場面は少なくなってきます。

ボタンなどの粒度の小さいコンポーネントを修正する場合は、その他のコンポーネントへの影響が出ないように意識する必要がありますが、粒度が大きいコンポーネントは使用される場面を特定しやすく、影響内容を正確に把握できるので、UIに影響がある修正も許容しやすくなります。

このようにコンポーネントを適切な粒度に切り離し、それぞれの影響範囲を予測しやすくなると、コンポーネントの修正や拡張をする際の判断を正確にできるようになります。

アトミックデザイン

https://atomicdesign.bradfrost.com/ コンポーネントの粒度の目安としてAtomic Designという方法論があります。

物質が原子や分子から構成されるように、コンポーネントを以下の5つの粒度に分類します。 Atoms(原子) Molecules(分子) Organisms(有機体) Templates(テンプレート) Pages(ページ)

詳細は割愛しますが、AtomicDesignは広く受け入れられている考え方なのでコンポーネントの粒度を考える際の目安として参考になると思います。

粒度を分類する際の注意点

粒度を分類する際に陥りやすい間違いとして、粒度を正しく分類できたとしても、そのコンポーネントの中で粒度の小さい部分まで定義してしまうことがあります。
例えば、ヘッダーコンポーネントをOrganismsに分類して作ったとしても、そのヘッダーの中でMoleculesやAtomsなどの小さい粒度のUIまで定義してしまうといったことです。
なるべくコンポーネント内の小さい粒度の実装は別のコンポーネントとして切り出すべきで、小さい粒度の実装を含めるとしても一つか二つ下の粒度(TemplatesならOrganismsとMoleculesまで)にとどめるべきと考えます。

また、階層の順番が逆になってしまっているコンポーネントもたまに見かけます。
Templatesのコンポーネントの中でPagesのコンポーネントを呼び出していたり、
Moleculesの中コンポーネントの中でOrganismsのコンポーネントを呼び出すといったことです。
実装しているコンポーネントの粒度だけでなく、その中で呼び出すコンポーネントの粒度も意識しなければいけません。

コードリーディング reg-suit/reg-keygen-git-hash-plugin

業務でビジュアルリグレッションテストの導入を進めており、画像の比較と差分出力のツールとしてNode.jsで動くreg-suitを使用することになりました。

reg-suitではGitのコミットを辿り、どのコミットを比較対象にするか選択することができる「reg-keygen-git-hash-plugin」というプラグインがあり、これを使用することでブランチをマージする前などに作業ブランチとその親ブランチの変更点をスクリーンショットで比較をすることができます。

このプラグインでどのようにNode.jsでGitのブランチやコミットの情報を取得し、利用しているのか気になったのでプラグインのコードを見てみました。

reg-keygen-git-hash-plugin

比較するスクリーンショットを識別するキーとしてGitのコミットハッシュを識別するために使われるプラグイン。 どのように比較するかは公式のREADME内で提示されているグラフを見るとわかりやすいです。

f:id:thasmto:20210711211428p:plain
引用:https://github.com/reg-viz/reg-suit/blob/master/packages/reg-keygen-git-hash-plugin/README.md
分岐元のコミットと現在のブランチのコミットを比較対象としています。

コードを見ていく

srcの中のファイルは以下の3つ。

index.ts

まずはindex.tsから見ていきます。

// index.ts
import { KeyGeneratorPlugin, PluginCreateOptions, KeyGeneratorPluginFactory } from "reg-suit-interface";
import { fsUtil } from "reg-suit-util";

import { CommitExplorer } from "./commit-explorer";

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...省略

  init(config: PluginCreateOptions): void {
    this._conf = config;
  }

  // 比較元のコミットハッシュを取得
  getExpectedKey(): Promise<string> {
    // ...省略
  }
  
 // 比較先のコミットハッシュを取得
  getActualKey(): Promise<string> {
    // ...省略
  }
  // ...省略
}

const pluginFactory: KeyGeneratorPluginFactory = () => {
  return {
    keyGenerator: new GitHashKeyGenPlugin(),
  };
};

export = pluginFactory;

index.tsにはGitHashKeyGenPluginクラスが定義されています。
getExpectedKeygetActuralKeyが重要そうで、メソッドの名前から比較元と比較先のキーとしてGitのコミットハッシュを取得をするメソッドだと予測できます。
getExpectedKeyの中は以下のようになっています。

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...
  getExpectedKey(): Promise<string> {
    if (!this._checkAndMessage()) {
      return Promise.reject<string>(null);
    }
    try {
      const result = this._explorer.getBaseCommitHash();
      if (result) {
        return Promise.resolve(result);
      } else {
        return Promise.reject<string>(null);
      }
    } catch (e) {
      this._conf.logger.error(this._conf.logger.colors.red(e.message));
      return Promise.reject<string>(null);
    }
  }
 ...
}

_checkAndMessage()はgitの情報を取得できるか判断するために.gitディレクトリが作業ディレクトリ内に存在するかをチェックするメソッド。 Git操作可能であれば別ファイルから読み込んだCommitExplorerクラスのインスタンスからgetBaseCommitHashメソッドを呼び出して、比較元のコミットハッシュを取得しています。

getActualKeyメソッドの処理も同じようにCommitExplorerのインスタンスからgetCurrentCommitHashメソッドを呼び出して比較先のコミットハッシュを取得しています。

commit-explorer.ts

次にCommitExplorerの詳細を見ていきます。 GitCmdClientクラスで取得したコミット情報を利用して比較対象のコミットを探すための処理がまとめられています。 見ていくコード量が多かったので、コード内に直接メモを書いています。 また、複雑で理解しきれていないくてメモできていない部分や間違ったことを書いている可能性がありますのでご了承ください。

import { GitCmdClient } from "./git-cmd-client";

export type CommitNode = string[];

export class CommitExplorer {
  private _gitCmdClient = new GitCmdClient();
  private _commitNodes!: CommitNode[];
  private _branchName!: string;
  private _branchNameCache: { [hash: string]: string[] } = {};

  /*
   * e.g. return `[["a38df15", "8e1ac3a"], ["8e1ac3a", "7ba8507"]]`.
   *      The first element of node means commit hash, rest elements means parent commit hashes.
   */
  // カレントブランチ内にあるコミットハッシュと親コミットハッシュのペアを格納した配列を取得する
  getCommitNodes(): CommitNode[] {
    return this._gitCmdClient
      .logGraph()
      .split("\n")
      .map((hashes: string) =>
        hashes
          .replace(/\*|\/|\||\\|_|-+\.|/g, "")
          .split(" ")
          .filter(hash => !!hash),
      )
      .filter((hashes: CommitNode) => hashes.length);
  }

  /*
   * e.g. return `master`.
   */
  // 作業ブランチのブランチ名を取得する
  getCurrentBranchName(): string {
    const currentName = this._gitCmdClient.currentName().replace("\n", "");
    if (
      currentName.startsWith("(HEAD detached") ||
      currentName.startsWith("(no branch") ||
      currentName.startsWith("(detached from") ||
      (currentName.startsWith("[") && currentName.indexOf("detached") !== -1)
    ) {
      throw new Error("Can't detect branch name because HEAD is on detached commit node.");
    }
    return currentName;
  }

  /*
   * e.g. return `ede92258d154f1ba1e88dc109a83b9ba143d561e`.
   */
  // 作業ブランチの現在のコミットのハッシュを取得する
  getCurrentCommitHash(): string {
    const currentName = this._branchName;
    if (!currentName || !currentName.length) {
      throw new Error("Fail to detect the current branch.");
    }
    return this._gitCmdClient.revParse(currentName).replace("\n", "");
  }

  /*
   * Return branch name including target hash.
   * e.g. `["master", "feat-x"]`.
   */
  // 引数に渡したコミットハッシュのコミットを含むブランチの名前を配列で取得する
  getBranchNames(hash: string): string[] {
    if (this._branchNameCache[hash]) return this._branchNameCache[hash];
    const names = this._gitCmdClient
      .containedBranches(hash)
      .split("\n")
      .filter(h => !!h)
      .map(branch => branch.replace("*", "").trim());
    this._branchNameCache[hash] = names;
    return names;
  }
  
  // 全てのブランチ名を配列で取得する
  getAllBranchNames(): string[] {
    return this._gitCmdClient
      .branches()
      .split("\n")
      .map(b => b.replace(/^\*/, "").trim().split(" ")[0])
      .filter(b => !!b || b === this._branchName)
      .filter((x, i, self) => self.indexOf(x) === i);
  }
  
  // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する
  getIntersection(hash: string): string | undefined {
    try {
      return this._gitCmdClient.mergeBase(hash, this._branchName).slice(0, 8);
    } catch (e) {}
  }


  getBranchHash(): string | undefined {
    const branches = this.getAllBranchNames();
    return branches
      .map(b => {
        
        const hash = this.getIntersection(b);
        // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する

        const time = hash ? new Date(this._gitCmdClient.logTime(hash).trim()).getTime() : Number.MAX_SAFE_INTEGER;
        // hashが作られた時間を取得する

        return { hash, time };
      })
      .filter(a => !!a.hash)
      .sort((a, b) => a.time - b.time) // ハッシュが古い順にソート
      .map(b => b.hash)[0]; // 一番最初に作られた分岐元のブランチを返す
  }

  getCandidateHashes(): string[] {
    const mergedBranches = this.getBranchNames(this._commitNodes[0][0]).filter(
      b => !b.endsWith("/" + this._branchName) && b !== this._branchName,
    );
    // this.getBranchNames(this._commitNodes[0][0])は作業ブランチの
    // 最新コミットを含んでいるブランチ名一覧(作業ブランチと、作業ブランチからさらに分岐しているブランチ、
    // 作業ブランチをすでにマージしているブランチの一覧)
    
    // branch:master
    // *
    // |\
    // | * currentコミット(branch:a)
    // |/ \
    // *   * other(branch:b)
    //
    // この場合、this._commitNodes[0][0]はcurrentコミットが入るので、
    // getBrancheNamesで取得できるブランチ名はmaster, a, b の3つになる
    // this._branchNameであるaブランチと**/aとなるブランチが除かれ、
    // mergedBranchesには['master', 'b']が返る

    return this._commitNodes
      .map(c => c[0])
      .filter(c => {
        const branches = this.getBranchNames(c);
      
        
        const hasCurrent = !!branches.find(b => this._branchName === b);
        // this._commitNodesはcurrentブランチ(this._branchName)上のログに含まれるコミットの一覧なのでhasCurrentは必ずtrueになる?
        
        const others = branches.filter(b => {
          return !(b.endsWith(this._branchName) || (mergedBranches.length && mergedBranches.some(c => b === c)));
        });
        // currentブランチ以外にもコミットを含んでいるブランチ
        
        return hasCurrent && !!others.length;
        // currentブランチに存在して、他のブランチにも存在しているコミットを返す
      });
  }
 
  isReachable(a: string, b: string) {
    const between = this._gitCmdClient.logBetween(a, b).trim();
    // aからは到達できないがbからは到達できるコミットを返す。
    // つまりaのあとbまでの間に追加されたコミットを返す。

    return !between;
    // aのあとにbまでの間に追加されたコミットがなければtrueを返す
  }

  findBaseCommitHash(candidateHashes: string[], branchHash: string): string | undefined {
    const traverseLog = (candidateHash: string): boolean | undefined => {
      if (candidateHash === branchHash) return true;
      return this.isReachable(candidateHash, branchHash);
      // currentブランチに存在して他のブランチにも存在しているコミットと、分岐元のコミットのハッシュの間にコミットがなければtrueを返す
    };
    const target = candidateHashes.find(hash => !!traverseLog(hash));
    return target;
  }

  getBaseCommitHash(): string | null {
    this._branchName = this.getCurrentBranchName();
    this._commitNodes = this.getCommitNodes();
    
    const candidateHashes = this.getCandidateHashes();
    // currentブランチに存在して、他のブランチにも存在しているコミットのハッシュ
    const branchHash = this.getBranchHash();
    if (!branchHash) return null;
    const baseHash = this.findBaseCommitHash(candidateHashes, branchHash);
    if (!baseHash) return null;
    const result = this._gitCmdClient.revParse(baseHash).replace("\n", "");
    return result ? result : null;
  }
}

git-cmd-client.ts

最後にGitCmdClientクラスのコードを見ていきます。 Gitのコマンドを直接呼び出す処理がまとめられています。

コード概要

import { execSync } from "child_process";
import shellEscape from "shell-escape";

export class GitCmdClient {
  private _revParseHash: { [key: string]: string } = {};
  
  // 現在選択しているブランチ名を出力
  currentName() {
    return execSync('git branch | grep "^\\*" | cut -b 3-', { encoding: "utf8" });
  }
  
  // 現在のブランチの最新のコミットを出力
  revParse(currentName: string) {
    if (!this._revParseHash[currentName]) {
      this._revParseHash[currentName] = execSync(`git rev-parse "${currentName}"`, { encoding: "utf8" });
    }
    return this._revParseHash[currentName];
  }

  // ブランチの一覧を出力
  branches() {
    return execSync("git branch -a", { encoding: "utf8" });
  }

  // 引数に渡したハッシュのコミットを含んだブランチの一覧を出力
  containedBranches(hash: string): string {
    return execSync(shellEscape(["git", "branch", "-a", "--contains", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡したハッシュのコミットのコミットされた日時を出力
  logTime(hash: string) {
    return execSync(shellEscape(["git", "log", "--pretty=%ci", "-n", "1", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡した期間のコミットを出力
  logBetween(a: string, b: string) {
    return execSync(shellEscape(["git", "log", "--oneline", `${a}..${b}`]), { encoding: "utf8" });
  }
  
  // コミットハッシュと親のコミットハッシュのペアのリストを出力
  logGraph() {
    return execSync('git log -n 300 --graph --pretty=format:"%h %p"', { encoding: "utf8" });
  }
  
  // 引数に渡した二つのブランチの分岐元となるコミットを出力
  mergeBase(a: string, b: string) {
    return execSync(shellEscape(["git", "merge-base", "-a", a, b]), { encoding: "utf8" });
  }
}

使用しているモジュール 

Node.js child_process

https://nodejs.org/api/child_process.html#child_process_child_process

The child_process module provides the ability to spawn subprocesses in a manner that is similar, but not identical, to popen(3). This capability is primarily provided by the child_process.spawn() function:

サブプロセスを生成する機能を提供するモジュール。

child_process.execSync()

The child_process.execSync() method is generally identical to child_process.exec() with the exception that the method will not return until the child process has fully closed. When a timeout has been encountered and killSignal is sent, the method won't return until the process has completely exited. If the child process intercepts and handles the SIGTERM signal and doesn't exit, the parent process will wait until the child process has exited.

execメソッドを非同期ではなく同期処理で呼び出すメソッド。 Node.jsのメソッドは基本的に非同期処理されるので、同期処理をしたい場合はメソッド名の後ろにSyncとついたメソッドが用意されていることが多い。

child_process.exec()

https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback

Spawns a shell then executes the command within that shell, buffering any generated output. The command string passed to the exec function is processed directly by the shell and special characters (vary based on shell) need to be dealt with accordingly

シェルを生成し、そのシェル内でコマンドを実行して、生成された出力をバッファリングする。

shell-escape

https://github.com/xxorax/node-shell-escape
コマンドを構成する文字列を配列で渡すことで、コマンド実行に適した文字列にエスケープする

// 実行例
var shellescape = require('shell-escape');

var args = ['curl', '-v', '-H', 'Location;', '-H', 'User-Agent: dave#10', 'http://www.daveeddy.com/?name=dave&age=24'];

var escaped = shellescape(args);
console.log(escaped);
//出力結果
curl -v -H 'Location;' -H 'User-Agent: dave#10' 'http://www.daveeddy.com/?name=dave&age=24'

まとめ

コードを見てなんとなく全体像を掴めました。 Nodejsでのコマンドの実行にchild_processモジュールとshell-escapeもモジュールが使われていることがわかったので、今後コマンドラインツールを作るときに活用できそうです。 また、Gitコマンドを直接呼び出すクラスとコミット情報を加工するクラスを分けるといったクラス設計の考え方も具体的な実装から学ぶことができました。

テスト駆動開発 第一部

テスト駆動開発の第一部を読んだのでそのメモ。

TDDのゴール

「動作するきれいなコード」

(以下、本書の引用)

「動作するきれいなコード」。RonJeffriesのこの簡潔な言葉が、テスト駆動開発(TDD)のゴールだ。動作するきれいなコードはあらゆる意味で価値がある。

開発が予測可能になる。 完成したかどうかがわかり、バグが残っているかを心配する必要もない。 コードが伝えようとしていることを余すところなく受け取れる。 最初に思いついたコードを書き殴っただけで終わりなら、再考してより良いコードを書くチャンスは永遠に来ない。 あなたが作るソフトウェアのユーザを快適にする。 チームメイトはあなたを信頼し、あなたもまたチームメイトを信頼する。 書いていて気持ちがいい。

TDDのルール

  • 自動化されたテストが失敗したときのみ、新しいコードを書く。
  • 重複を除去する。

TDDの流れ

  1. まずはテストを1つ書く
  2. すべてのテストを走らせ、新しいテストの失敗を確認する
  3. 小さな変更を行うすべてのテストを走らせ、すべて成功することを確認する
  4. リファクタリングを行って重複を除去する

まずはテストを1つ書く

TDDはテストを書くところから始まる。 テストを書くときに用途にあったインターフェースを考える。そのコンポーネントや関数がどのように呼び出されるのか、どのようなプロパティを受け取りどのような値を返すのか理想的なAPIから考える。

すべてのテストを走らせ、新しいテストの失敗を確認する

テストの内容をプロダクトコードに反映していないので、テストは当然失敗する。

小さな変更を行うすべてのテストを走らせ、すべて成功することを確認する

コードのクオリティは気にせずテストを通すための実装をする。 すばやくテストを通すために以下の2つの書き方を意識する。 仮実装:コードでまずベタ書きの値を使い、実装を進めるに従って、徐々に変数に置き換えていく。 明白な実装:すぐに頭の中の実装をコードに落とす。

リファクタリングを行って重複を除去する

べた書きで値を返してしまっている箇所や重複したコードなどリファクタリングしていく。 すでに自動テストがあるので、安心してコードを修正できる。

はじめは小さいステップで

この流れを小さいステップで繰り返していく。大きな処理は最初から正しいアーキテクチャで書くことは難しく、書き終わったあとに根本から修正することが難しくなる。

ただし、ステップは可能な限り小さくしなくてもいい。これまでの実装から正しい実装を書けると判断できる場合には小さいステップは省いてしまってもいいし、正しい設計が思いつかないのであればまずは小さいステップからはじめてもいい。

(以下、本書の引用)

TDDを行う際には、常にこのような微調整を行う。非常に細かなステップを窮屈に感じるならば、歩幅を大きくする。不安を感じるならば、歩幅を小さくする。TDDとは、あちらへ少し、こちらへ少しといった感じで舵を取るプロセスだ。正しい歩幅などというものは、未来永劫に存在しない。

そのほかTDD中に考えること

多少の回り道もOK

かならずしもTDDの流れに従わなくてもいい。 必要に応じて実装を先に書いたり、言語仕様を確認するためのテストを書いたりしてもいい。

(以下、本書の引用)

この修正をいま行うべきだろうか。それとも待つべきだろうか。教科書的な答えは「いま行っている作業を止めないために、修正は待つべき」となる。ただ、個人的な回答をするなら「ちょっとした割り込みを楽しもう。ただし、ちょっとしたものに限る」と言いたい。そこに「割り込みにさらに割り込むことはしない」というルールも加えておこう(JimCoplienがこのルールを教えてくれた)。

別のコードを修正したくなったとき

プロダクトコードを追記した結果、テストのエラーが出ているが、追記した場所とは異なる箇所でプロダクトコードを変更しなければいけないことがわかったとき、 一度テストのエラーが出ない状態まで変更を巻き戻し、新しくプロダクトコードを変更する箇所のテストを先に書いてから実装の修正を行い、テストが通れば再び取り組んでいた問題に戻る。

(以下、本書の引用)

レッドバーが出ているときに新しくテストを加えるのは避けたいのだが、プロダクトコードを変更しなければならないときが来てしまった。テスト無しでコードを変更するわけにはいかない。手堅い方法は、レッドバーの原因になった変更を巻き戻し、グリーンバーに復帰するというものだ。グリーンバーに復帰したらequalsのテストを書いてから実装の修正を行い、再び取り組んでいた問題に戻る。

実装内部で使うヘルパークラスのテストは書かない

実装のテストが通れば内部のヘルパークラスの動作が正しいことが保証されるのでヘルパークラスのテストを書かなくてもいい場合がある。

(以下、本書の引用)

今回はPairクラスのテストを書かない。なぜかというと、いまはリファクタリングの過程としてPairクラスを抽出している途中だからだ。リファクタリングが完了してすべてのテストが通るようになったら、それでPairクラスも同時にテストされていると見なしてよい。

TDDで作成されたテストはステートメントカバレッジが高くなるがパフォーマンステストなどの代替にはならない

TDDを行うとプロダクトコードの命令文全体のうち、テストを実施した割合は100%に近くなり、システム開発を続けるためには有用なものになる。 ただし、パフォーマンステスト、負荷テスト、ユーザービリティテストの代替になるわけではない。

TypeScriptでUnion型の判別

実際に案件でTypeScriptのリファクタリングを行ったのでざっくりメモ。

もともとのコードは以下のような内容。

type Form = CheckForm | FreeTextForm;

interface BaseForm {
  id: number;
  type: 'checkbox' | 'freeText';
  description: string;
}

interface CheckForm extends BaseForm {
  options: Options[];
}

interface FreeTextForm extends BaseForm {
  maxLength: number;
}

const form: Form = {
  id: 1,
  type: 'checkbox',
  description: 'select only one',
  options: [{key: 1,value: '1'},{key: 2,value: '2'}]
}


function findOptionIndex(form, key) {
  // CheckFormのときだけ処理をしたい
  if (form.type === 'checkbox') {
    const f = form as CheckForm; // form.optionsを参照できるように型を指定している
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

Formは複数の型を結合したUnion型のため、form.optionsを参照しようとするとoptionsをメンバとして持っていない可能性があると怒られてしまう。 そのためas を使ってformの型をCheckFormとして扱うように指定されていた。

しかしこの記述をするにはいちいち変数にオブジェクトを入れ直さないといけないのであまりよろしくない。 なので、まず特定の条件をクリアした場合にどういう型として扱うか指定することができるユーザー定義の型ガードというので書き直してみた。

const isCheckForm = (form: Form): form is CheckForm => {
  return form.type === 'checkbox';
}

function findOptionIndex(form: Form, key: number) {
  if (isCheckForm(form)) {
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

上記のように関数の返り値をarg is TypeNameとすることで、関数の返り値がtrueだった場合に引数にいれたargTypeNameで指定した型のオブジェクトとして扱うことができる。 これで可読性もだいぶ良くなり型の予測もしやすくなった。

ただ一つ問題があり、この方法だとisの後ろにどんな型でも指定できるので、開発者が間違った型を指定するとTypeScriptがエラーを検知できなくなるリスクがある。

Union型はメンバが持つリテラル型のプロパティを元にUnion型のメンバを判別できるらしく、最終的に先ほどのリファクタリングは諦めて、CheckFormFreeTextFormtypeプロパティに文字列を指定することで解決できた。 内容は以下のようになった。

type Form = CheckForm | FreeTextForm;

interface BaseForm {
  id: number;
  type: 'checkbox' | 'freeText';
  description: string;
}

interface CheckForm extends BaseForm {
  type: 'checkbox'; // 判別のためにリテラル型のプロパティを含める
  options: Options[];
}

interface FreeTextForm extends BaseForm {
  type: 'freeText'; // 判別のためにリテラル型のプロパティを含める
  maxLength: number;
}

const form: Form = {
  id: 1,
  type: 'checkbox',
  description: 'select only one',
  options: [{key: 1,value: '1'},{key: 2,value: '2'}]
}


function findOptionIndex(form: Form, key: number) {
  // CheckFormのときだけ処理をしたい
  if (form.type === 'checkbox') { // CheckFormのtypeプロパティと一致しているため、CheckFormと認識される
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

参考

grepコマンド基礎

シェルスクリプトやコマンドを使った開発や作業の効率化に興味があり、 Software Design 2021年6月号に掲載されていた「シェルとコマンドで使う正規表現」という章を読んだのでまとめる。

基本正規表現(BRE)

grepでデフォルトで使用されている正規表現は、基本正規表現(Basic Regular Expressions、BRE)と呼ばれれるもの。 特殊な意味をもつ文字は.[]^$*の6文字だけ。他の記号も使えるがエスケープが必要。

シェルで正規表現を書くときは基本的にシングルクォートで囲む。 そうしないとシェルが「*」や「$」などの文字列を別の意味で解釈してしまう。

$ grep '(.*)' text.txt
(きん)の網はゆらゆらゆれ、泡はつぶつぶ流れました。
『ふうん。しかし、そいつは鳥だよ。かわせみと云うんだ。大丈夫(だいじょうぶ)だ
『いいいい、大丈夫だ。心配するな。そら、樺(かば)の花が流れて来た。ごらん、き
 泡と一緒(いっしょ)に、白い樺の花びらが天井をたくさんすべって来ました。
$ grep -o '(.*)' text.txt
(げんとう)
(ひき)の蟹(かに)
(は)
(はがね)
(てんじょう)を、つぶつぶ暗い泡(あわ)
(つぶ)
$ grep -o '([^(]*)' text.txt
(げんとう)
(ひき)
(かに)
(は)
(はがね)
(てんじょう)
(あわ)
(つぶ)

[^(]でかっこの中のかっこを否定している

拡張正規表現(ERE)

grepには-Eというオプションが用意されている。これは拡張正規表現(Extended Regular Expressions、ERE)というクラスの正規表現を使うためのオプション。

GREとEREを比較

$ cat numbers.txt | grep '^.....-.....-.....$';
$ cat numbers.txt | grep -E '^.{5}-.{5}-.{5}'

BREでは繰り返す数だけ.を入力する必要があるが、EREでは{5}というように任意の文字が5個続くという表現を短い文字数で表現できる。 BREでもエスケープを使えば表現可能。

$ cat numbers.txt | grep '^.\{5\}-.\{5\}-.\{5\}'

{}による個数の指定には、{m,n}という記法もあり、この記法は「m個以上n個以内」という意味になる。

$ cat numbers.txt | grep -E '^.{2,3}-.{,2}-.{2,}$'

ほか、EREでエスケープなしで使える記号には、正規表現をグループ化する「()」や、1文字以上の繰り返しを表す「+」、直前に書いた文字が0文字か1文字存在することを表す「?」、()の中でORを表す「|」がある。

Perl互換正規表現(PCRE)

BRE、EREの違いは記号をエスケープするかどうかぐらいだったが、grepではPerlで使われる強力な正規表現も利用できる。 この正規表現Perl互換性正規表現Perl Compatible Regular Expressions、PCRE)と呼ばれ、同じ名前でライブラリ化されている。 grepでもこのライブラリが使え、-Pオプションで呼び出すことができる。

※ただし、macOSではPCREがサポートされていないので、別途GNU版のgrepをインストールする必要がある。インストールしたコマンドはggrepというコマンド名で登録される。

$ brew install grep
$ ggrep -P '.*' text.txt

RCREを使って正規表現を書き換える

$ cat numbers.txt | grep -Po '^\d{2,3}-\d{2,3}-\d{2,3}$'
$ cat numbers.txt | grep -P '^(\d{2,3})-(?1)-(?1)$'

?番号を使うと、()で囲んだ正規表現を再利用できる。 ?1の場合は一つ目の()の中の正規表現なので、\d{2,3}を再利用している。

おわり