Unit testing в Dynamics AX – Что это?

    Начиная с версии Dynamics Ax 4.0 был добавлен новый модуль для разработчиков – Unit Testing. Фреймворк назвали SysTest, и он очень похож на все традиционные системы UnitTesting. В этой теме постараемся узнать побольше о SysTest и посмотрим на примерах, как работать с этим фреймворком.

Модульное тестирование чрезвычайно важно, и что его отсутствие в Dynamics AX является серьезной проблемой. Одна из причин, по которой люди не пишут юнит-тесты, заключается в том, что они не знают, как это сделать. Попытаемся понять основы и показать несколько примеров, не вдаваясь в ненужные детали. Проблема в том, что, хотя модульное тестирование в принципе тривиально, но для его правильной работы требуются навыки и опыт.

    Прежде всего, почему необходимо беспокоиться о модульных тестах?

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

    Теперь, что именно подразумевается под модульным тестом? По сути, это тестирование кода, в котором модуль (обычно метод или класс) работает так, как задумано.

Пример

    // Prepare object to test
    Number n = new Number(5);

    // Perform some action
    n.Add(1);
    
    // Test the result
    assetEquals(6, n.value());

    Как видите, все дело в коде. Этот вид тестирования выполняется разработчиками, это не отдельный этап, выполняемый отделом контроля качества или кем-либо еще. Эти тесты обычно пишутся вместе с тестируемой функциональностью, что также оказывает положительное влияние на дизайн кода. Важно то, что тестируется один модуль — с помощью этих тестов можно протестировать метод или класс, а не что-то вроде выставления счетов. Зачем?

    Причины

    Одна из причин — сложность. Если тестируется большой компонент, состоящий из множества классов, он имеет много разных состояний и путей в коде, и сложность возрастает экспоненциально, поскольку каждый класс умножает свое количество состояний (например) на другие классы. Сложность и количество необходимых тестов быстро выходят из-под контроля. Решение заключается в индивидуальном тестировании мелких деталей до того, как их сложность возрастет.

    Конечно, могут быть проблемы с классами интеграций и понадобятся отдельные тесты для них. Но эти интеграционные тесты не должны будут проверять все детали, а вместо этого они сосредоточатся на том, как классы взаимодействуют между собой. То, что они делают по отдельности, уже проверено.

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

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

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

Это отличается от того, как тестировщики проектируют тесты — они часто объединяют тесты вместе, а не пытаются их изолировать, потому что запуск каждого теста с нуля будет представлять для них слишком много накладных расходов. Не нужно забывать, что различные типы тестов требуют различных подходов к дизайну.

    Утверждение

    Модульные тесты — это методы, и нам нужен класс для их хранения. Такой контейнер тестовых методов называется контрольным примером, и это просто класс, расширяющий SysTestCase.

[SysTestTargetAttribute('LedgerJournalTrans', UtilElementType::Table)] 
class LedgerJournalTransTest extends SysTestCase 
{ 
}

    Класс-тест может содержать много различных методов, которые ему надо выполнить. Но как понять, какие методы ему исполнять, методы тестирования имеют строгие правила — они должны:

— быть публичными;

— не иметь параметров;

— return void;

— быть объявлены с атрибутом SysTestMethodAttribute.

    Класс может содержать любое количество вспомогательных методов, которые не следуют этим правилам, т.к. правила применяются только к методам тестирования. Методы тестирования обычно делятся на три отдельных этапа: упорядочить, действовать и утверждать (шаблон AAA (arrange, act, assert)).

    Предопределенные атрибуты SysTest

    — SysTestMethodAttribute – указывает, что метод является тестовым. Применяется только к методам.

    — SysTestCheckInTestAttribute — указывает, что это check-in тест, то есть, что он должен выполняться при возврате модифицированных объектов в систему контроля версий, чтобы убедиться в должном уровне качества модификации. Применяется к методам и классам.

    — SysTestNonCheckInTestAttribute — указывает, что это – не check-in тест. Применяется к методам и классам.

    — SysTestTargetAttribute(<имя>, <тип>) — Указывает на объект приложения, который тестируется данным тестом, например, это может быть класс, таблица или форма. Используется классом. Этот атрибут принимает два параметра:

1. Имя – строка с названием тестируемого элемента приложения;

