5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-07-29

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

参考

5
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?