15
5

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.

単体テストと副作用 - 第2話 オブジェクト指向

Last updated at Posted at 2019-03-06

本シリーズ

単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱

前回のあらすじ

  • Main 関数にベタ書きでは、テストコードが書けなかった。
  • 副作用を意識すべし。
  • 次はにオブジェクト指向っぽく書いてみる。

お題おさらい

  • カンマ区切りの文字列を、標準入力から受け取る。
  • 受け取った文字列をカンマで分割し、各値のデータ型を調べる。
    • 空白または null のデータ型は null
    • true または false のデータ型は bool
    • 数値のデータ型は number
    • それ以外の値のデータ型は string
  • 各値を 値 : データ型 という形式で、標準出力に1秒間隔で表示する。

オブジェクト指向?

オブジェクト指向といっても、完璧にできる自信無いお(´・ω・`)
まあ、何となく適当にやってみよう…。

GitHubリンク

まずは、データ型を定義してみる。

TestableSample2/TestableSample2.App/TypeKind.cs

namespace TestableSample2.App
{
    public enum TypeKind
    {
        Null,
        Bool,
        Number,
        String
    }
}

続いて、値とデータ型の組をクラスにしよう。

TestableSample2/TestableSample2.App/TypedValue.cs
using System;
using System.Text.RegularExpressions;

namespace TestableSample2.App
{
    public class TypedValue
    {
        public string   Value    { get; }
        public TypeKind TypeKind { get; }

        public TypedValue(string value)
        {
            Value = value;

            if (Regex.IsMatch(value, @"^(|null)$"))
            {
                TypeKind = TypeKind.Null;
            }
            else if (Regex.IsMatch(value, @"^(true|false)$"))
            {
                TypeKind = TypeKind.Bool;
            }
            else if (Regex.IsMatch(value, @"^-?[0-9]+(\.[0-9]+)?$"))
            {
                TypeKind = TypeKind.Number;
            }
            else
            {
                TypeKind = TypeKind.String;
            }
        }

        public void WriteLine()
        {
            Console.WriteLine($"{Value} : {ToString(TypeKind)}");
        }

        private string ToString(TypeKind typeKind)
        {
            switch (typeKind)
            {
                case TypeKind.Null:
                    return "null";

                case TypeKind.Bool:
                    return "bool";

                case TypeKind.Number:
                    return "number";

                case TypeKind.String:
                    return "string";
                    
                default:
                    return "";
            }
        }
    }
}

そうしたら、プログラムの流れを書いて、と。

TestableSample2/TestableSample2.App/MainFlow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace TestableSample2.App
{
    public static class MainFlow
    {
        public static async Task Run()
        {
            var typedValues = ReadTypedValues();

            foreach (var tv in typedValues)
            {
                await Task.Delay(1000);
                tv.WriteLine();
            }
        }

        private static IEnumerable<TypedValue> ReadTypedValues()
        {
            Console.Write("カンマ区切りの値を入力:");
            var valueStrs = Console.ReadLine();

            return valueStrs.Split(",", StringSplitOptions.None)
                            .Select(x => new TypedValue(x));
        }
    }
}
TestableSample2/TestableSample2.App/Program.cs
using System.Threading.Tasks;

namespace TestableSample2.App
{
    internal class Program
    {
        private static async Task Main(string[] args)
        {
            await MainFlow.Run();
        }
    }
}

これで、テストコードが書けるようになったのだろうか…?

TestableSample2/TestableSample2.Test/Test_TypedValue.cs
using System.Collections.Generic;
using Xunit;
using TestableSample2.App;

namespace TestableSample2.Test
{
    public class Test_TypedValue
    {
        [Theory]
        [MemberData(nameof(Constructor_Data))]
        public void Constructor(TypeKind expected, string value)
        {
            var typedValue = new TypedValue(value);

            Assert.Equal(value   , typedValue.Value);
            Assert.Equal(expected, typedValue.TypeKind);
        }

        public static IEnumerable<object[]> Constructor_Data()
        {
            return new[] {
                new object[] { TypeKind.Null  , ""        },
                new object[] { TypeKind.Null  , "null"    },
                new object[] { TypeKind.Bool  , "true"    },
                new object[] { TypeKind.Bool  , "false"   },
                new object[] { TypeKind.Number, "0.0"     },
                new object[] { TypeKind.Number, "789"     },
                new object[] { TypeKind.Number, "-12.345" },
                new object[] { TypeKind.String, "a123"    },
                new object[] { TypeKind.String, "$#@?+"   }
            };
        }
    }
}

(^ω^)

ここでは、テストフレームワークは「xUnit.net」を使っている。
MemberData 属性で指定された Constructor_Data メソッドは、テストである Constructor メソッドに引数を渡す役割がある。
ここで定義された9個の配列の分だけテストが走り、全て通った。

やったお。
テストコードが書けたお(^ω^)
女神さま、見ていてくれたかな。

『…きこえますか…今…あなたのゾウリムシ並みの脳味噌に…直接呼びかけています…』

ゾウリムシって単細胞生物では…(´・ω・`)

