5_3_1_10_unit_test.md 16 KB

Предыдущая лекция   Следующая лекция
Создание библиотеки классов Содержание Fake data. Тестирование методов получающих внешние данные из удалённых источников

Создание UNIT-тестов

Немного теории

Именование проектов

в С# тест реализуется как отдельный проект в том же "решении".

К наименованию проекта добавляется суффикс ".Tests". То есть, если основной проект у нас называется, например, "Demo", то тестирующий проект должен называться "Demo.Tests"

Именование классов

Внутри тестового проекта тестирующие классы тоже должны заканчиваться словом "Tests" (тестирующий класс, это класс, который тестирует соответсвующий класс в основном приложении. В тестирующем приложении могут быть и другие вспомогательные классы, правила именования для них обычные), например, для класса "UserManager" создается тестирующий класс "UserManagerTests".

Именование методов

Принцип именования методов тестирующего класса:

[Тестируемый метод]_[Сценарий]_[Ожидаемое поведение]

Примеры:

  • Sum_10plus20_30returned
  • GetPasswordStrength_AllChars_5Points

Концепция ААА

Любой тест проходит три стадии:

  1. Arrange - подготовка тестовых данных

    // исходные данные
    int x = 10;
    int y = 20;
    // ожидаемый результат
    int expected = 30;
    
  2. Act - выполнение основного действия тестируемым классом

    int actual = Calc.Sum(x, y)
    
  3. Assert - проверка результата

    Assert.AreEqual(expected, actual);
    

Для проверки результатов используется класс Assert, который реализует множество методов для проверки.

Атрибуты

Перед методами тестирующего класса могут быть указаны атрибуты:

  • [TestClass] - тестирующий класс
  • [TestMethod] - тестирующий метод

  • [TestInitialize] - метод для инициализации, вызывается перед каждым тестирующим методом

  • [TestCleanup] - метод для освобождения ресурсов, вызывается после каждого тестирующего метода

  • [ClassInitialize] - вызывается один раз для тестирующего класса, перед запуском тестирующего метода

  • [ClassCleanup] - вызывается одина раз для тестирующего класса, после завершения тестирующих методов

  • [AssemblyInitialize] - вызывается перед тем, как начнут работать тестирующие методы в сборке

  • [AssemblyCleanup] - вызывается после завершения тестирующих методов в сборке

Assertion - сравнение разных типов данных

  • Assert

    • сравнение двух входящих значений
    • множество методов для сравнения
  • CollectionAssert

    • сравнение двух коллекций
    • проверка элементов в коллекции
  • StringAssert

    • сравнение строк

Основные методы класса Assert

Assert.AreEqual()
Проверка двух аргументов на равенство

Assert.AreSame()
Проверяет, ссылаются ли переменные на одну и ту же область памяти

Assert.InstanceOfType()
Метод для проверки типа объекта

Assert.IsTrue, Asser.IsFalse
Проверка логических конструкций

Практика

В решение, где у вас создана библиотека классов, добавьте проект "Unit Test Project":

  • Правила формирования имени тестового проекта были выше, у меня получилось CompanyCoreLib.Tests.
  • Расположение не трогаем, по-умолчанию тестовый проект сохранится в том же решении (рядом с основным проектом).
  • Тип тестов: MSTest

Получится такая "рыба":

namespace CompanyCoreLib.Tests;

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public void TestMethod1()
    {
    }
}

Связь с основным проектом

Добавьте связь с основным проектом (как делали в прошлой лекции в консольном приложении).

Написание тестов

Для начала нужно выбрать в тестируемом проекте (CompanyCoreLib) класс, который мы хотим протестировать - у нас там один класс, созданный именно для тестирования: Analytics.

Тестирующий класс переименуйте в AnalyticsTests (по соглашению к имени тестируемого класса добавляется суффикс Tests)

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

и реализуем этот метод:

[TestMethod]
public void PopularMonths_UniqueDates_OnlySort()
{
    // Arrange - подготовка исходных данных
    var srcDates = new List<DateTime>()
    {
        new DateTime(2023,12,18,12,30,0),
        new DateTime(2023,11,21,20,10,0),
    };
    
    /**
    * Act - действие
    * тут мы должны создать экземпляр класса, чтобы протестировать его
    */
    var myAnalytics = new Analytics();
    
    // выполняем метод и получаем результат
    var outDates = myAnalytics.PopularMonths(srcDates);
    
    /**
    * подгатавливаем данные для 
    * ожидаемого результата
    * просто оригинальный список, 
    * но с сортировкой по возрастанию
    */
    var expectedDates = new List<DateTime>()
    {
        new DateTime(2023,11,1,0,0,0),
        new DateTime(2023,12,1,0,0,0)
    };
    
    // Assert - сравниваем ожидаемый результат и данные полученные от тестируемого метода
    CollectionAssert.AreEqual(expectedDates, outDates);

}

