はじめましての方ははじめまして!ソフトウェアテストあどべんとかれんだー2014 8日目を担当させていただきます、@urasandesu こと杉浦と申します。
前日は、@hayabusa333 さんの、Ruby - Gauntltによるセキュリティテスト #SWTestAdvent - Qiita でしたね。セキュリティテスト自動化フレームワーク、そんなものもあるのだと興味深く拝読させていただきました。2014 年は、Heartbleed や、Apache Struts の脆弱性、Shellshock、POODLE と、脆弱性の話題に事欠かない年になってしまいましたが、今後セキュリティに関するテストはますます重要になっていくんでしょうね。
さて、ソフトウェアテストに関連することということで、私からは打って変わって実装寄りのお話を。以前から私が作成しています Prig という、.NET 自動ユニットテスト向け迂廻路生成ライブラリについて、紹介させていただこうと思います。迂廻路生成?と思われるかもしれませんが、簡単に言うと、通常は行えない static メソッドや private メソッドの上書きをできるようにするというものです(.NET だと Microsoft Fakes、Java だと JMockit とかが有名どころでしょうか)。
題材は「既存ライブラリの非公開メソッドが絡む自動ユニットテスト」。言語は C# です。よく言われる通り、非公開なメソッドそのものをテストすることはよくないこととされていますが、テストで非公開なメソッドに対して何かしたくなることは、しばしばあるんじゃないでしょうか?例えば、テスト対象のメソッド内で使われる private な setter を持つプロパティに意味のある値を与えておきたい、Web にアクセスしにいってしまう private メソッドをモックに入れ替えたい、などなど。まあ、今まさに開発中のコンポーネントであれば、いくらでも対処方法はあるのですが、すでに稼働しているシステムだったり、外部から買い入れたコンポーネントだったりすると、途端に難易度が跳ね上がるのが困りもの。
ところで、C# の特徴的な機能の 1 つに、今から 7 年ほど前に出た C# 3.0 で追加された、拡張メソッドという機能があります。皆さんは拡張メソッドは好きですか?乱用するべきではないですが、その機能が、本質的に、そのライブラリがそのレイヤーでサポートしてほしいものであれば、設計上自然な API を実現できることがあるかと、私は思います。ただし、そのライブラリが、そのような拡張に対してオープンであるかどうかは、場合によるでしょう。特に、そのシグネチャに、非公開な属性の 1 つである internal なクラスが現れるようなメソッドが関係する場合は要注意。今回は、Prig によって、どのようにこの問題を解決するかを解説したいと思います。
以下の記事、ライブラリを使用/参考にさせていただいています。この場を借りてお礼申し上げます m(_ _)m
How to mock ConfigurationManager.AppSettings with moq - Stack Overflow
TDD, Unit testing and Microsoft Fakes with Sitecore Solutions
Basic mocking techniques - Stack Overflow
Hybrid Framework - http://our.umbraco.org
Unit testing Umbraco 7 | just this guy
How to Write 3v1L, Untestable Code
Generic Methods Implementation in Microsoft Fakes - CodeProject
Paulo Morgado - Mastering Expression Trees With .NET Reflector
Expression Tree Visualizer for VS 2010 - Home
mocking - Using Microsoft Fakes Framework with VSTO Application-Level Add-in an XML based Ribbon - Stack Overflow
目次
非公開メソッドの入れ替え
既存ライブラリに、以下のような DTO 群があるとしましょう:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ULTableStatus | |
{ | |
internal bool IsOpened = false; | |
internal int RowsCount = 0; | |
} | |
public class ULColumn | |
{ | |
public ULColumn(string name) | |
{ | |
Name = name; | |
} | |
public string Name { get; private set; } | |
} | |
public class ULColumns : IEnumerable | |
{ | |
ULTableStatus m_status; | |
List<ULColumn> m_columns = new List<ULColumn>(); | |
internal ULColumns(ULTableStatus status) | |
{ | |
m_status = status; | |
} | |
public void Add(ULColumn column) | |
{ | |
ValidateState(m_status); | |
m_columns.Add(column); | |
} | |
public void Remove(ULColumn column) | |
{ | |
ValidateState(m_status); | |
m_columns.Remove(column); | |
} | |
public IEnumerator GetEnumerator() | |
{ | |
return m_columns.GetEnumerator(); | |
} | |
static void ValidateState(ULTableStatus status) | |
{ | |
if (!status.IsOpened) | |
throw new InvalidOperationException("The column can not be modified because owner table has not been opened."); | |
if (0 < status.RowsCount) | |
throw new ArgumentException("The column can not be modified because some rows already exist."); | |
} | |
} | |
public class ULTable | |
{ | |
ULTableStatus m_status = new ULTableStatus(); | |
public ULTable(string tableName) | |
{ | |
TableName = tableName; | |
Columns = new ULColumns(m_status); | |
} | |
public string TableName { get; private set; } | |
public ULColumns Columns { get; private set; } | |
public void Open(string connectionString) | |
{ | |
// ここで DB に接続し、このクラスへスキーマ情報を設定する。 | |
...(snip)... | |
// 「準備完了」を表すフラグを立てる。 | |
m_status.IsOpened = true; | |
} | |
} |
「DB への接続」という副作用と、「テーブル自体のデータ」という状態を 1 つのクラスで管理しており、嫌な臭いを感じます。ただ、このライブラリを作ったニンゲンが、これ以上のリファクタリングをするモチベーションを持つことはないかもしれません。なぜならば、このライブラリだけ見れば internal なクラスが緩衝材としてあり、InternalsVisibleToAttribute を使えば、制限なくそのクラスにアクセスができるため、テストをするのに特に問題を感じないでしょうから。
さて、このライブラリはテーブルスキーマの自動生成ツールも提供しており、特定の列を自動生成してくれます。そのような列は、以下のような規約で命名されるとのことです:
- <table name> + _ID ・・・ プライマリキー
- DELETED ・・・ 論理削除フラグ
- CREATED ・・・ 作成日時
- MODIFIED ・・・ 更新日時
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[TestFixture] | |
public class ULTableMixinTest | |
{ | |
[Test] | |
public void GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated() | |
{ | |
// Arrange | |
var expected = new[] { new ULColumn("USER_ID"), new ULColumn("DELETED"), new ULColumn("CREATED"), new ULColumn("MODIFIED") }; | |
var users = new ULTable("USER"); | |
users.Columns.Add(expected[0]); | |
users.Columns.Add(new ULColumn("PASSWORD")); | |
users.Columns.Add(new ULColumn("USER_NAME")); | |
users.Columns.Add(expected[1]); | |
users.Columns.Add(expected[2]); | |
users.Columns.Add(expected[3]); | |
// Act | |
var actual = users.GetAutoGeneratedColumns(); | |
// Assert | |
CollectionAssert.AreEqual(expected, actual); | |
} | |
} |
テーブル USER には、列 USER_ID、PASSWORD、USER_NAME、DELETED、CREATED、MODIFIED があるとします。そのテーブルに対し、拡張メソッド GetAutoGeneratedColumns を実行すると、自動生成された列が取得できるという寸法です。このままではビルドすら通りませんので、とりあえず以下のような最低限の実装を用意しました:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class ULTableMixin | |
{ | |
public static IEnumerable<ULColumn> GetAutoGeneratedColumns(this ULTable @this) | |
{ | |
throw new NotImplementedException(); | |
} | |
} |
ほい、実行っと。NotImplementedException がスローされるでしょうから、とりあえずなコードを追記し・・・て・・・あれ?
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
PM> & "C:\Program Files (x86)\NUnit 2.6.3\bin\nunit-console.exe" MyLibraryTest.dll /domain=None /framework=v4.0 | |
NUnit-Console version 2.6.3.13283 | |
Copyright (C) 2002-2012 Charlie Poole. | |
Copyright (C) 2002-2004 James W. Newkirk, Michael C. Two, Alexei A. Vorontsov. | |
Copyright (C) 2000-2002 Philip Craig. | |
All Rights Reserved. | |
Runtime Environment - | |
OS Version: Microsoft Windows NT 6.2.9200.0 | |
CLR Version: 2.0.50727.8009 ( Net 3.5 ) | |
ProcessModel: Default DomainUsage: None | |
Execution Runtime: v4.0 | |
.F | |
Tests run: 1, Errors: 1, Failures: 0, Inconclusive: 0, Time: 0.284553726293366 seconds | |
Not run: 0, Invalid: 0, Ignored: 0, Skipped: 0 | |
Errors and Failures: | |
1) Test Error : MyLibraryTest.Mixins.UntestableLibrary.ULTableMixinTest.GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated | |
System.InvalidOperationException : The column can not be modified because owner table has not been opened. | |
at UntestableLibrary.ULColumns.ValidateState(ULTableStatus status) | |
at UntestableLibrary.ULColumns.Add(ULColumn column) | |
at MyLibraryTest.Mixins.UntestableLibrary.ULTableMixinTest.GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated() | |
PM> | |
ヴァー!変なとこで引っかかっとる!! ('A`)
実は、スタックトレースにも出力されているように、ULColumns は、メソッド ValidateState を使うことによって、列が変更可能かどうかを検証しています。テストケースにおいては、GetAutoGeneratedColumns を検証したいのですが、その前、users.Columns.Add(expected[0]); で例外がスローされていたわけですね。これはいけません・・・。
このような状況で、Prig を使うことで、不必要な検証を一時的に外すことができます。Prig をインストールし、その Assembly のスタブ設定を追加します:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
PM> Add-PrigAssembly -AssemblyFrom <ULColumns の Assembly へのフルパス。例えば、"C:\Users\User\NonPublicUntestable\UntestableLibrary\bin\Debug\UntestableLibrary.dll"> |
以下のコマンドを実行し、ULColumns の設定をクリップボードにコピーします:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
PM> Add-Type -Path <ULColumns の Assembly へのフルパス。例えば、"C:\Users\User\NonPublicUntestable\UntestableLibrary\bin\Debug\UntestableLibrary.dll"> | |
PM> Find-IndirectionTarget ([<ULColumns のフルネーム。例えば、UntestableLibrary.ULColumns>]) ValidateState | Get-IndirectionStubSetting | Clip |
そうしましたら、追加されたスタブ設定ファイル(例:UntestableLibrary.v4.0.30319.v1.0.0.0.prig)にそれを貼り付け、ソリューションをビルドします:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<configuration> | |
<configSections> | |
<section name="prig" type="Urasandesu.Prig.Framework.PilotStubberConfiguration.PrigSection, Urasandesu.Prig.Framework" /> | |
</configSections> | |
<prig> | |
<stubs> | |
<add name="ValidateStateULTableStatus" alias="ValidateStateULTableStatus"> | |
<RuntimeMethodInfo xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns:x="http://www.w3.org/2001/XMLSchema" z:Id="1" z:FactoryType="MemberInfoSerializationHolder" z:Type="System.Reflection.MemberInfoSerializationHolder" z:Assembly="0" xmlns:z="http://schemas.microsoft.com/2003/10/Serialization/" xmlns="http://schemas.datacontract.org/2004/07/System.Reflection"> | |
<Name z:Id="2" z:Type="System.String" z:Assembly="0" xmlns="">ValidateState</Name> | |
<AssemblyName z:Id="3" z:Type="System.String" z:Assembly="0" xmlns="">UntestableLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</AssemblyName> | |
<ClassName z:Id="4" z:Type="System.String" z:Assembly="0" xmlns="">UntestableLibrary.ULColumns</ClassName> | |
<Signature z:Id="5" z:Type="System.String" z:Assembly="0" xmlns="">Void ValidateState(UntestableLibrary.ULTableStatus)</Signature> | |
<Signature2 z:Id="6" z:Type="System.String" z:Assembly="0" xmlns="">System.Void ValidateState(UntestableLibrary.ULTableStatus)</Signature2> | |
<MemberType z:Id="7" z:Type="System.Int32" z:Assembly="0" xmlns="">8</MemberType> | |
<GenericArguments i:nil="true" xmlns="" /> | |
</RuntimeMethodInfo> | |
</add> | |
</stubs> | |
</prig> | |
</configuration> |
上の準備が終わったら、以下のようにテストを書き直すことができるようになります:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Test] | |
public void GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated() | |
{ | |
using (new IndirectionsContext()) | |
{ | |
// Arrange | |
// Prig を使い、副作用に依存する検証を抑制するため、メソッド本体を入れ替えます。 | |
PULColumns.ValidateStateULTableStatus().Body = args => null; | |
var expected = new[] { new ULColumn("USER_ID"), new ULColumn("DELETED"), new ULColumn("CREATED"), new ULColumn("MODIFIED") }; | |
var users = new ULTable("USER"); | |
users.Columns.Add(expected[0]); | |
users.Columns.Add(new ULColumn("PASSWORD")); | |
users.Columns.Add(new ULColumn("USER_NAME")); | |
users.Columns.Add(expected[1]); | |
users.Columns.Add(expected[2]); | |
users.Columns.Add(expected[3]); | |
// Act | |
var actual = users.GetAutoGeneratedColumns(); | |
// Assert | |
CollectionAssert.AreEqual(expected, actual); | |
} | |
} |
こんどはどうでしょう?
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
PM> prig run -process "C:\Program Files (x86)\NUnit 2.6.3\bin\nunit-console.exe" -arguments "MyLibraryTest.dll /domain=None /framework=v4.0" | |
NUnit-Console version 2.6.3.13283 | |
Copyright (C) 2002-2012 Charlie Poole. | |
Copyright (C) 2002-2004 James W. Newkirk, Michael C. Two, Alexei A. Vorontsov. | |
Copyright (C) 2000-2002 Philip Craig. | |
All Rights Reserved. | |
Runtime Environment - | |
OS Version: Microsoft Windows NT 6.2.9200.0 | |
CLR Version: 2.0.50727.8009 ( Net 3.5 ) | |
ProcessModel: Default DomainUsage: None | |
Execution Runtime: v4.0 | |
.F | |
Tests run: 1, Errors: 1, Failures: 0, Inconclusive: 0, Time: 1.07656140775011 seconds | |
Not run: 0, Invalid: 0, Ignored: 0, Skipped: 0 | |
Errors and Failures: | |
1) Test Error : MyLibraryTest.Mixins.UntestableLibrary.ULTableMixinTest.GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated | |
System.NotImplementedException : The method or operation is not implemented. | |
at MyLibrary.Mixins.UntestableLibrary.ULTableMixin.GetAutoGeneratedColumns(ULTable this) | |
at MyLibraryTest.Mixins.UntestableLibrary.ULTableMixinTest.GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated() | |
PM> | |
よし!今度は NotImplementedException がスローされるという、意図した結果になりました。後は、テストを通すコードを書き、全てのテストが通ったらリファクタリングをし、新たなテストを追加する、という黄金の回転を回すだけ。良い感じじゃないですか?
付録
ところで、かの人が元のライブラリをどのように設計していれば、もっと簡単にテストができていたと思いますか?そもそも、状態を副作用から分離したライブラリとして再設計すべきだとは思いますが、既存のライブラリで、インターフェイスを変更するような再設計は難しいでしょうね。せめてなにかできることがあるとすれば、非 public なインターフェイスを public にするような変更ぐらいでしょう。
なお、次の点には注意してください:単にインターフェイスを公開する、例えば、ULTableStatus の全てのフィールドと、ULColumns のコンストラクタ .ctor(ULTableStatus) を公開するようなことをしてしまえば、簡単にデータの不整合が起きるようになってしまい、ライブラリが安全ではなくなってしまいます。ライブラリの安全性が保たれる範囲のインターフェイスだけを公開するべきでしょう。このケースでは、以下のような変更が考えられます:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
...(snip)... | |
public class ULColumns : IEnumerable | |
{ | |
List<ULColumn> m_columns = new List<ULColumn>(); | |
IValidation<ULColumns> m_val; | |
public ULColumns(IValidation<ULColumns> val) | |
{ | |
if (val == null) | |
throw new ArgumentNullException("val"); | |
m_val = val; | |
} | |
public void Add(ULColumn column) | |
{ | |
m_val.Validate(this); | |
m_columns.Add(column); | |
} | |
public void Remove(ULColumn column) | |
{ | |
m_val.Validate(this); | |
m_columns.Remove(column); | |
} | |
...(snip)... | |
internal static IValidation<ULColumns> GetDefaultValidation(ULTableStatus status) | |
{ | |
return new ColumnsVariabilityValidator(status); | |
} | |
class ColumnsVariabilityValidator : IValidation<ULColumns> | |
{ | |
readonly ULTableStatus m_status; | |
public ColumnsVariabilityValidator(ULTableStatus status) | |
{ | |
m_status = status; | |
} | |
public void Validate(ULColumns t) | |
{ | |
if (!m_status.IsOpened) | |
throw new InvalidOperationException("The column can not be modified because owner table has not been opened."); | |
if (0 < m_status.RowsCount) | |
throw new ArgumentException("The column can not be modified because some rows already exist."); | |
} | |
} | |
} | |
public class ULTable | |
{ | |
ULTableStatus m_status = new ULTableStatus(); | |
public ULTable(string tableName) | |
{ | |
TableName = tableName; | |
} | |
...(snip)... | |
ULColumns m_columns; | |
public virtual ULColumns Columns | |
{ | |
get | |
{ | |
if (m_columns == null) | |
m_columns = new ULColumns(ULColumns.GetDefaultValidation(m_status)); | |
return m_columns; | |
} | |
} | |
...(snip)... | |
} | |
public interface IValidation<T> | |
{ | |
void Validate(T obj); | |
} |
ULColumns のコンストラクタは公開しましたが、ULTableStatus を直接指定することはせず、検証のためのメソッドだけを持ったインターフェイス IValidation
これらの再設計により、Prig を使わなければキーとなるメソッドに到達することすら難しかったテストは、以下のようにできます。Moq のような通常のモックフレームワークで、簡単にテストができるようになるのです:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Test] | |
public void GetAutoGeneratedColumns_should_return_columns_that_are_auto_generated() | |
{ | |
// Arrange | |
// Moq を使い、副作用に依存する検証を抑制するため、メソッド本体を入れ替えます。 | |
var usersMock = new Mock<ULTable>("USER"); | |
usersMock.Setup(_ => _.Columns).Returns(new ULColumns(new Mock<IValidation<ULColumns>>().Object)); | |
var expected = new[] { new ULColumn("USER_ID"), new ULColumn("DELETED"), new ULColumn("CREATED"), new ULColumn("MODIFIED") }; | |
var users = usersMock.Object; | |
users.Columns.Add(expected[0]); | |
users.Columns.Add(new ULColumn("PASSWORD")); | |
users.Columns.Add(new ULColumn("USER_NAME")); | |
users.Columns.Add(expected[1]); | |
users.Columns.Add(expected[2]); | |
users.Columns.Add(expected[3]); | |
// Act | |
var actual = users.GetAutoGeneratedColumns(); | |
// Assert | |
CollectionAssert.AreEqual(expected, actual); | |
} |
終わりに
ソフトウェアテストあどべんとかれんだー2014 8日目、C# と自作ライブラリを題材に、自動ユニットテストにおける非公開メソッドの入れ替えを解説してみました。つい先月、V1.0.0 をリリースしたばかりということもあり、まだまだ問題もあるかと思いますが、もし興味を持っていただき、使っていただければ嬉しいです!問題などあれば、是非 @urasandesu 宛てにお気軽に mention 下さいませ (((o(*゚▽゚*)o)))
さて、私のまとまってきたドキュメントを日本語の記事にもしておくよシリーズ(1 2 3 4 5 6 7 8)はこれで終わりですが、ソフトウェアテストあどべんとかれんだー2014 はまだまだ続きますのでお見逃しなく!
明日は、@PoohSunny さん。よろしくどうぞ!!