Тестирование методов с ref-параметрами

Крутые пацаны пишут крутой код, который является крутым в том числе потому, что он покрыт тестами. А эти тесты нужно тоже писать не через задницу, между прочим. Многие ведь как? Напишут код, напишут на него тесты, в качестве моковых\стабовых значений используют мини-классы (а иногда и не мини). Not bad, конечно, но тем не менее, читать трудновато. Гораздо лучше, когда используются какие-то тестовые фреймворки, например, Rhino Mocks. Вот об одном из фреймворков и пойдет речь, о Moq.

Существует такая проблема: если вы имеет метод с ref-параметрами, которые хочется «замочить», то Moq нельзя использовать для тестирования, потому что он, из коробки, пока еще глуповат для таких экзерсисов. Казалось бы, кому нужны эти ref- out- параметры — но в большом энтерпрайзе случается даже .NET 2.0 (тьфу тьфу тьфу не у меня).

Так вот, собственно проблема: есть некий класс, который через dependency injection получает ссылку на другой класс, у которого есть методы с реф-параметрами, которые надо «замочить».

Продемонстрирую:

Допустим, у нас есть интерфейс вида

public interface ITryMockMe {
void TiskTisk(ref StringBuilder sb);
}

Допустим, у нас есть код, который надо протестировать

public class DieHard : ITryMockMe {
public void TiskTisk(ref StringBuilder sb) {
sb = new StringBuilder();
sb.Append("Hi there");
}
}


И здесь получается неприятная ситуация, мы не сможем “подставить” нужное нам значение в sb.

public class CoreClass
{
private ITryMockMe _tmm;</code>

public CoreClass(ITryMockMe tmm) {
_tmm = tmm;
}

public void TryIt() {
var sb = new StringBuilder();
_tmm.TiskTisk(ref sb);

if (sb.Length == 0) {
throw new Exception("Didn't worked!");
}
}
}

Смысл TryIt() в том, чтобы убедиться, что длина стрингбилдера не была равной нулю при вызове объекта, имплементирующего ITryMockMe (при этом референс ставится на другой объект).

Вот как всё теперь выглядит:

using System;
using System.Text;
using Moq;
using NUnit.Framework;</code>

namespace ConsoleApplication2
{
[TestFixture]
public class LetMock
{
public static void Main()
{
}

[Test]
public void MeMock()
{
//arrange
var mock = new Mock();
var sb = new StringBuilder("Hello");
mock.Setup(me =&gt;me.TiskTisk(ref sb));

var core = new CoreClass(mock.Object);

//act
core.TryIt();

//assert
mock.VerifyAll();
}
}

public interface ITryMockMe
{
void TiskTisk(ref StringBuilder sb);
}

public class DieHard : ITryMockMe
{
public void TiskTisk(ref StringBuilder sb)
{
sb = new StringBuilder();
sb.Append("Hi there");
}
}

public class CoreClass
{
private readonly ITryMockMe _tmm;

public CoreClass(ITryMockMe tmm)
{
_tmm = tmm;
}

public void TryIt()
{
var sb = new StringBuilder();
_tmm.TiskTisk(ref sb);
if (sb.Length == 0)
{
throw new Exception("Didn't worked!");
}
}
}
}

И тест конечно же не проходит:

Screen Shot 2014-06-07 at 12.35.40Чувствуете? И представьте, никак нельзя исправить класс CoreClass, потому что, например, у нас .NET Remoting и от ref никуда не деться. Что делать?

Но тут гугл выручает, конечно. Можно покурить исходные коды Moq и заметить, что он все проверки складывает определенным образом у себя внутрях. Что это нам дает? Это нам дает возможность совершить грязный хак, за которым надо будет следить по мере выхода новых версий Moq, потому что рефлексия еще никого до добра не доводила (см. P.S.). Пишем вот такой убер-класс для Moq:

public static class MoqExtension
{
/// </code>

/// This extension method add callback API to support 'ref' parameter
///

/////////
public static void RefOutCallback(this ICallback mock, object action)
{
if (!(action is Delegate))
{
throw new ArgumentException("Action should be Delegate", "action");
}
mock.GetType()
.Assembly.GetType("Moq.MethodCall")
.InvokeMember(
"SetCallbackWithArguments",
BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Instance,
null,
mock,
new[] {action});
}

///

/// This extension method hacked the IMatcher associated with each 'ref' parameter to ignore matching.
///

//////
public static ICallback IgnoreRefMatching(this ICallback mock)
{
try
{
FieldInfo matcherField = typeof (Mock)
.Assembly.GetType("Moq.MethodCall")
.GetField("argumentMatchers",BindingFlags.NonPublic | BindingFlags.GetField| BindingFlags.SetField | BindingFlags.Instance);

var argumentMatchers = (IList) matcherField.GetValue(mock);
Type refMatcherType = typeof (Mock).Assembly.GetType("Moq.Matchers.RefMatcher");
FieldInfo equalField = refMatcherType.GetField("equals", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.SetField | BindingFlags.Instance);

foreach (object matcher in argumentMatchers)
{
if (matcher.GetType() == refMatcherType)
{
equalField.SetValue(matcher, new Func&lt;object, bool&gt;(delegate { return true; }));
}
}

return mock;
}
catch (NullReferenceException)
{
return mock;
}
}
}

В нем два метода, один для того, чтобы мы могли заменить своими значениями ref- out- параметры, а второй — чтобы Moq мог корректно распознать наш Setup. Смотрим, что получается сейчас.

IgnoreRefMatching мы прописали затем, чтобы Moq смог корректно сопоставить наш сетап с заданным и проигнорировать на самом деле разные инстансы стрингбилдеров. Ну а RefOutCallback вызывает наш делегат после срабатывания сетапа. Так и замочили проблему. Сейчас мы можем подставить своё любое значение в ref- out- параметры, и наши тесты будут успешно проходить.

P.S. Во время написания статьи я попался на ненадежности рефлексии. Дело в том, что когда я в своем боевом проекте заиспользовал этот хак, у меня был Moq одной версии, а сейчас я через нугет поставил последнюю версию — и код не сработал! Пришлось отлаживаться, но ошибка содержалась именно там, где я и подозревал. Смотрите, в методе IgnoreRefMatching мы ищем тип “Moq.RefMatcher”, а, судя по всему, ребята из Moq уже успели применить рефакторинг, и данный тип сейчас называется “Moq.Matchers.RefMatcher”. К счастью, коротенькая дебаг-сессия выявила актуальное название типа, и сейчас всё хорошо.

 

2 комментария к “Тестирование методов с ref-параметрами

  1. Ёба, я бы постеснялся уже упоминать RhinoMock в приличном обществе, так как его использование ни разу не интуитивно понятное.

    А по поводу Moq, я бы посоветовал преходить на NSubstitute, как более легковесный и простой по API фреймворк мокирования. Точно так же открытый исходный код. Но на предмет ref out параметров не проверял, если честно. Но зато ты можешь написать продолжение статьи.

    Конечно я не призываю все бросить и переписать на NSub, но если проект начинается или без тестов, или есть возможность писать новые с другим мокирующим фреймворком, то определенно стоит дать шанс NSub

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *