2023/04/04(火)久々にC#でコードを書いてみたらテスタブルなコードを書くのが難しくて諦めた話

更新日:
投稿日:

Windows向けのツールを作ろうというので、昔よく使っていたC#.NETで開発をすることにしたのですが結構苦労があったのでその話を書いていこうと思います。Unit testingを書くのは一旦断念しましたが、暇があれば挑戦したいとは思ってます。

事のいきさつ

WindowsのVSCodeでRemote Development拡張を使うことが多いのですが、リモートエクスプローラーにアイテムが増えてくると移動したいフォルダを探すのが大変になってくるのと、ここに出てないフォルダを開くのが結構手間という問題があり、これを解決できないかということを考えていました。

そこで考えついたのがWSLやSSHのネットワークパス上でExplorerの右クリックメニューを開いたらVSCodeがリモートモードで起動できると良いのでは?という所でした。Windows向けでexeからバイナリ起動するならC#で書くのが楽だろうと考え、C#での開発に着手しました。

疎結合な実装でUnit testingもできたらいいなーと漠然と考えながら開発していたのですが、最後にC#を書いたのは3年ほど前で、C#でモダンな実装をした経験もなかったので結構苦労しました。よく考えたらTypeScriptかJavaScriptくらいでしかまともにやったことがないし、UT自体他言語でもGolangかPHPでしか書いたことがなかったので、C#でこれをやるのは自分の実装技術に対する一種の挑戦みたいなところがありました。結論としてはあっけなく破れたわけですが…。

ツールの要件

  • Explorerの右クリックメニューから起動する
  • 開いたパスに応じてWindowsローカル、SSH、WSLを判定し、適切なモードでVSCodeを起動する
  • パス解決には設定ファイルを用いる
    • Explorerから渡されるパスはリモート環境のパスと差異があるので、そこを解決するためのものです

ツールの設計

やることは設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけです

大まかなフロー

大まかな動作フロー

実行形式

  • コマンドライン引数
    • this.exe <dir path>
  • 設定ファイル形式
    ```json5
    {
    "CodePath": "VSCodeのexeパス",
    // リモートホスト定義。辞書形式
    "Remote": {
    "リモートホスト名": {
    "ExplorerPrefix": "explorerのパスから除外する文字列",
    "AppendPrefix": "explorerのパスに追加する文字列"
    },
    }
    }
    ```

ツールの実装

まずはTDD的にやろうとしてInterfaceを作成し、Classで実装し、ロジック自体は好調に組み上がっていき、テストもすべて通るようになりました。但し実際にexeを蹴ると動かず、テストは通るが動かないゴミが出来上がっていました。(途中で動作確認をしなかったのか?と思うかもしれませんが、実際は完全に動くものを作ってからテスト可能な形に書き換えるという流れで作っていたのでこうなっています)

動かなかった理由は設定ファイルにJSONを採用していて、デシリアライザにSystem.Text.Json.JsonSerializerを使っていたためです。このデシリアライザは標準ではInterfaceに対してJSONをデシリアライズできません(考えてみれば当然ですが)。しかし、テストをするためにはモックをDIしたいので、Interfaceが必要です。

以下は実際に設定を読み込むために実装したClassですが、メンバが全部Interfaceになっているので、標準の状態ではデシリアライズに失敗します。ならばデシリアライザを自作すればいいという話が出てくるのですが、高々設定ファイルを読み込む処理にそこまで情熱を込めるか…?と言うことになり、諦めました。

public class ConfigBase : IConfigBase {
    private IFileInfo? _CodePath;
    private IDictionary<string, IConfigRemote>? _Remote;

    public IFileInfo CodePath {
        get {
            if (this._CodePath == null) {
                throw new Exception("Config Error: Missing CodePath.");
            } else if (this._CodePath == null) {
                throw new Exception("Config Error: CodePath is empty. Set the code.exe path in this field.");
            } else if (!this._CodePath.Exists) {
                throw new Exception("Config Error: CodePath is Not exists.");
            } else {
                return this._CodePath;
            }
        }
        set { this._CodePath = value; }
    }

    public IDictionary<string, IConfigRemote> Remote {
        get {
            if (this._Remote == null) {
                throw new Exception("Config Error: Missing Remote.");
            } else if (this._Remote.Count == 0) {
                throw new Exception("Config Error: Remote is empty. Set the remote infomation in this field.");
            } else {
                return this._Remote;
            }
        }
        set { this._Remote = value; }
    }
}

そもそもこのツールは「設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけ」のツールです。たったそれだけのツールに入れる仕組みにしては大げさすぎると感じました。

