Programming By Example のためのちいさなライブラリ
表形式で事前条件と事後条件の列を並べた動作例を1件以上用意して1件ずつ検証し、その結果をまとめてアサートするのにちょっと便利なライブラリ Tester を作ってみた。
データの(空行でない)先頭行に「事前条件」セルと「事後条件」セルを書き、次の行には事前条件の列名のセルを並べた後に事後条件のセルを並べる。その際、事前条件先頭列のすぐ上に前の行の「事前条件」セル、事後条件先頭列のすぐ上に前の行の「事後条件」セルを配置する。3行目以降は各列名に沿った値例を記述する。ここまでが準備。
Tester.Evaluateメソッドでテストコードを評価する。このメソッドは、上で用意した複数行にわたる表形式の文字列を第一引数にとり、第二引数には、Tester.TestCaseクラスを受け取るAction(ラムダ式)の形でテスト評価処理をとる。その際、テストケースの値をValueOfメソッドで参照できるので、下記のコード例のように書くことができる。各行のテストケースデータについてのテスト評価結果がすべて合格(成功)ならなにも起きない。1行以上のテストケースが不合格なら全テストケースデータの結果の文字列を含む TestCaseFailedException が発生するのでテストメソッドは不合格になる。
制約事項:
表のセルデータ内には表のセル区切り文字列と行区切り文字列を含めることはできない。
ただし、表のセル区切り文字列(既定では"|")、表の行区切り文字列(既定では"\r\n")には任意の文字列を指定できる。
そのため、区切り文字列の指定を調整すれば、たとえばExcelで作った表をテストコードにコピー&ペーストしてもいい(はず)。
使い方と挙動は、テストコードを見るとわかるかと思う。
using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; namespace Tester.Tests { [TestClass()] public class TesterTests { [TestMethod()] public void EvaluateTest() { try { Tester.Evaluate(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零|0|0|0 正|5|1|6 ", (testCase) => { string note = testCase.ValueOf("", "備考"); int x1 = int.Parse(testCase.ValueOf("事前条件", "x1")); int x2 = int.Parse(testCase.ValueOf("事前条件", "x2")); int expected = int.Parse(testCase.ValueOf("事後条件", "y=x1+x2")); Assert.AreEqual(expected, x1 + x2, note); }, "|", "\r\n"); } catch (Exception ex) { Assert.Fail(string.Format("例外は発生しないはずです。{0}", ex.ToString())); } } [TestMethod()] public void EvaluateTest_Fail() { try { Tester.Evaluate(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零(故意の誤り)|0|0|1 正(故意の誤り)|5|1|0 ", (testCase) => { string note = testCase.ValueOf("", "備考"); int x1 = int.Parse(testCase.ValueOf("事前条件", "x1")); int x2 = int.Parse(testCase.ValueOf("事前条件", "x2")); int expected = int.Parse(testCase.ValueOf("事後条件", "y=x1+x2")); Assert.AreEqual(expected, x1 + x2, note); }, "|", "\r\n"); } catch (TestFailedException ex) { Assert.AreEqual(@"0:成功 1:Assert.AreEqual に失敗しました。<1> が必要ですが、<0> が指定されました。零(故意の誤り) 2:Assert.AreEqual に失敗しました。<0> が必要ですが、<6> が指定されました。正(故意の誤り) ", ex.Message); } } [TestMethod()] public void EvaluateTest_テストケースカテゴリ参照誤り() { try { Tester.Evaluate(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零|0|0|0 正|5|1|6 ", (testCase) => { string note = testCase.ValueOf("", "備考"); int x1 = int.Parse(testCase.ValueOf("事前条件", "x1")); int x2 = int.Parse(testCase.ValueOf("事前条件", "x2")); int expected = int.Parse(testCase.ValueOf("事前条件", "y=x1+x2"));//正しくは"事後条件" Assert.AreEqual(expected, x1 + x2, note); }, "|", "\r\n"); Assert.Fail(string.Format("ここまでは到達しないはずです。")); } catch (TestFailedException ex) { Assert.Fail(string.Format("この種類の例外は検出されないはずです。{0}", ex.ToString())); } catch (TestErrorException ex) { Assert.AreEqual(@"ValueOf(事前条件,y=x1+x2)が呼び出されましたが、カテゴリ 事前条件 には列名 y=x1+x2 が見つかりません。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseColumnNameNotFoundException)); } } [TestMethod()] public void EvaluateTest_テストケース列参照誤り() { try { Tester.Evaluate(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零|0|0|0 正|5|1|6 ", (testCase) => { string note = testCase.ValueOf("", "備考"); int x1 = int.Parse(testCase.ValueOf("事前条件", "x"));//正しくは x1 int x2 = int.Parse(testCase.ValueOf("事前条件", "x2")); int expected = int.Parse(testCase.ValueOf("事後条件", "y=x1+x2")); Assert.AreEqual(expected, x1 + x2, note); }, "|", "\r\n"); Assert.Fail(string.Format("ここまでは到達しないはずです。")); } catch (TestFailedException ex) { Assert.Fail(string.Format("この種類の例外は検出されないはずです。{0}", ex.ToString())); } catch (TestErrorException ex) { Assert.AreEqual(@"ValueOf(事前条件,x)が呼び出されましたが、カテゴリ 事前条件 には列名 x が見つかりません。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseColumnNameNotFoundException)); } } [TestMethod()] public void EvaluateTest_未定義のテストケースカテゴリ名参照() { try { Tester.Evaluate(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零|0|0|0 正|5|1|6 ", (testCase) => { string note = testCase.ValueOf("", "備考"); int x1 = int.Parse(testCase.ValueOf("事前条件", "x1")); int x2 = int.Parse(testCase.ValueOf("事前条件", "x2")); int expected = int.Parse(testCase.ValueOf("誤記", "y=x1+x2"));//正しくは 事後条件 Assert.AreEqual(expected, x1 + x2, note); }, "|", "\r\n"); Assert.Fail(string.Format("ここまでは到達しないはずです。")); } catch (TestFailedException ex) { Assert.Fail(string.Format("この種類の例外は検出されないはずです。{0}", ex.ToString())); } catch (TestErrorException ex) { Assert.AreEqual(@"ValueOf(誤記,y=x1+x2)が呼び出されましたが、カテゴリ 誤記 が見つかりません。", ex.Message); } } [TestMethod()] public void ParseTestCaseListTest_0ケースの例_テストケースなし() { try { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件|事後条件 x|!x "); Assert.Fail("例外が発生するはずです。"); } catch (TestErrorException ex) { Assert.AreEqual("テストケースがありません。列名定義 x|!x に沿って、1行以上のテストケースのデータ行が必要です。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseEmptyException)); } } [TestMethod()] public void ParseTestCaseListTest_0_列名なし() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件|事後条件 "); Assert.AreEqual(0, testCases.Count); } [TestMethod()] public void ParseTestCaseListTest_2ケースの例() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件|事後条件 x|y 0|1 2|3 "); Assert.AreEqual(2, testCases.Count); Assert.AreEqual("0", testCases[0].ValueOf("事前条件", "x")); Assert.AreEqual("1", testCases[0].ValueOf("事後条件", "y")); Assert.AreEqual("2", testCases[1].ValueOf("事前条件", "x")); Assert.AreEqual("3", testCases[1].ValueOf("事後条件", "y")); } [TestMethod()] public void ParseTestCaseListTest_3ケースの例_事前条件の前の列あり() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" |事前条件||事後条件 備考|x1|x2|y=x1+x2 負|-2|-1|-3 零|0|0|0 正|5|1|6 "); Assert.AreEqual(3, testCases.Count); Assert.AreEqual("負", testCases[0].ValueOf("", "備考")); Assert.AreEqual("-2", testCases[0].ValueOf("事前条件", "x1")); Assert.AreEqual("-1", testCases[0].ValueOf("事前条件", "x2")); Assert.AreEqual("-3", testCases[0].ValueOf("事後条件", "y=x1+x2")); Assert.AreEqual("零", testCases[1].ValueOf("", "備考")); Assert.AreEqual("0", testCases[1].ValueOf("事前条件", "x1")); Assert.AreEqual("0", testCases[1].ValueOf("事前条件", "x2")); Assert.AreEqual("0", testCases[1].ValueOf("事後条件", "y=x1+x2")); Assert.AreEqual("正", testCases[2].ValueOf("", "備考")); Assert.AreEqual("5", testCases[2].ValueOf("事前条件", "x1")); Assert.AreEqual("1", testCases[2].ValueOf("事前条件", "x2")); Assert.AreEqual("6", testCases[2].ValueOf("事後条件", "y=x1+x2")); } [TestMethod()] public void ParseTestCaseListTest_セル区切り文字変更() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件,事後条件 x,y 0,1 2,3 ", ","); Assert.AreEqual(2, testCases.Count); Assert.AreEqual("0", testCases[0].ValueOf("事前条件", "x")); Assert.AreEqual("1", testCases[0].ValueOf("事後条件", "y")); Assert.AreEqual("2", testCases[1].ValueOf("事前条件", "x")); Assert.AreEqual("3", testCases[1].ValueOf("事後条件", "y")); } [TestMethod()] public void ParseTestCaseListTest_行区切り文字変更() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@"@事前条件|事後条件@x|y@0|1@2|3@", "|", "@"); Assert.AreEqual(2, testCases.Count); Assert.AreEqual("0", testCases[0].ValueOf("事前条件", "x")); Assert.AreEqual("1", testCases[0].ValueOf("事後条件", "y")); Assert.AreEqual("2", testCases[1].ValueOf("事前条件", "x")); Assert.AreEqual("3", testCases[1].ValueOf("事後条件", "y")); } [TestMethod()] public void ParseTestCaseListTest_事前条件事後条件のテキスト変更() { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" pre-condition,post-condition x,y 0,1 2,3 ", ",", null, new string[] { "pre-condition", "post-condition" }); Assert.AreEqual(2, testCases.Count); Assert.AreEqual("0", testCases[0].ValueOf("pre-condition", "x")); Assert.AreEqual("1", testCases[0].ValueOf("post-condition", "y")); Assert.AreEqual("2", testCases[1].ValueOf("pre-condition", "x")); Assert.AreEqual("3", testCases[1].ValueOf("post-condition", "y")); } [TestMethod()] public void ParseTestCaseListTest_事前条件事後条件の欠損() { try { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" x|y 0|1 2|3 "); Assert.Fail("例外が発生するはずです。"); } catch (TestErrorException ex) { Assert.AreEqual("テストケース定義の書式に誤りがあります。テストケース行とそれらの前の列名の行に先行して「事前条件」と「事後条件」を含む行が必要です。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseCategoriesMissingException)); } } [TestMethod()] public void ParseTestCaseListTest_列名行の欠損() { try { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件|事後条件 0|1 2|3 "); Assert.AreEqual("0", testCases[0].ValueOf("事前条件", "x")); Assert.AreEqual("1", testCases[0].ValueOf("事後条件", "y")); Assert.AreEqual("2", testCases[1].ValueOf("事前条件", "x")); Assert.AreEqual("3", testCases[1].ValueOf("事後条件", "y")); Assert.Fail("例外が発生するはずです。"); } catch (TestErrorException ex) { Assert.AreEqual(@"ValueOf(事前条件,x)が呼び出されましたが、カテゴリ 事前条件 には列名 x が見つかりません。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseColumnNameNotFoundException)); } } [TestMethod()] public void ParseTestCaseListTest_事前条件事後条件のカテゴリ名変更誤り() { try { List<TestCase> testCases; testCases = Tester.ParseTestCaseList(@" 事前条件|事後条件 x|y 0|1 2|3 ", null, null, new string[] { }); Assert.Fail("例外が発生するはずです。"); } catch (TestErrorException ex) { Assert.AreEqual(@"事前条件のカテゴリ名と事後条件のカテゴリ名を持つ2要素の文字列配列が必要ですが、0要素の配列 {} が指定されました。", ex.Message); Assert.IsInstanceOfType(ex, typeof(TestCaseCategoriesReplacementException)); } } } }
上のテストコードに合格するコードはこんな感じ。
using System; using System.Collections.Generic; using System.Runtime.Serialization; namespace Tester { public class Tester { public static readonly string DefaultRowSeparator = "\r\n"; public static readonly string DefaultCellSeparator = "|"; public static readonly string DefaultPreConditionLabelText = "事前条件"; public static readonly string DefaultPostConditionLabelText = "事後条件"; public string RowSeparator = DefaultRowSeparator; public string CellSeparator = DefaultCellSeparator; public string PreConditionLabelText = DefaultPreConditionLabelText; public string PostConditionLabelText = DefaultPostConditionLabelText; public Tester(string cellSeparator = null, string rowSeparator = null, string[] prePostStrings = null) { CellSeparator = (null == cellSeparator) ? DefaultCellSeparator : cellSeparator; RowSeparator = (null == rowSeparator) ? DefaultRowSeparator : rowSeparator; if (null != prePostStrings && 2 != prePostStrings.Length) { throw new TestCaseCategoriesReplacementException(prePostStrings); } PreConditionLabelText = (null == prePostStrings) ? DefaultPreConditionLabelText : prePostStrings[0]; PostConditionLabelText = (null == prePostStrings) ? DefaultPostConditionLabelText : prePostStrings[1]; } public static void Evaluate(string testCases, Action<TestCase> testCode, string cellSeparator = null, string rowSeparator = null, string[] prePostStrings = null) { Tester tester = new Tester(cellSeparator, rowSeparator, prePostStrings); tester.Evaluate(testCases, testCode); } public static List<TestCase> ParseTestCaseList(string testCases, string cellSeparator = null, string rowSeparator = null, string[] prePostStrings = null) { Tester tester = new Tester(cellSeparator, rowSeparator, prePostStrings); return tester.ParseTestCaseList(testCases); } private void Evaluate(string testCases, Action<TestCase> testCode) { var cases = this.ParseTestCaseList(testCases); var actuals = new List<TestResult>(); bool hasFailures = false; foreach (var testCase in cases) { var actual = new TestResult(); try { testCode(testCase); actuals.Add(actual.Success()); } catch (TestErrorException) { throw; } catch (Exception ex) { actuals.Add(actual.Fail(ex.Message)); hasFailures = true; } } if (hasFailures) { throw new TestFailedException(actuals); } } private List<TestCase> ParseTestCaseList(string testCases) { var cases = new List<TestCase>(); int indexPreCond = -1; int indexPostCond = -1; var columnNames = new Dictionary<int, string>(); string[] lines = testCases.Split(new string[] { this.RowSeparator }, StringSplitOptions.None); for (int r = 0; r < lines.Length; ++r) { string line = lines[r]; if (string.IsNullOrWhiteSpace(line)) { continue; } if (0 == columnNames.Count) { if (!(0 <= indexPreCond && 0 <= indexPostCond)) { FindPrePostConditionIndexes(ref indexPreCond, ref indexPostCond, lines, r); } else { MemoryColumnNames(columnNames, lines, r); } } else { SetCellValues(cases, indexPreCond, indexPostCond, columnNames, lines, r); } } if (0 == cases.Count) { if (!(0 <= indexPreCond && 0 <= indexPostCond)) { throw new TestCaseCategoriesMissingException(this.PreConditionLabelText, this.PostConditionLabelText); } if (0 < columnNames.Count) { throw new TestCaseEmptyException(this.GetColumnNamesDefinition(columnNames)); } } return cases; } private string GetColumnNamesDefinition(Dictionary<int, string> columnNames) { var names = new List<string>(); for (int i = 0; i < columnNames.Count; ++i) { names.Add(columnNames[i]); } return string.Join(this.CellSeparator, names.ToArray()); } private void MemoryColumnNames(Dictionary<int, string> columnNames, string[] lines, int r) { string[] cells = lines[r].Split(new string[] { this.CellSeparator }, StringSplitOptions.None); for (int c = 0; c < cells.Length; ++c) { var cell = cells[c]; columnNames.Add(c, cell); } } private void FindPrePostConditionIndexes(ref int indexPreCond, ref int indexPostCond, string[] lines, int r) { string[] cells = lines[r].Split(new string[] { this.CellSeparator }, StringSplitOptions.None); for (int c = 0; c < cells.Length; ++c) { var cell = cells[c]; if (this.PreConditionLabelText.Equals(cell)) { indexPreCond = c; } if (this.PostConditionLabelText.Equals(cell)) { indexPostCond = c; } } } private void SetCellValues(List<TestCase> cases, int indexPreCond, int indexPostCond, Dictionary<int, string> columnNames, string[] lines, int r) { var dic = new Dictionary<string, Dictionary<string, string>>(); dic[""] = new Dictionary<string, string>(); dic[this.PreConditionLabelText] = new Dictionary<string, string>(); dic[this.PostConditionLabelText] = new Dictionary<string, string>(); string[] cells = lines[r].Split(new string[] { this.CellSeparator }, StringSplitOptions.None); for (int c = 0; c < cells.Length; ++c) { string categoryName = GetCategoryName(c, indexPreCond, indexPostCond); string columnName = columnNames[c]; string value = cells[c]; dic[categoryName].Add(columnName, value); } cases.Add(new TestCase(dic)); } private string GetCategoryName(int c, int indexPreCond, int indexPostCond) { if (c < indexPreCond) { return ""; } if (c < indexPostCond) { return this.PreConditionLabelText; } return this.PostConditionLabelText; } } public class TestResult { public static string SUCCESS = "成功"; public string Result { get; set; } internal TestResult Success() { this.Result = SUCCESS; return this; } internal TestResult Fail(string message) { this.Result = message; return this; } public override bool Equals(object obj) { if (obj == null) { return false; } if (!obj.GetType().Equals(this.GetType())) { return false; } return this.ToString().Equals(obj.ToString()); } public override string ToString() { if (null == this.Result) { return string.Empty; } return this.Result.ToString(); } public override int GetHashCode() { return this.Result.GetHashCode(); } } public class TestCase { private Dictionary<string, Dictionary<string, string>> Value; public TestCase(Dictionary<string, Dictionary<string, string>> dicTestCases) { this.Value = dicTestCases; } public string ValueOf(string categoryName, string columnName) { if (!this.Value.ContainsKey(categoryName)) { throw new TestCaseCategoryNameNotFoundException(categoryName, columnName); } if (!this.Value[categoryName].ContainsKey(columnName)) { throw new TestCaseColumnNameNotFoundException(categoryName, columnName); } return this.Value[categoryName][columnName]; } [Serializable] public class TestCaseKeyException : TestErrorException { public List<TestResult> ActualTestResults { get; private set; } public TestCaseKeyException(string message) : base(message) { } } } /// <summary> /// テストコード自体の異常の場合の例外。 /// すなわち、テストケースの失敗ではなく、テストケースライブラリの用法または実装の異常を検出した場合の例外。 /// </summary> [Serializable] public class TestErrorException : Exception { public TestErrorException(string message) : base(message) { } } /// <summary> /// テストコードが失敗した(被験体のコードがテストに不合格となった)場合の例外。 /// </summary> [Serializable] public class TestFailedException : Exception { public List<TestResult> ActualTestResults { get; private set; } public TestFailedException(List<TestResult> actuals) : base(TestFailedException.Description(actuals)) { this.ActualTestResults = actuals; } public static string Description(List<TestResult> actuals) { string description = ""; for (int i = 0; i < actuals.Count; ++i) { TestResult actual = actuals[i]; string value = (null == actual) ? "" : actual.ToString(); description += string.Format("{0}:{1}\r\n", i, value); } return description; } } [Serializable] public class TestCaseCategoriesReplacementException : TestErrorException { private string[] prePostStrings; public TestCaseCategoriesReplacementException(string[] prePostStrings) : base(Description(prePostStrings)) { this.prePostStrings = prePostStrings; } private static string Description(string[] prePostStrings) { return string.Format("事前条件のカテゴリ名と事後条件のカテゴリ名を持つ2要素の文字列配列が必要ですが、{0}要素の配列 {1} が指定されました。", prePostStrings.Length, string.Format("{{{0}}}", string.Join(",", prePostStrings))); } } [Serializable] public class TestCaseCategoryNameNotFoundException : TestErrorException { public TestCaseCategoryNameNotFoundException(string categoryName, string columnName) : base(Description(categoryName, columnName)) { } public static string Description(string categoryName, string columnName) { return string.Format("ValueOf({0},{1})が呼び出されましたが、カテゴリ {0} が見つかりません。", categoryName, columnName); } } [Serializable] public class TestCaseColumnNameNotFoundException : TestErrorException { public TestCaseColumnNameNotFoundException(string categoryName, string columnName) : base(Description(categoryName, columnName)) { } public static string Description(string categoryName, string columnName) { return string.Format("ValueOf({0},{1})が呼び出されましたが、カテゴリ {0} には列名 {1} が見つかりません。", categoryName, columnName); } } [Serializable] public class TestCaseEmptyException : TestErrorException { public TestCaseEmptyException(string columnNamesDefinition) : base(Description(columnNamesDefinition)) { } public static string Description(string columnNamesDefinition) { return string.Format("テストケースがありません。列名定義 {0} に沿って、1行以上のテストケースのデータ行が必要です。", columnNamesDefinition); } } [Serializable] public class TestCaseCategoriesMissingException : TestErrorException { public TestCaseCategoriesMissingException(string preConditionLabelText, string postConditionLabelText) : base(Description(preConditionLabelText, postConditionLabelText)) { } public static string Description(string preConditionLabelText, string postConditionLabelText) { return string.Format("テストケース定義の書式に誤りがあります。テストケース行とそれらの前の列名の行に先行して「{0}」と「{1}」を含む行が必要です。", preConditionLabelText, postConditionLabelText); } } }