При сравнении скалярных данных (числа, строки...) используется класс Assert. Но так как у нас не скалярный тип данных (List), то нужно использовать соответствующий класс - нам подходит CollectionAssert

При ошибке сравнения данных методы Assert вызывают исключения, по которым система и определяет пройден тест или нет.

Запуск тестов

  1. Пересоберите решение, чтобы убедиться что нет ошибок

  2. Запустите тест: Tests -> Run Unit Test

    Статус тестов будет показан в отдельном окне:

Оптимизация

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

Чтобы избежать повторений и очистить код добавим служебные методы инициализации и финализации:

// сам экземпляр класса "Analytics" мы объявляем 
// как свойство тестирующего класса
private static Analytics myAnalytics = null;

/* используем атрибут [ClassInitialize], 
который скажет тестировщику, 
что этот метод нужно выполнить ДО запуска тестов 
(поэтому и этот метод и свойство myAnalytics 
должны быть объявлены статическими)*/
[ClassInitialize]
static public void Init(TestContext tc)
{
    myAnalytics = new Analytics();
}

// и аналогично для завершения
[ClassCleanup]
static public void Done()
{
    myAnalytics = null;
}

А в методе PopularMonths_UniqueDates_OnlySort создание экземпляра myAnalytics убрать.

Добавление тестовых ситуаций (case)

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

  • проверяем что результат НЕ РАВЕН заведомо не правильному результату

    [TestMethod]
    public void PopularMonths_UniqueDates_NotEmpty()
    {
        // Arrange - подготовка исходных данных
        var srcDates = new List<DateTime>()
        {
            new DateTime(2023,12,1,0,0,0),
            new DateTime(2023,11,1,0,0,0),
        };
            
        // Act - действие
        var outDates = myAnalytics.PopularMonths(srcDates);
            
        /**
         * Arrange - подгатавливаем данные для ожидаемого результата
         * пустой список для проверки не ожиданного результата
         */
        var expectedDates = new List<DateTime>();
            
        CollectionAssert.AreNotEqual(expectedDates, outDates);
    }
    
  • используя атрибут ExpectedException, проверяем наличие исключения при делении на 0

    Наш метод исключений не вызывает, поэтому тут "сферический конь в вакууме" от другого метода

    [TestMethod]
    [ExpectedException(typeof(DivideByZeroException), "Деление на 0")]
    public void Div_2div0_exceptionexpected()
    {
        //arrange
        float a = 2;
        float b = 0;
        float expected = 2;
    
        //act
        float actual = MyCalc.Div(a, b);
    
        //assert
        Assert.AreNotEqual(actual, expected);
    }
    

    То же самое можно сделать без использования атрибута, а просто завернув код в блок try..catch, и в секции try после вычисления вставить Assert.Fail(); (т.е. если при делении на 0 вдруг попадем на этот код, то тест не пройден)

Ближе к телу

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

Сделаем тестирование сохранения услуги в БД.

  1. Вынесем код, сохраняющий данные об услуге, в отдельный метод класса Core.

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

    // добавляем исключения на каждый вариант проверки
    public class ServiceEmptyCost : Exception {
        public ServiceEmptyCost(string Mesage): base(Mesage) { }
    }
    
    public class ServiceInvalidDiscount : Exception
    {
        public ServiceInvalidDiscount(string Mesage) : base(Mesage) { }
    }
    
    public class Core
    {
        public static spenkinEntities DB = new spenkinEntities();
    
        // добавляем статический метод, который осуществляет все проверки
        // если есть ошибки, то он выкинет исключение
        public static void SaveService(Service SavedService) {
            if (SavedService.Cost <= 0)
                throw new ServiceEmptyCost("Не заполнена цена");
    
            if (SavedService.Discount < 0 || SavedService.Discount > 1)
                throw new ServiceInvalidDiscount("Скидка должна быть в диапазоне 0..1");
    
            if (SavedService.ID == 0)
                DB.Service.Add(SavedService);
    
            DB.SaveChanges();
        }
    }
    
  2. Реализуем тестирование

    namespace UnitTestProject1
    {
        [TestClass]
        public class UnitTest1
        {
            [TestMethod]
            [ExpectedException(typeof(ServiceEmptyCost), "Не заполнена цена")]
            public void TestMethod1()
            {
                var NewService = new Service();
                Core.SaveService(NewService);
                // досюда мы доходить не должны
                Assert.Fail();
            }
        }
    }
    

    Тут используется атрибут ExpectedException. Он означает, что НОРМАЛЬНЫМ завершением этого теста будет исключение указанного типа.


Задание

Реализовать тестовые сценарии, разработанные в прошлом задании


Предыдущая лекция   Следующая лекция
Создание библиотеки классов Содержание Fake data. Тестирование методов получающих внешние данные из удалённых источников