Pull to refresh

Тестирование по спецификации

Reading time 11 min
Views 34K
Недавно я писал про различные виды тестирования и про то, что интеграционное тестирование удобно производить с помощью спецификаций. В этой статье я покажу как именно происходит такое тестирование.

Спецификация — это текстовый файл с описанием того, что нужно протестировать в тестовых данных. В ней указывается какие результаты должна получить программа. Тестовый код находит реальные, вычисленные на живом коде результаты. А тестовый движок производит сверку спецификации и вычисленных результатов.

Такой подход позволяет декларативно создавать тесты. Спецификации легко читаются и дополняются при изменении требований. Тестовый код получается компактным. Его легко поддерживать и расширять.

В статье описаны принципы работы движка для тестирования спецификаций и приведены примеры использования. Сам движок прилагается к статье. Можно его считать небольшой библиотекой для интеграционного тестирования.

Код библиотеки


Исходный код — stalker98.narod.ru/TestBySpecification.rar

Код подробно прокомментирован, так что если по прочтении статьи некоторые моменты останутся непонятными, детали можно посмотреть в реализации. По большому счету библиотека состоит всего из 4х классов, что немного. Движок тестирования находится в Utils.Common.Tests.

Для тестирования я использую тестовый фреймворк 2008 студии, но можно перенести на NUnit. Для этого надо исправить вызов тестов в Test.Domain.Documents.DocumentTester.cs и Test.Domain.Polygons.PolygonTester.cs.

Предметная область для примеров


Пусть стоит задача написать конвертер документов из одного формата в другой. Конвертирование хитрое, с множеством математических расчетов. Наиболее сложная часть преобразования — обработка геометрических фигур, описанных в документах. Заказчик передал набор типовых документов, которые ему требуется перевести в другой формат.

До завершения разработки еще далеко. Уже написанный код покрыт unit-тестами, каждый из которых тестирует свой модуль в изоляции от остальной программы. Но настает момент, когда требуется убедится, что написанный код будет работать на реальных данных. Классы программы в этом случае должны использоваться вместе, без изоляции друг от друга. Нужно провести интеграционное тестирование.

Подход к тестированию такой — для каждого присланного заказчиком типового документа создадим спецификацию, где запишем существенные результаты, которые получит наша программа при конвертировании. Из присланных документов выберем различные фигуры и также напишем для них свои спецификации. В спецификациях фигур запишем те результаты, к которым должна прийти наша программа, при обработке той или иной фигуры.

Файл спецификации


Спецификация — это текстовый файл, описывающий, что нужно протестировать в тестовых данных. Файл состоит из:
  • Комментариев в начале файла. Комментарии могут состоять из любых символов кроме $.
  • Проверяемых свойств. Имя каждого свойства начинается с символа $. Конец значения свойства определяется либо началом следующего свойства, либо концом файла.
Пример (файл спецификации Параллелограмм.spec):
   4      3
   +------+
  /      /
 +------+
 1      2

$Vertices = (0;0) (3;0) (4;1) (1;1)

$VerticeCount = 4
$IsRhombus = false
$HasSelfIntersections = false

Тестовый код


Чтобы проверить соответствие свойств из спецификации и реальных (вычисляемых) значений свойств, нужно знать как находить эти реальные значения свойств. Для этого создается наследник класса Specification и в нем записываются get аксессоры для свойств, с теми же именами, что и в спецификации:

public class PolygonSpecification: Specification
{
    /// <summary>
    /// Число вершин в фигуре
    /// </summary>
    private int VerticeCount
    {
        get { return Polygon.Vertices.Count( ); }
    }

    /// <summary>
    /// Является ли фигура ромбом
    /// </summary>
    private bool IsRhombus
    {
        get { return Polygon.IsRhombus; }
    }

    /// <summary>
    /// Имеет ли фигура самопересечения
    /// </summary>
    private bool HasSelfIntersections
    {
        get { return Polygon.SelfIntersections.Any( ); }
    }

    // ... про чтение тестовых данных чуть позже
}


Тип свойства должен соответствовать имени. Например, свойства которые начинаются на Is, Has или Are, библиотека считает булевыми флагами. А свойства, заканчивающиеся на Count, целыми числами. О механизме сопоставления имени свойства его типу будет рассказано ниже.

Как можно видеть, тестовый код сведен к минимуму.

Вызов тестов


[TestClass( )]
public class PolygonTester: Engine<PolygonSpecification>
{
    /// <summary>
    /// Тестирование всех спецификаций типа PolygonSpecification
    /// </summary>
    [TestMethod( )]
    public void Polygon_AllSpecifications( )
    {
        Assert.IsTrue( TestSpecifications( ), FailedSpecInfo );
    }

    /// <summary>
    /// Для отладки одной спецификации
    /// </summary>
    [TestMethod( )]
    public void Polygon_DebugSpecification( )
    {
        Assert.IsTrue( TestSpecification( "Параллелограмм" ), FailedSpecInfo );
    }
}


Запуск тестирования в студии Ctrl + R, A:



Тестовый вывод


