пятница, 11 декабря 2009 г.

Row Test в MbUnit

Когда я впервые увидел MbUnit в одном из проектов, то расстроился: "Ну вот, и в этой фирме тоже зоопарк фреймворков. Мало людям MSTest и NUnit, зачем-то нашли ещё один." Но потом MbUnit стал мне нравится. Документация, правда, неполная. Синтаксис стандартный, но с некоторыми дополнениями. Например, атрибут Rollback2 или сравнение коллекций. Вообще много чего там есть, но моя любимая фича - это Row Test.

Мне нужно протестировать всего-навсего три функции. Но, чтобы быть уверенным в результате, на вход нужно подавать сотни различных комбинаций параметров. Row Test - как раз то, что нужно. Я не пишу сотни Test-функций, а просто использую атрибуты, вот так:

[Test]
[Row("Barking", "Waterloo", RateType.Peak, 3.00, 4.00, 2.70 )]
[Row("Barking", "Waterloo", RateType.OffPeak, 3.00, 4.00, 2.50 )]
[Row("Bank", "Waterloo", RateType.OffPeak, 2.50, 2.00, 1.00 )]
[Row("Bank", "Waterloo", RateType.Peak, 2.50, 2.00, 1.50 )]
public void JourneyPrice(string origin, string destination, RateType rateType, decimal weeklyTravelCardPrice, decimal cashFare, decimal oysterPaygFare)
{
var journey = new Journey(origin, destination, rateType);


Assert.AreEqual(journey.WeeklyTravelCardPrice, weeklyTravelCardPrice);
Assert.AreEqual(journey.CashFare, cashFare);
Assert.AreEqual(journey.OysterPaygFare, oysterPaygFare);
}

Этот пример я придумал для блога. Возможно, он слегка притянут за уши. Я пытаюсь протестировать класс Journey, который вычисляет стоимость проезда между разными станциями лондонского метро. Почему я тестирую конкретные станции, а не зоны? Потому что именно это и должны делать программисты TfL. Во-первых, некоторые станции находятся на границах зон; во-вторых, иногда бывает, что во время поездки пассажир может пересаживаться на разных станциях, и однозначно неизвестно, как именно он доехал, и в какие зоны заезжал. Всё это надо учесть при вычислении цены.

С помощью атрибутов Row я передаю и входные параметры (станции отправления и прибытия, тип тарифа), и ожидаемые результаты (цена недельного проездного, который бы покрыл поездку, цена одноразового билета и цена с Oyster Pay As You Go). Не придирайтесь к цифрам, я их не проверял :) И ещё здесь daily cap не учитывается.

Отлично - мне не пришлось писать десяток почти одинаковых методов со странными названиями вроде JourneyPrice_from_Barking_to_Waterloo_OffPeak(). Но возникает другая проблема: допустим, у меня есть несколько сотен тестов, а класс Journey работает достаточно медленно. У меня нет никакой возможности выбрать, какую группу тестов запускать. Да и не только в этом дело. Даже если все тесты прогоняются быстро, в test runner не сразу понятно, что именно провалилось, они же все относятся к одному методу и классу.

Чтобы решить эту проблему, я создал абстрактный класс JourneyTestBase. В нем находятся атрибуты [TextFixture], [SetUp], [TearDown] и [Test]. В [SetUp] я один раз создаю экземпляр Journey (потому что он долго инициализируется). Кроме того, у меня есть свойство

public string Origin { get; set; }

и реализация теста JourneyPrice()

[Test]
public virtual JourneyPrice(string destination, RateType rateType, decimal weeklyTravelCardPrice, decimal cashFare, decimal oysterPaygFare)
{
var journey = new Journey(this.Origin, destination, rateType);

// здесь может быть ещё какой-то длинный код

Assert.AreEqual(journey.WeeklyTravelCardPrice, weeklyTravelCardPrice);
Assert.AreEqual(journey.CashFare, cashFare);
Assert.AreEqual(journey.OysterPaygFare, oysterPaygFare);
}

Обратите внимание, что origin теперь не передается как аргумент, вместо этого я использую this.Origin.

Наследую десяток классов, вроде такого:

public class Barking : JourneyTestBase
{
public Barking()
{
this.Origin = "Barking";
}


[Row("Waterloo", RateType.Peak, 3.00, 4.00, 2.70 )]
[Row("Waterloo", RateType.OffPeak, 3.00, 4.00, 2.50 )]
public void JourneyPrice(string destination, RateType rateType, decimal weeklyTravelCardPrice, decimal cashFare, decimal oysterPaygFare)
{
base.JourneyPrice(destination, rateType, weeklyTravelCardPrice, cashFare, oysterPaygFare);
}
}

Я разбил все тесты по станциям отправления. Конечно, в зависимости от задачи, можно это сделать по какому-то другому признаку. Например, по типу оплаты (проездной, одноразовый билет, Ойстер), по тарифу (час пик, выходные, студент, пенсионер) или по комбинации станции отправления и прибытия (тогда бы класс назывался Barking_Waterloo ).

В дочерних классах нет никакой логики, только входные данные и ожидаемые результаты. Мне пришлось написать конструктор, но в принципе можно было бы извратиться и извлекать название станции прямо из имени класса. Ещё пришлось перекрыть функцию JourneyPrice и вызвать JourneyPrice базового класса, но реально я это не набирал руками. Просто написал override, и Visual Studio всё сгенерировала за меня.

Теперь, когда я запускаю всё это, то сразу вижу: Barking красный, Waterloo зеленый... Кстати, именно для удобства запуска в test runner (в моем случае Gallio) я не стал давать классам длинные имена, вроде BarkingOriginTestFixture - их тяжело читать.

Маленькое неудобство: базовый класс тоже показывается в Gallio, хотя он и абстрактный. Но это не вызывает ошибку, он всегда показывается зеленым.

Вы скажите, а зачем я вообще связался с наследованием? Ведь можно было сделать один класс с вспомогательным методом JourneyPrice(), и кучу тестовых методов вроде Barking() и Waterloo(). Да, это так, но представьте, что кроме цены, наш класс Journey умеет выдавать ещё что-нибудь. Например, минимальную и среднюю продолжительность поездки, количество пересадок, расстояние, список достопримечательностей по пути... Наследование помогает сгруппировать тесты. И в test runner ты их видишь так:

Barking: Price, Duration, Distance
Waterloo: Price, Duration, Distance

Я не эксперт в тестировании - вполне может быть, что мой подход не самый красивый. Но мне понравилось. Первый день работать не мог - всё время хотелось запустить свои 800 тестов и просто наблюдать как Gallio по ним бежит, постепенно окрашивая всё в зеленый цвет...

P.S. В версии 3 поменялся синтаксис, он стал более аккуратным и простым. Например, вместо атрибута [RowTest] теперь используется обычный [Test]. Так же, как и раньше, данные для такого теста передаются через атрибут [Row]. Сравнение коллекций и прочие навороты теперь делаются через обыкновенный класс Assert, например: Assert.AreElementsEqualIgnoringOrder(....) Если у Вас уже есть много тестов, написанных с использованием старого синтаксиса, то надо подключить MbUnit.Compatibility.dll

1 комментарий:

Dima Pasko комментирует...

Row Tests уже добавили в NUnit 2.5

Ratings by outbrain