Edited at

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

More than 1 year has passed since last update.

チェリー本: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);
}
}
}



参考