При тестировании ведется лог операций (он же тестовый вывод). В лог пишутся какие спецификации и свойства были протестированы, каков результат тестирования, сколько времени заняло тестирование. Если какое-то свойство не прошло тестирование, то это указывается отдельно. Тестовый вывод можно посмотрев щелкнув по тесту в Test Results окне.

Я написал еще 5 спецификаций для различных фигур. При тестировании оказалось, что свойство IsRhombus (булев флаг — является ли фигура ромбом) в спецификациях не соответствует значению, которое находится в программе. Очевидно допущена ошибка в методе, определяющем является ли фигура ромбом. Лог выглядит при этом так:
Тестирование спецификаций типа PolygonTester
-----------------------------------------------/ Квадрат
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [успех] IsRhombus
00:00:00.000 [успех] VerticeCount
-----------------------------------------------/ Параллелограмм
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [ошибка] IsRhombus
00:00:00.000 [успех] VerticeCount
-----------------------------------------------/ Перекрещенный
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [ошибка] IsRhombus
-----------------------------------------------/ Ромб
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [успех] IsRhombus
-----------------------------------------------/ Трапеция
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [ошибка] IsRhombus
00:00:00.000 [успех] VerticeCount
-----------------------------------------------/ Треугольник
00:00:00.000 [успех] HasSelfIntersections
00:00:00.000 [успех] IsRhombus
00:00:00.000 [успех] VerticeCount
===============================================
Спецификаций прошло тест: 3/6
Свойств проверено: 16
Затрачено времени: 00:00:00.0029297
-----------------------------------------------
Не прошли тест спецификации по свойствам:

Параллелограмм [1]:
IsRhombus: Specified = False, Actual = True

Перекрещенный [1]:
IsRhombus: Specified = False, Actual = True

Трапеция [1]:
IsRhombus: Specified = False, Actual = True
Для примера я взял несложные проверки, поэтому везде во времени вычислений стоит 00:00:00.000. В реальных проектах время будет более существенным.

Механизм определения свойств


Внимательному читателю на данный момент должны быть непонятны два вопроса — откуда берутся тестовые данные и что делать в случае, если нужно протестировать что-то кроме равенства двух bool или int. На оба вопроса ответ лежит в механизме определения свойств.

Каждое свойство относится к одному определенному типу. Причем под типом здесь подразумевается нечто больше, чем просто .NET тип. Это скорее .NET тип плюс поведение при тестировании. Поведение описывается специальным объектом — дескриптором свойства PropertyDescriptor<T>. Дескриптор определяет соглашение, принятое для имен свойств, преобразование из строки в значение свойства и обратно, а также критерий, который определяет прошло ли свойство тестирование или нет.

Приведу в пример библиотечный дескриптор булевых флагов:

protected PropertyDescriptor<bool> FlagProperty = new PropertyDescriptor<bool>
{
    NamePattern = @"(Is|Has|Are)\w+",
    Convert = ( value ) => bool.Parse( value ),
    Verify = ( specified, actual ) => specified == actual,
};


В дескрипторе указано регулярное выражение NamePattern, задающее соглашение для именования свойств, к которым применяется дескриптор. В данном случае это все свойства с именами, которые начинаются на Is, Has или Are. Convert задает функцию преобразования из строки в значение свойства. Verify определяет критерий прохождения теста. В данном случае это простая проверка на равенство. То есть если булево значение в спецификации равно вычисленному значению, то считается что свойство прошло тест. Если Verify опустить, то свойство будет считываться, но не будет тестироваться. Существует еще Translate, который переводит значение свойства в строку. Если его не указать, то при выводе в лог будет использоваться ToString().

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

Расширение спецификации новым типом


До этого момента я показывал примеры на спецификациях фигур. Но у нас еще есть спецификации документов. Представим себе следующую ситуацию — на предварительном показе нашей системы оказалось, что программа некорректно производит разбор документов. Вместо кириллицы отображаются квадратики. Чтобы такие ошибки впредь не появлялись, напишем тест. Дополним спецификацию документа, где была обнаружена ошибка, строчкой с перечислением имен секций:
$SectionNames = Ромб, Параллелограмм, Треугольник
В тестовом коде добавляем одноименное свойство:

/// <summary>
/// Имена секций документа
/// </summary>
private IEnumerable<string> SectionNames
{
    get { return Document.Sections.Select( s => s.Name ); }
}


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

/// <summary>
/// Описатель свойства имен секций
/// </summary>
protected PropertyDescriptor<IEnumerable<string>> SectionNamesProperty = new PropertyDescriptor<IEnumerable<string>>
{
    NamePattern = @"SectionNames",
    Convert = ( text ) => text.Split( ',' ).Select( n => n.Trim( ) ),
    Verify = ( specified, actual ) =>
        specified.Count( ) == actual.Count( ) &&
        specified.Intersect( actual ).Count( ) == actual.Count( ),
    Translate = ( value ) => string.Format( "[{0}]: {1}",
        value.Count( ),
        string.Join( ", ", value.ToArray( ) ) ),
};