2. Тип – тип тестируемого элемента приложения.

    — SysTestInactiveTestAttribute — указывает, что класс или метод не используется. Применяется к методам и классам.

    В предыдущих версиях, использовались соглашения по именованию классов и методов. Чтобы обозначить, что класс используется для сбора тестового покрытия, применялся шаблон имени <ЦелевойКласс>Test. Названия всех методов, предназначенных для тестирования, в классе-наследнике SysTestCase должны были начинаться со строки test. За счет использования SysTestTargetAttribute и SysTestMethodAttribute можно более явно выражать назначение кода.

    Новые предопределенные атрибуты дают возможность создавать фильтры, чтобы можно было выполнять определенные методы. Для этих целей можно использовать SysTestCheckInTestAttribute, SysTestNonCheckInTestAttribute и SysTestInactiveTestAttribute.

    Регрессионные тесты

    Прекрасным способом поддерживать высокий уровень качества вашего кода является выполнение регрессионных тестов перед регистрацией кода в системе контроля версий. Но не всегда хочется выполнять все тесты, потому что длительность их выполнения может стать проблемой. Здесь-то и пригодятся атрибуты SysTestCheckInTestAttribute и SysTestNonCheckInTestAttribute. Для указания того, какие тесты нужно выполнять при регистрации

кода в системе контроля версий (т.н. check-in), выполните следующие шаги.

1. Укажите для методов тестирования атрибуты SysTestCheckInTestAttribute или SysTestNonCheckInTestAttribute. Отметим, что по умолчанию тест не является check-in тестом, поэтому, чтобы сделать его таковым, вам нужно указать SysTestCheckInTestAttribute. Наилучшим подходом является явное указание одного из двух атрибутов для всех тестов, как в следующем примере.

[SysTestMethodAttribute, SysTestCheckInTestAttribute]

2. Создайте новый проект тестирования и поместите все классы тестов с check-in тестами в этот проект.

3. На форме Настройки для созданного проекта тестирования (щелкните правой кнопкой мыши по названию проекта и выберите Настройки) укажите в качестве фильтра Проверки при возврате (Check-in Tests).

4. В меню Контроль версий > Параметры системы выберите проект в списке проектов тестирования.

При следующем возврате измененных объектов приложения в систему контроля версий будут выполнены соответствующие тесты, и сообщения о результатах их работы выведены в Infolog.

    Изоляция при выполнении тестов

    Уровень изоляции зависит от изменений, которые тестирование вносит в данные. Каждый тестовый сценарий может иметь разные потребности в изоляции в зависимости от того, какие данные будут изменены. Среда UnitTest предоставляет четыре класса, которые обеспечивают разные уровни изоляции.

Классы, предоставляющие уровни изоляции:

  1. SysTestSuite – тест выполняется без изоляции. Используется по умолчанию.
  2. SysTestSuiteCompanyIsolateClass —     создает чистую компанию для тестового класса. Все методы тестирования выполняются в этой компании, после завершения теста компания удаляется.
  3. SysTestSuiteCompanyIsolateMethod — создает чистую компанию для каждого метода тестирования. Компания используется в каждом методе тестирования, а затем компания удаляется. Это обеспечивает лучшую изоляцию, но за счет производительности. Это вызвано тем, что компания создается и удаляется для каждого метода тестирования.
  4. SysTestSuiteTTS — оборачивает каждый тестовый метод в транзакции. После выполнения тестового метода транзакция прерывается. Этот уровень изоляции работает хорошо, но есть два ограничения. Не работает для тестов, которые фиксируют данные. Исключение parmExceptionExpected не поддерживается.

    В следующем примере кода показано, как использовать один из разных уровней изоляции. Для этого следует переопределить метод createSuite в своем тестовом примере и установить необходимое возвращаемое значение.

class SysTestSuite createSuite() 
{  
    // Set the isolation level to construct a company account for the  
    // entire test class.  
    return new SysTestSuiteCompanyIsolateClass(this); 
}

    Microsoft Dynamics AX включает в себя среду для создания, запуска, анализа и организации тестовых примеров. Тестовые примеры могут быть организованы в наборы тестов. Потребности в тестировании могут потребовать определенной логики до запуска теста или набора тестов. Кроме того, может потребоваться, чтобы логика работала для очистки среды тестирования или набора тестов. Среда UnitTest предоставляет возможность поэтапной обработки данных до и после выполнения тестового набора или тестовых примеров.

    Поскольку число тестовых методов растет для тестового примера или набора тестовых случаев, может потребоваться создать методы setUp и tearDown.

    Создание метода setUp

    Все методы тестирования будут вызывать эти методы для настройки и подготовки данных. Определите, что должно входить в метод установки, взглянув на методы тестирования, чтобы увидеть, какой код можно использовать повторно. В примере каждый метод тестирования создает экземпляр класса.

public void setUp() 
{  
    // Create an myclass instance to use in test cases.  
    myClass = new MyClass("your name");  
    super(); 
}

    Создание метода tearDown

public void tearDown() 
{  
    print 'Test method cleaning called...';  
    super(); 
}

    Применение

    Первый тест связан с одним сценарием в методе amoundCur2DebCred() таблицы LedgerJournalTrans:

