12
7

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 3 years have passed since last update.

C#Advent Calendar 2020

Day 17

テスタブルなコードを書くためのインターフェース入門

Last updated at Posted at 2020-12-16

この記事は C# Advent Calendar 2020 17日目の記事です。

はじめに

C#を書いている人なら一度はインターフェースという機能を見たことがあるだろう。
メソッド、プロパティなどの定義だけを記述しておき、実装を強制できるあれのことだ。

一体あんなものが何の役に立つんだろうか。
私は最初そんなことを思っていた。

しかし、テストコードを書きたいと思ったときインターフェースはとても役に立つことに気づいたんだ。
この記事ではテストが難しいコードに対し、インターフェースを使用し、テスタブルなコードに変更していくことで、インターフェースについて理解を深められたらと思う。

対象者

この記事は以下のような人を対象とする。

  • インターフェースの使い所やメリットがいまいち良くわからないという人
  • テスタブルなコードを書きたいと思っている人

それでは始めよう。

例題

入力した文字列をラベルプリンタに印刷するシステムを想定しよう。
システムの仕様は以下の通りだ。

  • ラベルプリンタにはWebAPIが備わっているため、そのAPIを経由してラベルを印刷する。
  • 印刷が完了したらユーザに知らせる。
  • 何かしらの理由により印刷できなかった場合は印刷に失敗したことをユーザに知らせる。

これらの仕様を満たすような以下のようなコードがあったとしよう。
このときPrintServiceクラスに対してテストコードを書けるだろうか?
(コードに不備があるかもしれないが、あくまで例題用のサンプルコードとして見ていただければと思う。)

Main

Program.cs
using System;

namespace QiitaAdventCalendar2020
{
    class Program
    {
        static void Main(string[] args)
        {
            var str = args[0];
            var svc = new PrintService();
            if(svc.Print(str, out string errMsg))
            {
                Console.WriteLine($"印刷が完了しました。[{str}]");
            }
            else
            {
                Console.WriteLine(errMsg);
            }
        }
    }
}

PrintServiceクラス

PrintService.cs
using System;
namespace QiitaAdventCalendar2020
{
    public class PrintService
    {
        private readonly LabelPrinter _printer;

        public PrintService()
        {
            _printer = new LabelPrinter(new Uri("http://webprinter/api"));
        }

        public bool Print(string contents, out string errMsg)
        {
            if(!_printer.IsRunning())
            {
                errMsg = "プリンタの電源が入っていません。";
                return false;
            }

            if(!_printer.Print(contents))
            {
                errMsg = "印刷に失敗しました。";
                return false;
            }

            errMsg = "";
            return true;
        }
    }
}

LabelPrinterクラス

LabelPrinter.cs
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using Newtonsoft.Json;

namespace QiitaAdventCalendar2020
{
    public class LabelPrinter
    {
        private static readonly HttpClient __client = new HttpClient();
        private readonly Uri _uri;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="uri">ラベルプリンタのURI</param>
        public LabelPrinter(Uri uri)
        {
            _uri = uri;
        }

        /// <summary>
        /// プリンタが起動しているかどうかを確認する
        /// </summary>
        /// <returns>true: 起動している、false: 起動していない</returns>
        public bool IsRunning()
        {
            var uri = new Uri(_uri, "status");
            var contents = __client.GetStringAsync(uri).Result;
            var status = JsonConvert.DeserializeObject<GetResultForStatus>(contents);
            return status.Running;
        }

        /// <summary>
        /// 印刷する
        /// </summary>
        /// <param name="contents">印刷する内容</param>
        /// <returns>true: 印刷成功、false: 印刷失敗</returns>
        public bool Print(string contents)
        {
            // 印刷用のパラメータを作成する
            var postData = new PostDataForPrint()
            {
                Text = contents
            };
            var json = JsonConvert.SerializeObject(postData);

            // 印刷する
            var uri = new Uri(_uri, "print");
            var httpContent = new StringContent(json, Encoding.UTF8, @"application/json");
            var response = __client.PostAsync(uri, httpContent).Result;

            return response.StatusCode == HttpStatusCode.OK;
        }

        /// <summary>
        /// GET /status の結果
        /// </summary>
        private class GetResultForStatus
        {
            [JsonProperty("running")]
            public bool Running { get; set; }
        }

        /// <summary>
        /// POST /print のデータ
        /// </summary>
        private class PostDataForPrint
        {
            [JsonProperty("text")]
            public string Text { get; set; }
        }
    }
}

テストが難しいコードとは

今のままPrintServiceクラスに対してテストコードを書こうとするとどうなるだろうか。

まずラベルプリンタを準備して・・・。
テストに応じて電源を付けたり、消したり・・・。

そんなことできるはずがない。

テストコードを動かすために一人一台ラベルプリンタを準備するのは難しい。
仮に準備できたとしてもテストごとにラベルプリンタの電源を付けたり、消したりするなんてできないだろう。

このままではPrintServiceクラスはテストコードを書くことができないのだ。

このように外部のデバイスへを利用したり、データベースに接続するなど、外部のものに依存しているとテストコードを書くのは途端に難しくなる

テスタブルなコードに改善する

どうすればテスタブルなコードにできるだろうか。
ここからは実際にテスタブルなコードに改善していこう。

インターフェースに依存する

今のコードの依存関係を改めて整理してみよう。
スクリーンショット 2020-12-13 23.30.32.png
この依存関係を見て分かる通り、PrintServiceクラスはLabelPrinterクラスを直接使用しているため、テストしようとするとLabelPrinterクラスの実装にもろに影響を受けてしまう。

LabelPrinterクラスの実装を気にせずに使用する方法は無いだろうか?
こういうときこそインターフェースの出番だ。

