2014年12月8日月曜日

非公開メソッドの入れ替え - from "Prig: Open Source Alternative to Microsoft Fakes" Wiki -

ソフトウェアテストあどべんとかれんだー2014 8日目!&まとまってきたドキュメントを日本語の記事にもしておくよシリーズ第 9 段!(元ネタ:Prigwiki より、FEATURES: Non Public Method Replacement。同シリーズの他記事:1 2 3 4 5 6 7 8

はじめましての方ははじめまして!ソフトウェアテストあどべんとかれんだー2014 8日目を担当させていただきます、@urasandesu こと杉浦と申します。

前日は、@hayabusa333 さんの、Ruby - Gauntltによるセキュリティテスト #SWTestAdvent - Qiita でしたね。セキュリティテスト自動化フレームワーク、そんなものもあるのだと興味深く拝読させていただきました。2014 年は、Heartbleed や、Apache Struts の脆弱性ShellshockPOODLE と、脆弱性の話題に事欠かない年になってしまいましたが、今後セキュリティに関するテストはますます重要になっていくんでしょうね。

さて、ソフトウェアテストに関連することということで、私からは打って変わって実装寄りのお話を。以前から私が作成しています 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 群があるとしましょう:
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;
}
}
view raw 09_01.cs hosted with ❤ by GitHub

「DB への接続」という副作用と、「テーブル自体のデータ」という状態を 1 つのクラスで管理しており、嫌な臭いを感じます。ただ、このライブラリを作ったニンゲンが、これ以上のリファクタリングをするモチベーションを持つことはないかもしれません。なぜならば、このライブラリだけ見れば internal なクラスが緩衝材としてあり、InternalsVisibleToAttribute を使えば、制限なくそのクラスにアクセスができるため、テストをするのに特に問題を感じないでしょうから。

さて、このライブラリはテーブルスキーマの自動生成ツールも提供しており、特定の列を自動生成してくれます。そのような列は、以下のような規約で命名されるとのことです:
  • <table name> + _ID ・・・ プライマリキー
  • DELETED ・・・ 論理削除フラグ
  • CREATED ・・・ 作成日時
  • MODIFIED ・・・ 更新日時
なるほどなるほど。そうすると「自動生成された列だけを取得する」や「手動で生成された列だけを取得する」などのようなことがやりたくなりますね。残念ながら、既存のライブラリは、そのような機能を提供していないとのこと。なので、今回は自分で作成することにしました。こんなシチュエーションでは拡張メソッドがピッタリでしょう。テストを書いてみます:
[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);
}
}
view raw 09_02.cs hosted with ❤ by GitHub

テーブル USER には、列 USER_ID、PASSWORD、USER_NAME、DELETED、CREATED、MODIFIED があるとします。そのテーブルに対し、拡張メソッド GetAutoGeneratedColumns を実行すると、自動生成された列が取得できるという寸法です。このままではビルドすら通りませんので、とりあえず以下のような最低限の実装を用意しました:
public static class ULTableMixin
{
public static IEnumerable<ULColumn> GetAutoGeneratedColumns(this ULTable @this)
{
throw new NotImplementedException();
}
}
view raw 09_03.cs hosted with ❤ by GitHub

ほい、実行っと。NotImplementedException がスローされるでしょうから、とりあえずなコードを追記し・・・て・・・あれ?
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>
view raw 09_04.ps1 hosted with ❤ by GitHub