[SysTestMethodAttribute]
public void positiveNoCorrection()
{
    // ARRANGE
    LedgerJournalTrans trans;

    // Set to non-zero values, because one side should be zeroed
    // and we want to test if it happens.
    trans.AmountCurDebit = 1.234;
    trans.AmountCurCredit = 1.234;

    // ACT
    trans.amountCur2DebCred(99);

    // ASSERT
    this.assertEquals(99, trans.AmountCurDebit);
    this.assertEquals(0, trans.AmountCurCredit);
}

    Можно легко увидеть три этапа. Подготовка всего необходимого для теста, запуск некоторой логики (либо возвращение значения, либо, как в этом случае, изменение состояния объекта), а затем проверка, что результат соответствует ожидаемому.

    Теперь запустите тест. Щелкните правой кнопкой мыши класс и выберите «Надстройки» > «Запустить тесты». Вы должны увидеть панель инструментов модульного теста, показывающую, что один тест был выполнен и успешно выполнен. Панель инструментов также можно открыть в меню «Инструменты» > «Юнит-тест»> «Показать панель инструментов».

Добавим еще один метод:

[SysTestMethodAttribute]
public void negativeNoCorrection()
{
    // ARRANGE
    LedgerJournalTrans trans;

    // Set to non-zero values, because one side should be zeroed
    // and we want to test if it happes.
    trans.AmountCurDebit = 1.234;
    trans.AmountCurCredit = 1.234;

    // ACT
    trans.amountCur2DebCred(-99);

    // ASSERT
    this.assertEquals(0, trans.AmountCurDebit);
    this.assertEquals(99, trans.AmountCurCredit);
}

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

class LedgerJournalTransTest extends SysTestCase
{
    LedgerJournalTrans trans;
}

public void setUp()
{
    super();

    // Set to non-zero values, because it one side should be zeroed
    // and we want to test if it happes.
    trans.AmountCurDebit = 1.234;
    trans.AmountCurCredit = 1.234;
}

[SysTestMethodAttribute]
public void negativeNoCorrection()
{
    // ACT
    trans.amountCur2DebCred(-99);

    // ASSERT
    this.assertEquals(0, trans.AmountCurDebit);
    this.assertEquals(99, trans.AmountCurCredit);
}

[SysTestMethodAttribute]
public void positiveNoCorrection()
{
    // ACT
    trans.amountCur2DebCred(99);

    // ASSERT
    this.assertEquals(99, trans.AmountCurDebit);
    this.assertEquals(0, trans.AmountCurCredit);
}

    Все работает точно так же, как и раньше, и все еще есть три шага. Было просто перемещена часть Arrange, чтобы сократить методы тестирования. Это делает их более читабельными, а также позволяет поддерживать код инициализации в одном месте.

    Все вышеприведенные тесты используют метод assertEquals, который является очень распространенным утверждением, но не единственным доступным. Если посмотреть на другие методы с именем assert*, можно найти несколько других, таких как assertTrue и assertNotNull. Также обратите внимание, что у каждого из них есть дополнительный параметр для настраиваемого сообщения о сбое.

    Рассмотрим один сценарий, который работает иначе, чем другие утверждения. Вызовем getAssetCompany с недопустимой транзакцией для этого конкретного метода. В результате этого он должен вызвать исключение — проверим, действительно ли он работает так, как задумано.

[SysTestMethodAttribute]
public void getAssetCompanyNotAssetType()
{
    trans.AccountType = LedgerJournalACType::Cust;
    trans.OffsetAccountType = LedgerJournalACType::Bank;
    trans.getAssetCompany();
}

    Да, исключение выдается правильно, но это приводит к сбою теста. Мы должны сказать фреймворку, что это на самом деле правильное поведение. Для этого необходимо передать в параметр parmExceptionExpected
значение true.

this.parmExceptionExpected (true).

[SysTestMethodAttribute]
public void getAssetCompanyNotAssetType()
{
    trans.AccountType = LedgerJournalACType::Cust;
    trans.OffsetAccountType = LedgerJournalACType::Bank;
    this.parmExceptionExpected(true);
    trans.getAssetCompany();
}

    Теперь фреймворк знает, что тест должен выдать исключение, и, если это произойдет, он все равно будет считаться выполненным успешно. Без указания параметра, получили бы ошибку при выполнении теста: «Ожидалось исключение».

    Зависимости

    Ранее были рассмотрены простые ситуации, тем не менее, обычно случаи гораздо сложнее. Например, класс, который необходимо проверить, использует другие объекты. Код, который не нужно тестировать в данный момент (потому что он станет слишком сложным). Отсутствие возможности контролировать с помощью нашего теста данные (например, значения, возвращаемые из внешней системы). Медленно выполнение и так далее. Решение состоит в том, чтобы устранить тесную связь между модулями и разрешить замену объектов зависимости чем-то, что предусмотрено тестом (внедрение зависимости). Следование этим принципам разработки также сделает код более гибким и простым в обслуживании, поэтому разработка кода для тестирования заставляет улучшить свой код в нескольких аспектах.

    Допустим существует зависимость от базы данных.

