C#
.NETCore
プロを目指す人のためのRuby入門

【C#】RGB変換プログラムでテスト駆動開発

チェリー本:cherries:ことプロを目指す人のためのRuby入門に『RGB変換プログラム』という例題があります。
そちらで自分なりに C# + VSCode + dotnet CLI でテスト駆動開発したのでまとめました。。

プロを目指す人のためのRuby入門は Ruby プログラマーでなくても勉強になる凄い本だと思います。。

環境構築

ライブラリとテストのプロジェクトを構築します。

PS > dotnet --version
dotnet 2.1.300

PS > mkdir tdd-csharp
PS > cd tdd-csharp  # root
PS > dotnet new sln
PS > dotnet new classlib -o ./tdd-csharp.Lib
PS > dotnet new xunit -o ./tdd-csharp.Test
PS > dotnet sln add ./tdd-csharp.Lib ./tdd-csharp.Test
PS > dotnet add ./tdd-csharp.Test reference ./tdd-csharp.Lib

visual studio codetdd-csharpディレクトリを開く。

:warning:Required assets to build and debug are missing from 'tdd-csharp'. Add them?

と聞かれるので Yes を選択。

正しく作成できたか.csprojを確認

tdd-csharp.Lib.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <RootNamespace>tdd_csharp.Lib</RootNamespace>
  </PropertyGroup>
</Project>
tdd-csharp.Test.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <RootNamespace>tdd_csharp.Test</RootNamespace>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
    <PackageReference Include="xunit" Version="2.3.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
    <DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\tdd-csharp.Lib\tdd-csharp.Lib.csproj" />
  </ItemGroup>
</Project>

Rgb -> カラーコード(16進数)のメソッドを作成

tdd-csharp.Lib/class1.csを削除して、ColorConveter.csを作成する。
メソッドは未実装例外をスローする仮実装。

tdd_csharp.Lib/ColorConverter.cs
using System;

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) =>
            throw new NotImplementedException(nameof(ToHex));
    }
}

tdd-csharp.Test/UnitTest1.csを削除する。
先に失敗するテストを書く。

tdd_csharp.Test/ColorConverterTest.cs
using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {
        [Fact]
        public void Test_ToHex() {
            // Arrange
            var sut = new ColorConverter(); // sut means `System under test`
            // Act
            var colorcode = sut.ToHex(red: 0, green: 0, blue: 0);
            // Assert
            Assert.Equal("#000000", colorcode);

        }
    }
}

テストを実行する。
未実装例外で失敗するが、プロジェクト参照ができてビルドが通ることは確認できる。

PS > dotnet test ./tdd-csharp.Test
ビルドが開始されました。しばらくお待ちください...
ビルドが完了しました。

tdd-csharp\tdd-csharp.Test\bin\Debug\netcoreapp2.1\tdd-csharp.Test.dll(.NETCoreApp,Version=v2.1) のテスト実行
Microsoft (R) Test Execution Command Line Tool Version 15.7.0
Copyright (c) Microsoft Corporation.  All rights reserved.

テスト実行を開始しています。お待ちください...
[xUnit.net 00:00:00.5976127]     tdd_csharp.Test.ColorConverterTest.Test_ToHex [FAIL]
失敗   tdd_csharp.Test.ColorConverterTest.Test_ToHex
エラー メッセージ:
 System.NotImplementedException : ToHex
スタック トレース:
   at tdd_csharp.Lib.ColorConverter.ToHex(Int32 red, Int32 green, Int32 blue) in tdd-csharp\tdd-csharp.Lib\ColorConverter.cs:line 6
   at tdd_csharp.Test.ColorConverterTest.Test_ToHex() in tdd-csharp\tdd-csharp.Test\ColorConverterTest.cs:line 11

テストの合計数: 1。成功: 0。失敗:1。スキップ: 0。
テストの実行に失敗しました。
テスト実行時間: 1.2166 秒

期待値をそのまま返してテストが通るコードにする。

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) => "#000000";  // Fake It
    }
}

今度は成功する。

PS RgbConverter> dotnet test ./tdd-csharp.Test

# 中略

テスト実行を開始しています。お待ちください...

テストの合計数: 1。成功: 1。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.1687 秒

検証コードを追加する。
複数ケースでテストするから XUnit のパラメータ付きテスト形式にする。

using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {
        [Theory]
        [InlineData("#000000", 0, 0, 0)]
        [InlineData("#ffffff", 255, 255, 255)]
        public void Test_ToHex(string expected, int red, int green, int blue) {
            // Arrange
            var sut = new ColorConverter(); // sut means `System under test`
            // Act
            var colorcode = sut.ToHex(red, green, blue);
            // Assert
            Assert.Equal(expected, colorcode);
        }
    }
}

テストを実行する。

PS > dotnet test ./tdd-csharp.Test

# 中略

[xUnit.net 00:00:00.5045876]     tdd_csharp.Test.ColorConverterTest.Test_ToHex(expected: "#ffffff", red: 255, green: 255, blue: 255) [FAIL]
失敗   tdd_csharp.Test.ColorConverterTest.Test_ToHex(expected: "#ffffff", red: 255, green: 255, blue: 255)
エラー メッセージ:
 Assert.Equal() Failure
           ↓ (pos 1)
Expected: #ffffff
Actual:   #000000
           ↑ (pos 1)
スタック トレース:
   at tdd_csharp.Test.ColorConverterTest.Test_ToHex(String expected, Int32 red, Int32 green, Int32 blue) in tdd-csharp\tdd-csharp.Test\ColorConverterTest.cs:line 15

テストの合計数: 2。成功: 1。失敗:1。スキップ: 0。
テストの実行に失敗しました。
テスト実行時間: 1.0961 秒

固定値を返しているから失敗する。

ColorConverter.ToHexをちゃんと実装する。

intを16進数stringにするには以下の方法がある。

> int n = 123;

> Convert.ToString(n, 16)
"7b"

> String.Format("{0:X}", n)
"7B"

> String.Format("{0:x}", n)
"7b"

> $"{n:X}"
"7B"

> $"{n:x}"
"7b"

短い$""文字列補間を使うことにする。

※簡単な C# コードを検証するときはcsi.exeとかTry.Netが便利。
Try.Netはブラウザで実行できる C# のコードランナー。

数値を0埋めのstringにする場合は書式設定でできる。

> $"{3:d2}"
"03"

> $"{3:00}"
"03"

> 3.ToString("00")
"03"

ただ、今回は対象が16進数stringなので数値->文字列の書式設定ができない。

なので2文字になるまで左側に'0'埋めはstring.PadLeftでやる。

> "3".PadLeft(2, '0')
"03"

> "12".PadLeft(2, '0')
"12"

ColorConverter.ToHex()メソッドはこうなる。

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) =>
            "#" +
            red.ToHex().PadLeft(2, '0') +
            green.ToHex().PadLeft(2, '0') +
            blue.ToHex().PadLeft(2, '0');
    }
}

テストを通す。

PS > dotnet test ./tdd-cshapr.Test
# 中略
テストの合計数: 2。成功: 2。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.1976 秒

さらにテストケースを追加する。

using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {
        [Theory]
        [InlineData("#000000", 0, 0, 0)]
        [InlineData("#ffffff", 255, 255, 255)]
+       [InlineData("#043c78", 4, 60, 120)]
        public void Test_ToHex(string expected, int red, int green, int blue) {

3つ目のテストケースも通る。

PS > dotnet test ./tdd-cshapr.Test
# 中略
テストの合計数: 3。成功: 3。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.1809 秒

ToHexメソッドをリファクタリングする

.PadLeft(2, '0')が3回登場している。
これでは何か変更があったときに3か所も直す必要がある。
配列に入れて繰り返し処理にする。

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) {
            string hex = "#";
            foreach (int n in new int[] { red, green, blue }) {
                hex += $"{n:x}".PadLeft(2, '0');
            }
            return hex;
        }
    }
}

メソッドが壊れていないかテストする

PS > dotnet test ./tdd-cshapr.Test
# 中略
テストの合計数: 3。成功: 3。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.0885 秒

foreachが使えるということは LINQ が使える。
Enumerable.Aggregateを使うともっと短くできる。
整数の配列new int[]も匿名型の配列new[]にできる。型は推論してくれる。

using System.Linq;

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) =>
            new [] { red, green, blue }.Aggregate("#", (hex, n) =>
                hex + $"{n:x}".PadLeft(2, '0'));
    }
}

デグレしていないか確認する。

PS > dotnet test ./tdd-cshapr.Test
# 中略
テストの合計数: 3。成功: 3。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.4149 秒

これでリファクタリングは終了。

カラーコード(16進数) -> Rgb のメソッドを作成

まず失敗するテストコードから。

ColorConverter.cs を開き未実装例外をスローするtoRgbを定義。

using System;
using System.Linq;

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) =>
            new [] { red, green, blue }.Aggregate("#", (hex, n) =>
                hex + $"{n:x}".PadLeft(2, '0'));

        // ↓追加
        public(int r, int g, int b) ToRgb(String hex) =>
            throw new NotImplementedException(nameof(ToRgb));
    }
}

ColorConverterTest.csを開きテストを追加する。