テストをするためにInterfaceを作ったり、本実装とモック用のClassを作る程度まではまだ容認できるのですが、カスタムデシリアライザを作ると、今度はそれのテストも必要になってきます。どう考えてもしんどい。

因みにこのコード、仮にInterfaceをやめても文字列をFileInfoに組み替えるためのカスタムデシリアライザの実装が必要で結構頭が痛くなります…。多分そこはFile.existsをラップしたクラスをDIしてやるのが無難な気がしますね。

この辺はTypeScriptだとimportの中身をJestで書き換えたら終わりなのであんま考えなくていいのは楽ですが、C#だと厳しいなと感じました。

C#でテストを書いていて思ったこと

クラスベースで実装していくとメソッド単位でのテストがしづらく、テストがコケても原因が把握しづらいというのを一つ課題感として覚えました。TSでなんちゃって関数型開発をしていれば関数のUTを書けば関数の挙動を把握できますが、Classではそうも行きません。

仮に1つのpublic methodが5つのprivate methodを呼び出していて、private methodの裏ではprivate propertyが複雑な依存を持っていたとしたらどうでしょうか?
正直デバッグをかけないとどこで何がコケたか特定できないと思います。そんな実装にするのが悪いといえばそうですが、でもクラスってそういうものじゃないですっけ…?お互いに関連性がなくていいならもうそれ関数で良くないですか?って思いました。

勿論、全部静的メソッドにしてClassそのものは単なるエンティティにするのも選択肢の一つだとは思います。しかしC#でそこまでやるか…?という疑念が個人的にあるのと、処理を繋げたテストをUTとして書く方法がなくなると思います。例えばインスタンスメソッドならモックをDIすることでメソッドが呼ばれた事や、戻り値に対する分岐を確認できますが、静的メソッドでこれをやるのは難しいと思います。

Classの単体テストは関数と状態が密結合したテストになってしまうので、いまいち微妙だなと思ったのが今回思ったことでした。

2023/03/23(木)Visual Studio 2022 Communityで手軽にC#.NETのコードカバレッジを見る方法

更新日:
投稿日:

TypeScriptやJavaScriptだとコマンド一発で見れるコードカバレッジだが、C#.NETの開発ではちょっと手こずったので導入方法を記録しておく。

確認環境

Env Ver
Visual Studio 2022 Community Version 17.5.1
.NET Framework 6.0

導入手順

  1. C#.NETで開発用のプロジェクトを作成
  2. 作成したプロジェクトのソリューションにxUnitテストプロジェクトを追加
  3. 何かしらの実装と、それに対するテスト実装を作成
  4. Fine Code CoverageをVisual Studioにインストール
  5. ソリューションエクスプローラからxUnitテストプロジェクトを右クリックしてテストの実行
    1. xUnitテストプロジェクトを右クリックしてテストの実行
  6. 下部トレイにあるFine Code Coverageタブを選択
    1. Fine Code Coverageタブを選択
    2. 表示されていない場合は表示 → その他のウィンドウ → Fine Code Coverageで表示出来る
      1. 表示されていない場合は表示 → その他のウィンドウ → Fine Code Coverageで表示出来る
  7. コードカバレッジが表示される

疑問

Visual Studio 2022にもなって標準でコードカバレッジも取れないの?

今どきそんな事あるか?思って調べてみたところ、どうやらEnterpriseであればコードカバレッジを取る機能がついている模様。

Community版では標準でカバレッジが取れない

まぁ無料版だから仕方ないねということで諦めるしかないでしょう。Professional版にもないけど…w。VisualStudioの便利機能は以前から有料機能に組み込まれる傾向があるので仕方がない気もしますが、取り敢えず今回は有志が便利なツールを作っていてくれて助かりました。

因みにコードカバレッジ自体は無料版でも取れます。ただこれはXMLのカバレッジレポートを吐くだけなので、可視化するには別途ここからHTMLを生成する必要があり面倒なので、なんかもっと楽な方法はないかなと思って見つけたのが今回のFine Code Coverageでした。

しかしFine Code Coverage便利なのにそこまで利用されているように見えないのは、やはりテストに関心がある人が少ないのか、公式にあるカバレッジHTMLを吐く方法で納得しているのか、その辺りが気になりました。自前でFine Code Coverageの様な物を作っている人もいるでしょうが、それは少数派だと思いますし。

この方法で取れるカバレッジは正しいものか?

GitHubのREADMEを読む限り、組み込みのカバレッジレポーターのAPIを叩いて取ってきたものを表示しているだけに見えるので、恐らく正しいデータが出ているのではないかと思います。