ヴァー!変なとこで引っかかっとる!! ('A`)

実は、スタックトレースにも出力されているように、ULColumns は、メソッド ValidateState を使うことによって、列が変更可能かどうかを検証しています。テストケースにおいては、GetAutoGeneratedColumns を検証したいのですが、その前、users.Columns.Add(expected[0]); で例外がスローされていたわけですね。これはいけません・・・。

このような状況で、Prig を使うことで、不必要な検証を一時的に外すことができます。Prig をインストールし、その Assembly のスタブ設定を追加します:
PM> Add-PrigAssembly -AssemblyFrom <ULColumns の Assembly へのフルパス。例えば、"C:\Users\User\NonPublicUntestable\UntestableLibrary\bin\Debug\UntestableLibrary.dll">
view raw 09_05.ps1 hosted with ❤ by GitHub

以下のコマンドを実行し、ULColumns の設定をクリップボードにコピーします:
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
view raw 09_06.ps1 hosted with ❤ by GitHub

そうしましたら、追加されたスタブ設定ファイル(例:UntestableLibrary.v4.0.30319.v1.0.0.0.prig)にそれを貼り付け、ソリューションをビルドします:
<?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>
view raw 09_07.ps1 hosted with ❤ by GitHub

上の準備が終わったら、以下のようにテストを書き直すことができるようになります:
[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);
}
}
view raw 09_08.cs hosted with ❤ by GitHub

こんどはどうでしょう?
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>
view raw 09_09.ps1 hosted with ❤ by GitHub

よし!今度は NotImplementedException がスローされるという、意図した結果になりました。後は、テストを通すコードを書き、全てのテストが通ったらリファクタリングをし、新たなテストを追加する、という黄金の回転を回すだけ。良い感じじゃないですか?





付録
ところで、かの人が元のライブラリをどのように設計していれば、もっと簡単にテストができていたと思いますか?そもそも、状態を副作用から分離したライブラリとして再設計すべきだとは思いますが、既存のライブラリで、インターフェイスを変更するような再設計は難しいでしょうね。せめてなにかできることがあるとすれば、非 public なインターフェイスを public にするような変更ぐらいでしょう。

なお、次の点には注意してください:単にインターフェイスを公開する、例えば、ULTableStatus の全てのフィールドと、ULColumns のコンストラクタ .ctor(ULTableStatus) を公開するようなことをしてしまえば、簡単にデータの不整合が起きるようになってしまい、ライブラリが安全ではなくなってしまいます。ライブラリの安全性が保たれる範囲のインターフェイスだけを公開するべきでしょう。このケースでは、以下のような変更が考えられます:
...(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);
}
view raw 09_10.cs hosted with ❤ by GitHub

ULColumns のコンストラクタは公開しましたが、ULTableStatus を直接指定することはせず、検証のためのメソッドだけを持ったインターフェイス IValidation を代わりに指定するようにしました。Prig を使って入れ替えたかったメソッド ValidateState が持つ機能を、外出ししたことになります。対象のメソッドは状態にアクセスはしますが、それを書き換えることはしません。従って、その部分を公開するだけであれば、ライブラリのデータの整合性は保ち続けられることになります。それから、ULTable の ULColumns を生成するプロパティを virtual 化します。

これらの再設計により、Prig を使わなければキーとなるメソッドに到達することすら難しかったテストは、以下のようにできます。Moq のような通常のモックフレームワークで、簡単にテストができるようになるのです:
[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);
}
view raw 09_11.cs hosted with ❤ by GitHub





終わりに
ソフトウェアテストあどべんとかれんだー2014 8日目、C# と自作ライブラリを題材に、自動ユニットテストにおける非公開メソッドの入れ替えを解説してみました。つい先月、V1.0.0 をリリースしたばかりということもあり、まだまだ問題もあるかと思いますが、もし興味を持っていただき、使っていただければ嬉しいです!問題などあれば、是非 @urasandesu 宛てにお気軽に mention 下さいませ (((o(*゚▽゚*)o)))

さて、私のまとまってきたドキュメントを日本語の記事にもしておくよシリーズ(1 2 3 4 5 6 7 8)はこれで終わりですが、ソフトウェアテストあどべんとかれんだー2014 はまだまだ続きますのでお見逃しなく!

明日は、@PoohSunny さん。よろしくどうぞ!!

0 件のコメント:

コメントを投稿