using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {

        // 略

        [Fact]
        public void Test_ToRgb() {
            // Arenge
            var sut = new ColorConverter();
            // Act
            var rgb = sut.ToRgb("#000000");
            // Assert
            Assert.True((0, 0, 0) == rgb);
        }
    }
}

タプル同士を ==!= 演算子で比較できるようになったのは C# 7.3 からなので設定する。
*.csproj<LangVersion>7.3</LangVersion>もしくはを<LangVersion>latest</LangVersion>追加する。

tdd-csharp.Lib.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <RootNamespace>tdd_csharp.Lib</RootNamespace>
+   <LangVersion>7.3</LangVersion>
  </PropertyGroup>
</Project>
tdd-csharp.Test.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <RootNamespace>tdd_csharp.Test</RootNamespace>
    <IsPackable>false</IsPackable>
+   <LangVersion>7.3</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
    <PackageReference Include="xunit" Version="2.3.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
    <DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\tdd-csharp.Lib\tdd-csharp.Lib.csproj" />
  </ItemGroup>
</Project>

Ctrl + ,Visual Studio Code の設定を開く。
以下の設定を追加。

  "omnisharp.path": "latest",

ビルドを通す。

PS > dotnet build

ominisharp が最新バージョンになっていない場合、ダウンロードとインストールされる。
うまくいかない場合、一度 Visual Stuidio Code を閉じてからもう一度行うとうまくいく。

ビルドが通ったので、テストする。

PS > dotnet test ./tdd-csharp.Test
# 略
エラー メッセージ:
 System.NotImplementedException : ToRgb
# 略
テストの合計数: 4。成功: 3。失敗:1。スキップ: 0。
テストの実行に失敗しました。
テスト実行時間: 2.4092 秒

未実装例外で失敗する。
テストをパスする仮実装をする。

using System;
using System.Linq;

namespace tdd_csharp.Lib {
    public class ColorConverter {
        // 略

        public (int r, int g, int b) ToRgb(String hex) => (0, 0, 0);
    }
}

仮実装がテストをパスすることを確認。

PS > dotnet test ./tdd-csharp.Test
# 略
テストの合計数: 4。成功: 4。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.3565 秒

パラメータ付きテストに書き換えて、2つ目のテストケースを追加する。

using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {

        // 略

        [Theory]
        [InlineData(0, 0, 0, "#000000")]
        [InlineData(255, 255, 255, "#ffffff")]
        public void Test_ToRgb(int r, int g, int b, string hex) {
            // Arenge
            var sut = new ColorConverter();
            var expected = (r, g, b);
            // Act
            var rgb = sut.ToRgb(hex);
            // Assert
            Assert.True(expected == rgb);
        }
    }
}

InlineData属性引数に直接タプルを渡したかったけど、
An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type [tdd-csharp.Test]
と怒られるので上記のようになった。

メソッドの引数に直接タプル書くのはできるけどInlineDataにはダメだった。

// こういうのは可能
(int x, string y) GetValueTuple((int i, string s) vt) => vt;
var (i, s) = GetValueTuple((1, "A"));
Console.WriteLine($"{i} {s}"); //=> "1 A"

テストを実行する。

PS > dotnet test ./tdd-csharp.Test

テストの合計数: 5。成功: 4。失敗:1。スキップ: 0。
テストの実行に失敗しました。
テスト実行時間: 1.7179 秒

固定値を返しているためテストが失敗する。
タプル同士の比較はメンバーごとの ==&& で繋いだものに展開される

ToRgbの実装に入る。

16進数stringを数値に変換する方法

> string hex = "8e";

> Convert.ToInt32(hex, 16)
142

> int.Parse(hex, System.Globalization.NumberStyles.HexNumber)
142

/* TryParse で変換できるか確かめることも可能 */
> bool can = int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null,  out int n);

> can
True

> n
142

簡単なConvert.ToInt32を使う。

ToRgbの実装は以下のようになる。

using System;
using System.Linq;

namespace tdd_csharp.Lib {
    public class ColorConverter {

        // 略

        public(int r, int g, int b) ToRgb(String hex) {
            string r = hex.Substring(1, 2);
            string g = hex.Substring(3, 2);
            string b = hex.Substring(5, 2);
            var rgb = new[] { r, g, b }
                .Select(s => Convert.ToInt32(s, 16))
                .ToList();
            return (rgb[0], rgb[1], rgb[2]);
        }
    }
}

テストを実行する。

PS > dotnet test ./tdd-csharp.Test
# 略
テストの合計数: 5。成功: 5。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.6214 秒

ちゃんとパスすることを確認。
さらにテストケースを追加する。