少なくとも単純なコードのUnit testingを書いた感じではテストケースを増減することでカバレッジも連動して増減していたのと、組み込みのレポーターがそのくらいはやってくれている筈で、これはその内容を表示しているだけなので恐らく大丈夫なのではないかと考えています。

2023/02/27(月)TypeScript + SWC + Jestでexport方法別のspyOnするパターンを書いた

本記事ではTypeScriptとSWC、Jestの組み合わせ環境でjest.spyOnを使う方法をexportパターン別に書いていきます。また、jest.spyOn対象の実装の詳細はテストせず、モック化したインターフェースのテストのみを行う前提で書いています。

確認環境

Env Ver
Node.js 18.14.0
typescript 4.9.5
@swc/cli 0.1.62
@swc/core 1.3.36
@swc/jest 0.2.24
jest 29.4.3

サンプルコード

全編は以下にあります
jest.spyOn pattern example for SWC + TypeScript

親関数から子関数を呼び出すパターンで、子関数のjest.spyOnをするシチュエーションで考えます。

default export編

以下のdefaultエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import child from 'src/default/child';

const parent = (param: string) => {
  return child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

この形式の場合import側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/default/child';
import { Parent } from 'src/default/parent';

jest.mock('src/default/child');

describe('default', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'default');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

named export編

以下の名前付きエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import { child } from 'src/named-export/child';

export const parent = (param: string) => {
  return child(param);
};
parent.spec.ts

default exportと同じくimport側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/named-export/child';
import { parent } from 'src/named-export/parent';

jest.mock('src/named-export/child');

describe('function', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'child');
    parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

namespace export編

やり方はnamed export編と同じです。

child.ts
export namespace Child {
  export const child = (param: string) => {
    return param;
  };
}
parent.ts
import { Child } from 'src/namespace/child';

export namespace Parent {
  export const parent = (param: string) => {
    return Child.child(param);
  };
}
parent.spec.ts

namespaceの場合、トランスパイル後にクロージャになり、書き換え以前に関数にアクセスできなくなるため、ファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import { Child } from 'src/namespace/child';
import { Parent } from 'src/namespace/parent';

jest.mock('src/namespace/child');

describe('namespace', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

module export編

これだけやり方が変わります。

child.ts
const child = (param: string) => {
  return param;
};

export const Child = {
  child,
};
parent.ts
import { Child } from 'src/module/child';

const parent = (param: string) => {
  return Child.child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

このケースの場合オブジェクトをexportしていて書き換えができるため、モック化せずにそのままjest.spyOnすることが出来ます。
このやり方であれば、ファイルのモックはしないため、同じファイルにある関数をjest.spyOnする事ができますが、裏を返せばオブジェクトの書き換えが可能であるため、実装方法によっては予期せぬ不具合が生まれる可能性があり、危険です。
何かしらの悪意ある攻撃を受けた場合にオブジェクトがすり替わるなどすると致命的だと思います。

import { Child } from 'src/module/child';
import { Parent } from 'src/module/parent';

describe('module', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

おまけ:それぞれのトランスパイル結果

.spec.jsは省いてます

default export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'default', {
  enumerable: true,
  get: () => _default,
});
const child = (param) => {
  return param;
};
const _default = child;
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = /*#__PURE__*/ _interopRequireDefault(require('./child'));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule
    ? obj
    : {
        default: obj,
      };
}
const parent = (param) => {
  return (0, _child.default)(param);
};
const Parent = {
  parent,
};

named export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'parent', {
  enumerable: true,
  get: () => parent,
});
const _child = require('./child');
const parent = (param) => {
  return (0, _child.child)(param);
};

namespace export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
var Parent;
(function (Parent) {
  var parent = (Parent.parent = (param) => {
    return _child.Child.child(param);
  });
})(Parent || (Parent = {}));

module export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Child', {
  enumerable: true,
  get: () => Child,
});
const child = (param) => {
  return param;
};
const Child = {
  child,
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
const parent = (param) => {
  return _child.Child.child(param);
};
const Parent = {
  parent,
};

この記事を書いた切っ掛け

一言でいうとTSC(TypeScript Compiler) からSWCに移行した際にjest.spyOnを使ったら、上手く使えずにハマったためです。

まずTypeScript + JestではTypeScriptをCJS(CommonJS) にトランスパイルするためjest.spyOnが有効でした。しかし、SWCはTypeScriptをESM(ES Modules) にトランスパイルします。ESMではjest.spyOnが有効になりません。

これはCJSではexportしたObjectの書き換えが可能なのに対し、ESMでは出来ないためです。(jest.spyOnは実行時にオブジェクトを書き換えることで動いています)

この対策としてファイルそのものをモックに置き換えることで、jestの管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)