『…あなたの書いたコードには…2つの欠陥があります…。責務が分離できていないこと…。副作用を回避できていないこと…。では…順を追って見てゆきましょう…』

責務の分離

『…TypedValue クラスを見てください…この部分です…』

TestableSample2/TestableSample2.App/TypedValue.cs
        //...

        public void WriteLine()
        {
            Console.WriteLine($"{Value} : {ToString(TypeKind)}");
        }

        //...

『…TypedValue クラスは何のためのクラスですか…?』

えっと…。
TypedValue クラスは、文字列値とそのデータ型の情報を持ったクラスだお。
コンストラクタでは文字列値を受け取って、データ型を自動で判別してる。
あとは、文字列表現を生成する機能もあるね。

『…このクラスは…値とデータ型の組の情報と…それにまつわる機能で構成されるべきです…。不要な責務は…排除しなくてはなりません…』

ふむ。
つまり、上述のコンソール出力は、このクラスの責務ではない、と。

『…その通りです…。更に言えば…このコンソール出力は副作用です…。この種の副作用は…テストコードではテストできません…』

そういえば、TypedValue の文字列表現に関するテストが書けてないお…(´・ω・`)

『…テストを妨げる副作用は…何としても回避するのです…』

副作用の回避?

『…前述の通り…副作用はテストが難しいか…または全くテストできません…。副作用の塊である MainFlow クラスを見てみましょう…』

TestableSample2/TestableSample2.App/MainFlow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace TestableSample2.App
{
    public static class MainFlow
    {
        public static async Task Run()
        {
            var typedValues = ReadTypedValues();

            foreach (var tv in typedValues)
            {
                await Task.Delay(1000);
                tv.WriteLine();
            }
        }

        private static IEnumerable<TypedValue> ReadTypedValues()
        {
            Console.Write("カンマ区切りの値を入力:");
            var valueStrs = Console.ReadLine();

            return valueStrs.Split(",", StringSplitOptions.None)
                            .Select(x => new TypedValue(x));
        }
    }
}

この MainFlow クラスは、プログラム全体の流れを記述する static なクラスだお。
標準入力から文字列を受け取り、TypedValue に加工して、1秒ごとに標準出力する。

『…TypedValue への変換以外は副作用ですね…』

そっか。
だから全くテストできなかったんだね。
…?
さっき女神さまが言った、「副作用の回避」ってどうやるんだお(´・ω・`)?
他のクラスに副作用のある責務をまとめたとして、その機能を使ったら、使った側のクラスもテストできなくなるんでは…?

『…あなたに"3種の神器"を与えます…。即ち…"テストフレームワーク"…"テストダブル"…"依存性の注入"です…。次回までに…使い方を覚えてくるのです…』

宿題が出たお(・ω・)

~ 次回へ続く ~

ここまでのまとめ

  • オブジェクト指向は責務を意識すると、機能を明確に分離しやすい。
  • 副作用を回避する方法がある?
  • そのためには、テストフレームワーク、テストダブル、依存性の注入を覚える必要がある。

本シリーズ

単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱

15
5
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
15
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?