using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {

        // 略

        [Theory]
        [InlineData(0, 0, 0, "#000000")]
        [InlineData(255, 255, 255, "#ffffff")]
+       [InlineData(4, 60, 120, "#043c78")]
        public void Test_ToRgb(int r, int g, int b, string hex) {

3つ目のテストケースも無事パス。

PS > dotnet test ./tdd-csharp.Test
# 略
テストの合計数: 6。成功: 6。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.7007 秒

ToRgbをリファクタリングする

カンマで区切ることで複数の変数を同時に宣言できる。

using System;
using System.Linq;

namespace tdd_csharp.Lib {
    public class ColorConverter {

       // 略

       public (int r, int g, int b) ToRgb(String hex) {
            string
                r = hex.Substring(1, 2),
                g = hex.Substring(3, 2),
                b = hex.Substring(5, 2);
            var rgb = new [] { r, g, b }
                .Select(s => Convert.ToInt32(s, 16))
                .ToList();
            return (rgb[0], rgb[1], rgb[2]);
        }
    }
}

正規表現を使えばSubstringを3回書かなくても1度で取得できる。
\wは単語に使用される正規表現。アルファベット、数字、アンダーバー(_)、ひらがな、カタカナ、漢字などにマッチする。

> Regex.Match("#%r&", @"\w").Value
"r"

2文字ずつ取得するので以下のようになる。

Regex.Matchesはマッチした文字列をMatchCollectionで返す。
MatchCollectionIEnumerable<T>を実装していないので.Cast<Match>()している。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace tdd_csharp.Lib {
    public class ColorConverter {

        // 略

        public(int r, int g, int b) ToRgb(String hex) {
            var rgb = Regex.Matches(hex, @"\w\w").Cast<Match>()
                .Select(m => Convert.ToInt32(m.Value, 16))
                .ToList();
            return (rgb[0], rgb[1], rgb[2]);
        }
    }
}

テストをパスすることを確認しリファクタリングを終了。

PS > dotnet test ./tdd-csharp.Test
# 略
テストの合計数: 6。成功: 6。失敗:0。スキップ: 0。
テストの実行に成功しました。
テスト実行時間: 1.4715 秒

まとめ

ユニットテストの AAA

  • Arrange … テストの事前条件のセットアップ
  • Act … テスト対象となるアクションの実行
  • Assert … 振る舞いが期待通りであることの検証

XUnit のテストメソッドの属性

  • [Fact] … テスト ランナーによって実行されるテストメソッド
  • [Theory] … 同じコードを実行するものの、異なる入力引数が含まれる一連のテスト
  • [InlineData][Theory]の入力の値を指定

その他テスト用語

  • Fake it … 仮実装
  • SUT(System Under Test) … テスト対象のクラス

テスト駆動開発のサイクル

Red -> Green -> Refactor

  • Red … SUT の期待される振る舞いターゲットとした失敗するテストを書く
  • Green … テストを成功させる最小限の実装を SUT に追加する
  • Refactor … SUT の設計や全体の設計を向上させるためのリファクタリングを行う

最終的なソースコード

tdd-csharp/tdd-csharp.Lib/ColorConverter.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace tdd_csharp.Lib {
    public class ColorConverter {
        public string ToHex(int red, int green, int blue) =>
            new [] { red, green, blue }.Aggregate("#", (hex, n) =>
                hex + $"{n:x}".PadLeft(2, '0'));

        public(int r, int g, int b) ToRgb(String hex) {
            var rgb = Regex.Matches(hex, @"\w\w").Cast<Match>()
                .Select(m => Convert.ToInt32(m.Value, 16))
                .ToList();
            return (rgb[0], rgb[1], rgb[2]);
        }
    }
}
tdd-csharp/tdd-csharp.Test/ColorConverterTest.cs
using tdd_csharp.Lib;
using Xunit;

namespace tdd_csharp.Test {
    public class ColorConverterTest {
        [Theory]
        [InlineData("#000000", 0, 0, 0)]
        [InlineData("#ffffff", 255, 255, 255)]
        [InlineData("#043c78", 4, 60, 120)]
        public void Test_ToHex(string expected, int red, int green, int blue) {
            // Arrange
            var sut = new ColorConverter(); // sut means `System under test`
            // Act
            var actual = sut.ToHex(red, green, blue);
            // Assert
            Assert.Equal(expected, actual);
        }

        [Theory]
        [InlineData(0, 0, 0, "#000000")]
        [InlineData(255, 255, 255, "#ffffff")]
        [InlineData(4, 60, 120, "#043c78")]
        public void Test_ToRgb(int r, int g, int b, string hex) {
            // Arenge
            var expected = (r, g, b);
            var sut = new ColorConverter();
            // Act
            var actual = sut.ToRgb(hex);
            // Assert
            Assert.True(expected == actual);
        }
    }
}

参考