「ファイル」とか
「データベース」に絡む場所って
「どうやってテストコードを書けばいいのー?」
今回は「C#でテスト駆動開発をする方法」の
第3回
「C#でファイルやデータベース部分を
テストコードでユニットテストする方法」
をお届けします。
「外部に接触するロジックを含む実装」を
どうやってテストコードでカバーするかを解説しています。
前回はテスト駆動開発の5つの手順を中心に解説しました。
何となくテスト駆動開発がわかってきましたか?
最初は何となくテストを書きながら実装するのは
めんどくさい気がするかもしれませんが,
慣れてくると,テストを書きながらやらないと
うまく実装できないというか,テストを先に書くことで,
いつもシンプルでわかりやすい実装ができていると個人的には感じています。
「テストしやすいコード」と「テストしにくいコード」とは?
C#での実装コードには,テストしやすいコードと,
テストしにくいコードというものがあります。
テストしやすいコードとは,メソッドが完結しているものです。
例えば前回の例で出てきた,「Add」メソッドなどは,
intの引数を2つ取得して,2つの値の合計を返却するというものでしたが,
こういった1つのメソッド内ですべてが完結しているメソッドは
テストがしやすいコードといえます。
単純にintの引数を2つ投げて,戻り値をチェックすれば,
メソッドが正しく動作していることがテストコードで確認できます。
逆に,テストコードが書きにくい実装というものがあります。
例えばファイルを開いたり,データベースに接続している部分などです。
なぜテストコードを書きにくいかというと,例えばコード内で
「CドライブのworkフォルダーのTest.txtファイルの内容を読み込んで
改行コードの有無をチェックする」という仕様があった場合のテストを考えてみましょう。
新しいテストクラスの作成
実際に書いたほうがわかりやすいと思うので,書いていきましょう。
新しテストクラスを作成しましょう。
TDDTest.Testsプロジェクトを右クリックして,「追加」→「新しい項目」を選択します。
新しテストクラスが生成されます。
※Visual Studio2015などではファイル名の入力などが必要かもしれません。
その場合はファイル名を「UnitTest2」として作成してください。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void TestMethod1() { } } }
それでは新しくできたテストクラスに
先ほどの例の通りにテストコードを書いていきましょう。
先ほどの例は「CドライブのworkフォルダーのTest.txtファイルの
内容を読み込んで改行コードの有無をチェックする」というものでした。
Test.txtファイルにアクセスするクラスとして
TestFileクラスがあったとして,
NewLineExistsメソッドでファイル内の文字列に,
改行コードが存在するかをboolで返すメソッドがあるとします。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFile = new TextFile(); Assert.AreEqual(true, textFile.NewLineExists()); } } }
TestFileクラスはまだ存在しないため,
WinformプロジェクトのObjectsフォルダーにTestFileクラスを作成します。
using System.IO; using System.IO; namespace TDD.Winform.Objects { public class TextFile { public bool NewLineExists() { var fileString = File.ReadAllText(@"C:\work\Test.txt"); return fileString.Contains(System.Environment.NewLine); } } }
TestFileクラスのアクセス修飾子をpublicに変更し,
NewLineExistsメソッドを作成します。
さてこの状況で先ほどのテストを実行したらどうなるでしょうか?
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void ファイルを読み込む() { var testFile = new TestFile(); var fileString = testFile.GetData(); Assert.AreEqual("AAA", fileString); } } }
もちろんテストはNGとなります。
原因はCドライブにworkフォルダーも
Test.txtファイルも存在しないからです。
では実際にその場所にファイルを置いて,
中身を「AAA」の文字プラス改行コードを入れて
保存したらテストはパスするでしょうか?
実際にやってみましょう。 どうでしたか?テストは成功しますね。
これですべてはめでたしめでたし…でしょうか?
残念ながらこのテストには限界があります。
まず,今やったようにCドライブのworkフォルダーに
Test.txtファイルを設置し,
中身に改行コードをいれなければなりません。
あなたのPCではテストに成功しても
他の人のPCで実行するとNGになる可能性があります。
それになりより,テストのバリエーションが書けません。
改行コードありのテストをすると,改行コード無しのテストはできません。
同一フォルダーに同じファイル名のファイルは複数設置できませんから,
当然そうなります。 次のようなテストコードの共存は不可能になります。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFile = new TextFile(); Assert.AreEqual(true, textFile.NewLineExists()); } [TestMethod] public void 改行コードの有無チェックができるFalse() { var textFile = new TextFile(); Assert.AreEqual(false, textFile.NewLineExists()); } } }
「TestFileクラスで固定でパスを指定せずに,引数でパスをとるようにして,テストコードからは,いろいろなパスで指定できるようにすればいいのでは?」
と思われるかもしれません。
確かにそれだとテストは可能であり,
テスト用のファイルもVisual Studioに登録して,
自動で吐き出すことでテストすることはでいます。
ファイルの例以外にもデータベースに接続する場合も
同様の問題が起こります。
データベースとの接続部分が含まれる処理を通過させるテストは,
実際に接続先を一時的に変更したり,
テーブルの中身をテストの前後で作り変えるなどの工夫が必要です。
Mock(モック)を使う事で
ファイルやデータベースとの接続箇所のテストを実施する
先述した通り,
ファイルやデータベースとの接触が含まれる実装コードを通そうとすると,
通常はエラーになり,テストを成功させることができません。
その場合は接続先をテスト用に変更したり,
その都度データを書き換えることで対応できなくはないのですが,
テスト用のデータベースを使う場合は,
サーバー上にSQLServerを置いたり,
テーブル追加に追従したり,複数人で開発するには,
いろいろとメンテナンスが大変です。
私はそういった外部との接触部分が絡むテストをするときは
Mockを使う事で解決しています。
Mockとはテスト用にダミーの処理をさせて,
後続処理のテストを実施する手法の事を言います。
似た言葉に「スタブ」というものがありますが,
スタブは未実装部分をコンパイルエラーが出ないために
実装している程度の意味で使用しており,
モックは狙ったテストを成功させるために,
意図的なふるまいをさせるダミークラス的な意味で使用しています。
例えば先ほどの例であれば次のように実装します。
WinformプロジェクトのObjectsフォルダに次のインターフェースを追加します。
namespace TDD.Winform.Objects { public interface ITextFile { string GetData(); } }
次に同じくObjectsフォルダーに新しくクラス「TextFileAccess」を作成し,
次のように記載します。
using System.IO; namespace TDD.Winform.Objects { public class TextFileAccess : ITextFile { public string GetData() { return File.ReadAllText(@"C:\work\Test.txt"); } } }
次にFileTextクラスを次のように変更します。
using System.IO; namespace TDD.Winform.Objects { public class TextFile { private ITextFile _textFile; public TextFile(ITextFile textFile) { _textFile = textFile; } public bool NewLineExists() { var fileString = _textFile.GetData(); return fileString.Contains(System.Environment.NewLine); } } }
変更1 ITextFile変数の追加
まず「private ITextFile _textFile;」というメンバー変数を宣言します。
これは先ほど作成したITextFileを設定する変数です。
まだここではインスタンスを生成しません。
変更2 コンストラクタの追加
コンストラクタを追加して,引数でITextFileを受け取ります。
受け取ったITextFileはそのままtextFile変数に設定します。
これで外部から自由にITextFileを設定することが可能となりました。
変更3 ファイルアクセスはインターフェース経由で行う
「var fileString = File.ReadAllText(@”C:\work\Test.txt”);」としていた部分を
「var fileString = _textFile.GetData();」に変更しました。
直接ファイルにアクセスしていた部分
「File.ReadAllText(@”C:\work\Test.txt”);」をなくして
_textFile.GetData()に変更しました。
使い方
実際の実装はTextFile クラスのコンストラクタに
TextFileAccessを設定することで,
GetDataメソッドが呼び出されたときに,
先ほどと同様に「File.ReadAllText(@”C:\work\Test.txt”)」が
呼び出されることになります。
テストコードから呼び出すときは,
後述するMockクラスを設定することで,
任意のテストデータがGetDataメソッドから返却されるように
実装をしていきます。
テストコードの実装
それではテストコードに戻りましょう。
今,テストコードを表示するとTextFileクラスの
インスタンスを生成する箇所「var textFile = new TextFile();」の
部分でコンパイルエラーが出ていますね。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFile = new TextFile(); Assert.AreEqual(true, textFile.NewLineExists()); } [TestMethod] public void 改行コードの有無チェックができるFalse() { var textFile = new TextFile(); Assert.AreEqual(false, textFile.NewLineExists()); } } }
これは引数にITextFileを指定するように
コンストラクタを作成したためです。
ここにITextFileを設定する必要があるのですが,
ここに何を入れましょうか?
ここにMock(モック)を設定します。
ITextFileインターフェースは
stringを返却するGetDataメソッドが1つ定義されています。
実際にはここには「File.ReadAllText(@”C:\work\Test.txt”);」の
戻り値を返す想定です。
ただテストコードを記載するときは実際の
ファイルがなくても動作するようにしたいので,
テストコードから呼び出すときは,
ファイルにアクセスしないように実装します。
Mockの作成
それではMockを作成しましょう。
TDDTest.Testsプロジェクトに普通のクラスを追加し,
名前をTextFileMockとしましょう。
作成されたクラスを次のように変更します。
using TDD.Winform.Objects; namespace TDDTest.Tests { internal class TextFileMock : ITextFile { public string GetData() { throw new NotImplementedException(); } } }
まずMockクラスのアクセス修飾子はinternalとします。
これはTDDTest.Testsプロジェクトからしか参照しないテスト用のクラスのため,
publicではなくinternalが適切です。
ちなみにC#のクラスにアクセス修飾子を記載しなければ,
デフォルトでinternalになります。
ですが,可読性と意図していることを示すために
明示的にinternalと記載しましょう。
次にITextFileを実装し,
ITextFileで定義しているGetDataメソッドを記載します。
「throw new NotImplementedException();」は未実装の例外です。
コードを自動生成すると記載されます。
それではテストコードの改行コードありのテストを
通すために次のようにテストコードを記載しましょう。
using TDD.Winform.Objects; namespace TDDTest.Tests { internal class TextFileMock : ITextFile { public string GetData() { return "AAA" + System.Environment.NewLine; } } }
GetDataメソッドは文字列の「AAA」プラス改行コードを返却するようにしました。「System.Environment.NewLine」はC#で改行コードを表します。
それでは再びテストコードに戻りましょう。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFile = new TextFile(); Assert.AreEqual(true, textFile.NewLineExists()); } [TestMethod] public void 改行コードの有無チェックができるFalse() { var textFile = new TextFile(); Assert.AreEqual(false, textFile.NewLineExists()); } } }
引き続きTextFileをnewしているところでコンパイルエラーになっています。
なのでその部分を今作ったMockクラスを設定してコンパイルエラーをなくしましょう。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFile = new TextFile(new TextFileMock()); Assert.AreEqual(true, textFile.NewLineExists()); } [TestMethod] public void 改行コードの有無チェックができるFalse() { var textFile = new TextFile(new TextFileMock()); Assert.AreEqual(false, textFile.NewLineExists()); } } }
これでコンパイルエラーはなくなりました。
晴れてテストを実行できるようになりました。
テストを実行してみましょう。
「改行コードの有無チェックができる」のテストは成功していますが,
「改行コードの有無チェックができるFalse」のテストは失敗しています。
これはTextFileMockクラスのGetDataメソッドが必ず
「AAA」+改行コードを返しているためです。
「改行コードの有無チェックができるFalse」のテスト用に
別のMockクラスを作成し,
そのGetDataメソッドで改行コードを含まない文字列を返却すればOKなのですが,
それだとテストケースの数だけMockクラスが必要になります。
それだと手間がかかるので,
TextFileMockクラスの返却できる値を動的に変更できるようにすることで対応できます。
TextFileMockクラスを次のように変更します。
using TDD.Winform.Objects; namespace TDDTest.Tests { internal class TextFileMock : ITextFile { internal string Value { get; set; } public string GetData() { return Value; } } }
internalのValueプロパティを作成し,
GetDataメソッドではそのValueをそのまま返却しています。
そのため,TextFileMockクラスValueを操作することでMockが
返却する値を操作することができます。
テストコードを次のように変更します。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using TDD.Winform.Objects; namespace TDDTest.Tests { [TestClass] public class UnitTest2 { [TestMethod] public void 改行コードの有無チェックができる() { var textFileMock = new TextFileMock(); textFileMock.Value = "AAA" + System.Environment.NewLine; var textFile = new TextFile(textFileMock); Assert.AreEqual(true, textFile.NewLineExists()); } [TestMethod] public void 改行コードの有無チェックができるFalse() { var textFileMock = new TextFileMock(); textFileMock.Value = "BBB"; var textFile = new TextFile(textFileMock); Assert.AreEqual(false, textFile.NewLineExists()); } } }
TextFileMockクラスを生成した後に任意の値をValueにセットしています。
これで,TextFileクラスがNewLineExistsメソッドを
読んだ時のGetDataメソッドが返す値が任意に変更できるため,
正常系とエラー系など,自由に値を設定することができます。
この状態でテストを実行してみましょう。
これですべてのテストが成功しました。
実際の実装部分
これでテストコードは成功したので,
TextFileクラスのNewLineExistsメソッドが正しく
動作していることが確認できました。
実際の実装はTextFileクラスをnewする時に
TextFileAccessを生成してコンストラクタの引数に渡すことで,
実際のCドライブのパスにアクセスされて動作することになります。
まとめ
少し長くなってしまいましたが,
要するにファイルなど外部に接触する場所は,
テストコードでのテストがやり辛いため,
インターフェースを作成し,実際の実装と,
テストコードで使用するMockとで切り換えれるようにしておく。
Mockが返却する値を外部から設定できるようにすることで,
テストケースにあった値をMockが返却できるようにする。
というのがポイントとなります。