public Amount availPhysical(ItemId _itemId, InventDimId _inventDimId)
{
    InventSum is = InventSum::find(itemId, inventDimId);
    return is.PostedQty
            + is.Received
            - is.Deducted
            + is.Registered
            - is.Picked
            - is.ReservPhysical;
}

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

Если посмотреть на метод, можно заметить, что он делает две разные вещи — он находит запись InventSum и выполняет вычисления. Если разделить его на два метода, каждый со своей ответственностью, то тестирование вычислений станет тривиальным.

// The complicated calculation logic was extracted to this method
public Amount calcAvailPhysical(InventSum _is)
{
    return _is.PostedQty
            + _is.Received
            - _is.Deducted
            + _is.Registered
            - _is.Picked
            - _is.ReservPhysical;
}
// The remaining code is here
public Amount availPhysical(ItemId _itemId, InventDimId _inventDimId)
{
    return this.calcAvailPhysical(InventSum::find(_itemId, _inventDimId));
}

    Теперь можно легко писать тесты

InventSum is;
is.Received = 8;
is.ReservedPhysical = 5;
this.assertEquals(3, new MyClass.calcAvailPhysical(is));

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

    Другие случаи

    Давайте посмотрим на несколько других случаев, связанных с зависимостями. Допустим, есть довольно много значений, которые необходимо передать своему методу, и число параметров будет быстро расти. Всегда можете создать класс, функционирующий как контейнер данных, и использовать его вместо многих параметров (контракт данных — это тот же шаблон проектирования). В приведенном выше примере метод получил все в параметре. Но часто это не лучший дизайн. Другой вариант — установка зависимостей в состояние объекта. Необходимо протестировать класс MyClassTest, но к сожалению, он использует DependencyObject, который не нужно покрывать этим тестом.

class MyClassTest
{
    DependencyObject depObj;
    
    new()
    {
        depObject = DependencyObject::construct(MyParameters::find().DependencyType);
    }
}

    DependencyObject создается внутри MyClassTest, поэтому тест не может его контролировать. Кроме того, он инициализируется по-разному в зависимости от параметра в базе данных, поэтому тест должен был бы знать об этой детали реализации и изменять параметр по мере необходимости (со всеми проблемами, упомянутыми ранее). Поэтому просто немного изменим класс. Разрешим установку depObj с помощью метода parm*, сохраняя при этом логику.

class MyClassTest
{
    DependencyObject depObj;
    
    // Caller code can set the dependency through this method
    public DependencyObject parmDependencyObject(DependencyObject _depObj = depObj)
    {
        depObj = _depObj;
        return depObj;
    }
    
    // The class still offers a method for initialization from the parameter,
    // but it's now just one of possible ways.
    public static MyClassTest newDefault()
    {
        MyClassTest c = new MyClassTest();
        c.parmDependencyObject(DependencyObject::construct(MyParameters::find().DependencyType));
        return c;
    }
}

    Теперь тесты могут создавать DependencyObject любым способом, в котором они нуждаются.

MyClassTest c = new MyClassTest();
DependentObject d = ...
c.parmDependentObject(d);
... act and assert ...

    Это также делает решение более гибким — если появится новое требование, для которого нужно создать depObj каким-либо другим способом, возможно повторное использование класса без каких-либо изменений. Это еще один пример, когда проектирование для тестируемости ведет к созданию более надежной архитектуры.

Обратим внимание, что вы не хотите всегда показывать все зависимости в коде вызывающей стороны, потому что это выявит детали реализации, которые должны оставаться скрытыми в классе. Методика, показанная выше, отлично работает, если все, что нам нужно, это указать конкретное значение в тестируемую единицу, но иногда требуется реализовать другое поведение. Во многих случаях требуется просто «отключить» некоторую логику, мешающую тесту.

Допустим, метод doStuff() выполняет некоторую логику и нужно провести тест и распечатать отчет.

void doStuff() 
{  
    ... some interesting logic 
    ...  
    
    reportRun.run(); 
}

    Чтобы избежать всех потенциальных проблем с печатью внутри теста, можно заменить фактический reportRun на предназначенный для тестирования и переопределить в нем метод run.

MySRSReportRunDoingNothing extends SRSReportRun
{
    public void run()
    {
        // Nothing here
    }
}

    Во время теста передадим в качестве параметра фиктивный reportRun (так называемый тестовый тупик) в тестируемый класс.

MyClassTest c = new MyClassTest();
c.parmReportRun(new MySRSReportRunDoingNothing());
c.doStuff();
... assert ...

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

dev.goshoom.net

microsoft.com

 

Comments

So empty here ... leave a comment!

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

Sidebar