お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
言語::C#OS::Linux::Ubuntuジャンル::セットアップ

インストール

https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script

wget https://dot.net/v1/dotnet-install.sh
chmod 755 dotnet-install.sh
./dotnet-install.sh
echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.zshrc
echo 'export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools' >> ~/.zshrc

Hello world

https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet

dotnet new console -o myapp
dotnet build myapp/myapp.csproj
./myapp/bin/Debug/net6.0/myapp
投稿日:
言語::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の単体テストは関数と状態が密結合したテストになってしまうので、いまいち微妙だなと思ったのが今回思ったことでした。

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. コードカバレッジが表示される
    1. vs2022-easy-code-coverage.jpg

疑問

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

投稿日:
言語::C#

なぜこんな実装が必要なのかさっぱり不明ですが、必要になって困ったのでメモ。

DataTableを内部結合したList<Dictionary>をLINQで作る

var loc = new DataTable();
var pep = new DataTable();
loc.Columns.Add("ID");
loc.Columns.Add("LOCATION");
loc.Columns.Add("DELETE_FLG");
loc.Rows.Add("100", "US", "0");
loc.Rows.Add("101", "AU", "1");
loc.Rows.Add("102", "JP", "1");
loc.Rows.Add("103", "CH", "1");
loc.Rows.Add("104", "UK", "1");
loc.Rows.Add("105", "RU", "1");

pep.Columns.Add("LOCATION_ID");
pep.Columns.Add("NAME");
pep.Columns.Add("DELETE_FLG");
pep.Rows.Add("100", "TAKANA", "0");
pep.Rows.Add("101", "YAKATA", "1");
pep.Rows.Add("100", "WADA", "1");
pep.Rows.Add("101", "SHINODA", "1");
pep.Rows.Add("100", "HARUKA", "1");
pep.Rows.Add("105", "SAIONJI", "0");


var result = (
    from l in loc.AsEnumerable()
    join p in pep.AsEnumerable()
    on new {
        a = l.Field<string>("ID"),
        b = l.Field<string>("DELETE_FLG")
    } equals new {
        a = p.Field<string>("LOCATION_ID"),
        b = p.Field<string>("DELETE_FLG")
    }
    select new Dictionary<string, string> {
        {
            "LOCATION_ID",
            l.Field<string>("ID")
        },
        {
            "NAME",
            p.Field<string>("NAME")
        }
    }
).ToList();

List<Dictionary>DataTableを外部結合してList<Dictionary>を操作する

var location = new List<Dictionary<string, string>>();
var fruits = new DataTable();

location.Add(
    new Dictionary<string, string> {
        {
            "ID",
            "001"
        },
        {
            "NAME",
            "Hokkaido"
        }
    }
);
location.Add(
    new Dictionary<string, string> {
        {
            "ID",
            "002"
        },
        {
            "NAME",
            "Aomori"
        }
    }
);
location.Add(
    new Dictionary<string, string> {
        {
            "ID",
            "003"
        },
        {
            "NAME",
            "Iwate"
        }
    }
);

fruits.Columns.Add("LOCATION_ID");
fruits.Columns.Add("NAME");
fruits.Columns.Add("FRUITS_FLG");
fruits.Rows.Add("001", "ikura", "1");
fruits.Rows.Add("001", "uni", "1");
fruits.Rows.Add("002", "ringo", "0");

// dummyは処置待ち用のダミー。locationの操作が目的
var dummy = location
    .Join(
        fruits.AsEnumerable()
        , loc => loc["ID"]
        , fruit => fruit.Field<string>("LOCATION_ID")
        , (loc, fruit) => new { lo = loc, fru = fruit }
    )
    .Select(
        joined => {
            joined.lo["FRUITS_NAME"] = joined.fru.Field<string>("NAME");
            joined.lo["FRUITS_FLG"] = joined.fru.Field<string>("FRUITS_FLG");
            return new Dictionary<string, string>();
        }
    )
    .ToList();