2022/06/30(木)Next.js + Jestでコンポーネントの分岐出力結果をテストする

更新日:
投稿日:

条件分岐でコンポーネントの出し分けをしている時に、意図したとおりにコンポーネントが出ているかどうかをテストする方法。

UIロジックのリグレッションテストで使え、分岐結果の出力を見てるだけなのでテストとして壊れづらく、運用しやすいのではないかと考えている。

テスト対象の実装コード

JSXが分岐するコード

type BaseProps = {
  id: string;
};

type SwitchExampleProps = BaseProps & {
  display: 'Foo' | 'Bar';
};

export const Foo = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Foo</p>
    </div>
  );
};

export const Bar = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Bar</p>
    </div>
  );
};

export const SwitchExample = (props: SwitchExampleProps) => {
  if (props.display === 'Foo') {
    return <Foo id={props.id} />;
  } else {
    return <Bar id={props.id} />;
  }
};

2025年版のテストコード

TestRendererが非推奨になったため、その対応。

確認環境

Env Ver
@swc/core 1.11.13
@swc/jest 0.2.37
jest 29.7.0
next 15.2.4
react 18.3.1
react-dom 18.3.1
react-test-renderer 未使用
typescript 5.8.2

テストコード

コンポーネント呼び出しと、コンポーネントの中身べた書きの二つを書いて、その二つのHTMLが等価であるかどうかを見ている。

本稿ではコールバック関数の中身までは見ていないが、そちらはロジックテストでjest.fn()を差し込んで確認すればよいと思う。

import { render } from '@testing-library/react';
import { Bar, Foo, SwitchExample } from './SwitchExample';
import { JSX } from 'react';

type TestCase = {
  name: string;
  param: Parameters<typeof SwitchExample>[0];
  actual: JSX.Element;
};

describe('SwitchExample', () => {
  const testCaseItems: TestCase[] = [
    {
      name: 'Foo',
      param: {
        id: 'hoge',
        display: 'Foo'
      },
      actual: <Foo id={'hoge'} />
    },
    {
      name: 'Bar',
      param: {
        id: 'piyo',
        display: 'Bar'
      },
      actual: <Bar id={'piyo'} />
    }
  ];

  testCaseItems.forEach((item) => {
    it(`switched condition ${item.name}`, () => {
      const { container: result } = render(
        <SwitchExample id={item.param.id} display={item.param.display} />
      );
      const { container: actual } = render(item.actual);

      expect(result.innerHTML).toStrictEqual(actual.innerHTML);
    });
  });
});

2022年版のテストコード

確認環境

Env Ver
@swc/core 1.2.133
@swc/jest 0.2.17
jest 27.4.7
next 12.0.8
react 17.0.2
react-dom 17.0.2
react-test-renderer 17.0.2
typescript 4.5.4

テストコード

react-testing-libraryの.toHaveAttribute().toHaveDisplayValue()を書き連ねるより圧倒的に楽で保守性も良いと思う

import TestRenderer from 'react-test-renderer';
import { Bar, Foo, SwitchExample } from './SwitchExample';

type TestCase = {
  name: string;
  param: Parameters<typeof SwitchExample>[0];
  actual: JSX.Element;
};

describe('SwitchExample', () => {
  const testCaseItems: TestCase[] = [
    {
      name: 'Foo',
      param: {
        id: 'hoge',
        display: 'Foo',
      },
      actual: <Foo id={'hoge'} />,
    },
    {
      name: 'Bar',
      param: {
        id: 'piyo',
        display: 'Bar',
      },
      actual: <Bar id={'piyo'} />,
    },
  ];

  testCaseItems.forEach((item) => {
    // eslint-disable-next-line jest/valid-title
    it(`switched condition ${item.name}`, () => {
      const result = TestRenderer.create(
        <SwitchExample id={item.param.id} display={item.param.display} />
      );
      const actual = TestRenderer.create(<>{item.actual}</>);

      expect(result.toJSON()).toStrictEqual(actual.toJSON());
    });
  });
});

参考記事

2021/10/06(水)Next.js + TypeScriptでuseRouter()をモックする

更新日:
投稿日:

確認環境

Env Ver
next 11.1.2
typescript 4.3.4

サンプルコード

__mocks__/next/router.ts

export const useRouter = () => {
  return {
    route: '/',
    pathname: '',
    query: '',
    asPath: '',
    push: jest.fn(),
    replace: jest.fn(),
    events: {
      on: jest.fn(),
      off: jest.fn(),
    },
    beforePopState: jest.fn(() => null),
    prefetch: jest.fn(() => null),
  };
};