Приведенный выше дескриптор применим для свойства с именем «SectionNames». Convert разделяет по запятым строку, считанную из спецификации, и убирает крайние пробелы. Verify определяет, что свойство проходит проверку, когда две коллекции строк эквивалентны — каждый элемент из первой коллекции присутствует во второй коллекции и наоборот. Translate нужен для того, чтобы в случае провала проверки, в логе появилась осмысленная надпись, а не имя анонимного типа. Translate создает строку, в которой указано количество элементов в коллекции и через запятую перечислены их значения.

Дескриптор типизирован, так что при его заполнении IntelliScense будет подсказывать типы аргументов, а компилятор проверит корректность операций.

Если в дальнейшем понадобиться добавить свойство с таким же поведением, то в дескрипторе NamePattern можно изменить на @"\w+Names". И тогда все свойства, оканчивающиеся на Names, будут использовать данный дескриптор.

Чтение тестовых данных


Тестовые данные могут находится где угодно — библиотека не накладывает каких либо ограничений. Однако на практике оказалось удобным пользоваться двумя местами хранения тестовых данных — в самой спецификации, либо в отдельном файле. В обоих случаях файлы хранятся в DLL тестового проекта как Embedded Resource. Это позволяет:
  • Включать интеграционные тесты вместе со всеми данными в систему контроля версий.
  • Всегда иметь под рукой спецификации и тестовые данные, когда работаешь в студии.
Проиллюстрирую оба подхода.

1) Тестовые данные зашиты в спецификацию


Удобно для тестирования объектов, для инициализации которых не нужно много данных. В классе спецификации объявляется дескриптор, в котором не указан метод Verify. Значение свойства при этом берется из словаря считанных свойств спецификации SpecifiedProperties:

/// <summary>
/// Описатель для свойства, перечисляющего вершины полигона
/// </summary>
PropertyDescriptor<IEnumerable<Vector>> VerticesProperty = new PropertyDescriptor<IEnumerable<Vector>>
{
    NamePattern = "Vertices",
    Convert = ( text ) => Polygon.ParseVertices( text ),
};

/// <summary>
/// Тестируемый полигон
/// </summary>
public Polygon Polygon
{
    get { return new Polygon( (IEnumerable<Vector>) SpecifiedProperties[ "Vertices" ] ); }
}


2) Тестовые файлы во внешнем файле


Подходит для случая, когда данных много, или когда тестовые данные — документ. Удобно принять соглашение, по которому имена файлов спецификации и тестовых данных совпадают. При этом тестовые данные лежат в соседнем, относительно спецификаций, каталоге. Тогда чтение будет осуществляться следующим образом:

/// <summary>
/// Тестируемый документ
/// </summary>
public Document Document
{
    get
    {
        var assembly = Assembly.GetExecutingAssembly( );
        var resourceName = string.Format(
            "{0}.Documents.Data.{1}.txt",
            assembly.GetName( ).Name, Name );
        var stream = assembly.GetManifestResourceStream( resourceName );
        var text = new StreamReader( stream ).ReadToEnd( );

        return Document.CreateFromText( text );
    }
}


Принятые соглашения


Движок при тестировании опирается на следующие соглашения:
  • Для каждого типа спецификаций создается отдельная папка в тестовом проекте.
  • В этой папке создается наследник от класса Specification.
  • Файлы спецификации складываются в подпапку Specs как Embedded Resource.
  • Расширение у файлов спецификации должно быть .spec
  • Спецификации ищутся в той же сборке, откуда запущено тестирование. Но если потребуется, сборку можно указать явно в Engine.Assembly.
Удобно рядом с тестовым кодом положить код, запускающий тестирование, и легенду — какие свойства можно проверить в спецификации.



Для случая хранения тестовых данных в отдельных файлах, структура будет следующей (данные, как и спецификации, хранятся в сборке как Embedded Resource):



В спецификациях:
  • Имена свойств должны совпадать с вычисляемыми свойствами в тестовом коде.
  • Каждому считанному свойству должен соответствовать ровно один дескриптор.
  • Тестовый код не должен иметь предположения в каком порядке тестируются свойства. Тесты не должны влиять друг на друга.
Если какое-то соглашение нарушено (например, для свойства из спецификации не обнаружено одноименное свойство в тестовом коде), во время выполнения в движке возникнет соответствующее исключение.

Заключение


Я применял приведенный подход к интеграционному тестированию в своем последнем проекте и остался доволен результатами. Тесты, основанные на спецификациях, проще читать и поддерживать в сравнении с аналогичными тестами, реализованными как unit-тесты.

Фактически спецификации представляют из себя файлы, написанные на Mini DSL (Domain Specific Language). Библиотека является движком этого языка и определят API взаимодействия с тестируемым кодом. На языке общего назначения (C#) тестирование по спецификации также можно написать, но от этого пострадает читаемость и увеличатся издержки по поддержке тестов.

Думаю в будущем добавлю к библиотеке возможность указывать сколько времени имеет право вычисляться то или иное свойство. Тогда можно будет ограничить отводимое на тестирование время и проверять при этом SLA (System Level Agreements) соглашения.
Tags:
Hubs:
+6
Comments 6
Comments Comments 6

Articles