LabelPrinterクラスに依存するのではなく、LabelPrinterクラスのインターフェースに依存するように変更してみよう。
(このことを依存関係を逆転させると言ったりもする。以下の図を見ると矢印の方向が逆転していることが分かる。)
スクリーンショット 2020-12-13 23.32.03.png
LabelPrinterクラスのメソッドをすべてインターフェースとして抽出する。

ILabelPrinter.cs
namespace QiitaAdventCalendar2020
{
    public interface ILabelPrinter
    {
        bool IsRunning();
        bool Print(string contents);
    }
}

そして、LabelPrinterクラスはこのインターフェースを実装するようにする。

namespace QiitaAdventCalendar2020
{
    // インターフェースを実装するように書き換える
    //public class LabelPrinter
    public class LabelPrinter : ILabelPrinter
    {
        // 以下全く同じのため省略
    }
}

あとは、PrintServiceクラスをインターフェースに依存させるようにするだけだ。

using System;
namespace QiitaAdventCalendar2020
{
    public class PrintService
    {
        // インターフェースに依存するように変更する
        //private readonly LabelPrinter _printer;
        private readonly ILabelPrinter _printer;

        public PrintService()
        {
            _printer = new LabelPrinter(new Uri("http://webprinter/api"));
        }
        
        // 以下全く同じのため省略
    }
}

これを見て「結局ラベルプリンタを使わないといけないじゃないか」と思うかもしれない。
それは正しいが、その解決は次のステップに置いておこう。
まずはこのインターフェースに依存するということが何よりも重要だ。

インターフェースに依存したことにより、PrintServiceクラスはLabelPrinterクラスの実装に依存しなくなった。
さらに嬉しいことに、今まではたった一つのクラスしか使うことができなかったが、インターフェースを実装するクラスであれば何でも利用できるようになった。
具体的なクラスに依存するのではなく、インターフェースに依存することで、柔軟性が飛躍的に向上したしたことが分かると思う。

依存性を注入する(DI)

さて、先程解決できなかった次のステップに移ろう。
今のままではPrintServiceクラスの中でLabelPrinterを生成(new)しているため、どうしてもLabelPrinterの利用を避けられない。
それなら、内部で生成するのではなく外部で生成し、それを渡してあげるのはどうだろうか。
先ほど抽出したインターフェースを実装したクラスを受け取れるようにコードを変更してみよう。

using System;
namespace QiitaAdventCalendar2020
{
    public class PrintService
    {
        private readonly ILabelPrinter _printer;

        public PrintService()
        {
            _printer = new LabelPrinter(new Uri("http://webprinter/api"));
        }
        
        // ILabelPrinterを実装したクラスを受け取れるように
        // コンストラクタを追加した
        public PrintService(ILabelPrinter printer)
        {
            _printer = printer;
        }

        // 以下全く同じのため省略
    }
}

このように依存するものを外部から渡してあげることを**依存性の注入(DI)**と言ったりする。
これでPrintServiceクラスはILabelPrinterインターフェースを実装しているクラスであれば何でも受け取れるようになった。
これでテスト用のLabelPrinterクラスを設定するといったことも可能だ。

テストコードが書ける準備は整った。
早速テストコードを書いてみよう!

テストコードを書く

テストコードではILabelPrinterインターフェースを実装した、LabelPrinterの結果を偽装したクラスを作成し、先程準備したコンストラクタで偽装したクラスを渡してあげるようにする。

PrintServiceTest.cs
using System;
using Xunit;
using QiitaAdventCalendar2020;

namespace QiitaAdventCalendar2020.Tests
{
    public class PrintServiceTest
    {
        [Fact]
        public void Test_正常に印刷できた場合はエラーメッセージが空文字になること()
        {
            var printer = new FakeLabelPrinter()
            {
                IsRunningResult = true,
                PrintResult = true,
            };
            var svc = new PrintService(printer);
            var result = svc.Print("テスト", out string errMsg);
            Assert.True(result);
            Assert.Equal("", errMsg);
        }

        [Fact]
        public void Test_電源が入っていない場合はエラーメッセージが設定されること()
        {
            var printer = new FakeLabelPrinter()
            {
                IsRunningResult = false,
                PrintResult = false,
            };
            var svc = new PrintService(printer);
            var result = svc.Print("テスト", out string errMsg);
            Assert.False(result);
            Assert.Equal("プリンタの電源が入っていません。", errMsg);
        }

        [Fact]
        public void Test_印刷に失敗した場合はエラーメッセージが設定されること()
        {
            var printer = new FakeLabelPrinter()
            {
                IsRunningResult = true,
                PrintResult = false,
            };
            var svc = new PrintService(printer);
            var result = svc.Print("テスト", out string errMsg);
            Assert.False(result);
            Assert.Equal("印刷に失敗しました。", errMsg);
        }

        /// <summary>
        /// テスト用にLabelPrinterを偽装したクラス
        /// </summary>
        private class FakeLabelPrinter : ILabelPrinter
        {
            public bool IsRunningResult { get; set; }
            public bool PrintResult { get; set; }

            public bool IsRunning()
            {
                return IsRunningResult;
            }

            public bool Print(string contents)
            {
                return PrintResult;
            }
        }
    }
}

テストに応じてパラメータを自由に変更することで、ラベルプリンタを実際に準備したり、電源を入れたり、消したりしなくても、テストができるようになった。

まとめ

この記事ではテストが難しいコードに対し、テスタブルなコードに変更して行くことでインターフェースの使い所やメリットを紹介した。
この記事を読んでインターフェースについての理解やテスタブルなコードについての理解が少しでも深まったなら幸いだ。

それではまた。

TomoProg

12
7
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
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?