Статьи

Методы проектирования для улучшения модульных тестов

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

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

Нестандартные тесты

Я написал простой класс, чтобы объяснить идеи статьи.

public static class TransportSubscriptionCardPriceCalculator
{
    public static decimal CalculateSubscriptionPrice(string ageInput)
    {
        decimal subscriptionPrice = default(decimal);
        int age = default(int);
        bool isInteger = int.TryParse(ageInput, out age);

        if (!isInteger)
        {
            throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
        }

        if (age <= 0)
        {
            throw new ArgumentException("The age should be greater than zero."); 
        }
        else if (age > 0 && age <= 5)
        {
            subscriptionPrice = 0;
        }
        else if (age > 5 && age <= 18)
        {
            subscriptionPrice = 20;
        }
        else if (age > 18 && age < 65)
        {
            subscriptionPrice = 40;
        }
        else if (age >= 65 && age <= 122)
        {
            subscriptionPrice = 5;
        }
        else
        {
            throw new ArgumentException("The age should be smaller than 123."); 
        }

        return subscriptionPrice;
    }
}

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

0 <Возраст <= 5 — Цена = 0 лв.

5 <Возраст <= 18 — Цена = 20 лв.

18 <Возраст <65 — Цена = 40 лв.

65 <= Возраст <= 122 — Цена = 5 лв.

На мой взгляд, большинство разработчиков склонны писать тесты на основе своего кода. Сначала они читают спецификацию, пишут свой код, а затем разрабатывают свои тесты на основе самого кода. Они направлены на достижение покрытия коды 100% , а не 100% спецификация покрытия . Когда я думаю об этой тенденции, я спрашиваю себя: «Зачем вам писать тесты, которые не пройдут, если они основаны на коде, который уже может содержать ошибки?»

Для достижения 100% покрытия кода требуется всего семь тестов. В качестве примеров тестов я собираюсь использовать NUnit из-за его удобных атрибутов (вы можете посмотреть обзор инструмента производительности Telerik’s Devcraft от Джона, если хотите больше поиграть с NUnit).

[TestFixture]
public class TransportSubscriptionCardPriceCalculatorTests
{
    private const string GreaterThanZeroExpectionMessage = "The age should be greater than zero.";
    private const string SmallerThan123ExpectionMessage = "The age should be smaller than 123.";
    private const string ShouldBeIntegerExpectionMessage = "The age input should be an integer value between 0 - 122.";

    [Test]
    public void ValidateCalculateSubscriptionPrice_Free([Random(min: 1, max: 5, count: 1)]
                                                                        int ageInput)
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput.ToString());

        Assert.AreEqual(0, actualPrice);
    }

    [Test]
    public void ValidateCalculateSubscriptionPrice_20lv([Random(min: 6, max: 18, count: 1)]
                                                                        int ageInput)
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput.ToString());

        Assert.AreEqual(20, actualPrice);
    }

    [Test]
    public void ValidateCalculateSubscriptionPrice_40lv([Random(min: 19, max: 64, count: 1)]
                                                                        int ageInput)
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput.ToString());

        Assert.AreEqual(40, actualPrice);
    }

    [Test]
    public void ValidateCalculateSubscriptionPrice_5lv([Random(min: 65, max: 122, count: 1)]
                                                                        int ageInput)
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput.ToString());

        Assert.AreEqual(5, actualPrice);
    }

    [Test]
    [ExpectedException(typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
    public void ValidateCalculateSubscriptionPrice_NotInteger()
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice("invalid");

        Assert.AreEqual(5, actualPrice);
    }

    [Test]
    [ExpectedException(typeof(ArgumentException), ExpectedMessage = GreaterThanZeroExpectionMessage)]
    public void ValidateCalculateSubscriptionPrice_InvalidZero()
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice("0");

        Assert.AreEqual(5, actualPrice);
    }

    [Test]
    [ExpectedException(typeof(ArgumentException), ExpectedMessage = SmallerThan123ExpectionMessage)]
    public void ValidateCalculateSubscriptionPrice_InvalidGreater122()
    {
        decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice("1000");

        Assert.AreEqual(5, actualPrice);
    }
}

Случайные? В самом деле? Вы можете быть шокированы, но многие разработчики склонны использовать эту технику в своих тестах. В первый раз, когда я увидел что-то похожее на код выше, я нанес себе на лицо не менее 5 минут Использование случайных данных в ваших тестах приводит к ненадежным результатам теста. С некоторыми из сгенерированных значений тест может быть зеленым, а с другими — красным.

Тестовые примеры на основе кода

[Случайно (мин: 1, макс: 5, кол-во: 1)], затем цена = 0 , в первую очередь, если.

else if (age > 0 && age <= 5)
{
    subscriptionPrice = 0;
}

[Случайно (мин: 6, макс: 18, количество: 1)], затем цена = 20 , покрывает секунду, если.

else if (age > 5 && age <= 18)
{
    subscriptionPrice = 20;
}

[Случайно (мин .: 19, макс: 64, кол: 1)], затем цена = 40 , охватывает третий.

еlse if (age > 18 && age < 65)
{
    subscriptionPrice = 40;
}

[Случайно (мин: 65, макс: 122, кол: 1)], затем цена = 5 , покрывает старшую цену.

else if (age >= 65 && age <= 122)
{
    subscriptionPrice = 5;
}

AgeInput = «invalid» , проверяет сценарий первого исключения, когда пользователь передает нецелое значение.

if (!isInteger)
{
    throw new ArgumentException("The age input should be an integer value between 0 - 122.");    
}

AgeInput = «0» , охватывает вторую защитную проверку.

if (age <= 0)
{
    throw new ArgumentException("The age should be greater than zero."); 
}

AgeInput = «1000» , заставьте тест пройти последнюю проверку достоверности на предмет максимального возраста.

else
{
    throw new ArgumentException("The age should be smaller than 123."); 
}

Всего за семь тестовых случаев нам удалось достичь 100% покрытия кода. Тем не менее, весьма вероятно, что эти тестовые случаи не будут обнаруживать ошибки регрессии, если кто-то, например, изменит один из условных операторов «<«,> »,> =» или «<=». Кроме того, такой подход к написанию тестов не гарантирует правильность кода. Если тесты основаны на глючном коде, они не помогут нам предоставить более качественное и беспроблемное программное обеспечение. Именно здесь нам могут помочь методы разработки тестов на основе спецификаций .

Тесты на основе спецификаций: на основе разделения эквивалентности

Во-первых, позвольте мне рассказать, что означает тестирование на основе спецификаций.

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

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

Эквивалентная гипотеза разбиения

Разделенные наборы называются разделами эквивалентности или классами эквивалентности. Затем мы выбираем только одно значение из каждого раздела для тестирования. Гипотеза, лежащая в основе этого метода, состоит в том, что если одно условие / значение в разделе проходит, все остальные также пройдут . Аналогичным образом, если одно условие в разделе не выполняется, все остальные условия в этом разделе также не будут выполнены .

Легко проверить небольшие входные диапазоны, например, 1-10, но сложно протестировать, например, 2-10000. Эквивалентность Partitioning помогает нам следовать один из принципов Семь тестирования :

Исчерпывающее тестирование невозможно : тестирование всего, включая все комбинации входов и предварительных условий, невозможно. Вместо того, чтобы проводить полное тестирование, мы можем использовать риски и приоритеты, чтобы сосредоточить наши усилия по тестированию. Например: в приложении на одном экране имеется 15 полей ввода, каждое из которых имеет 5 возможных значений. Чтобы протестировать все действительные комбинации, вам потребуется 30 517 578 125 (515) тестов. Это весьма маловероятно , что проект Сроки позволят этому количеству тестов. Оценка и управление рисками является одним из наиболее важных действий и причин для тестирования в любом проекте.

Иногда может быть дешевле написать от 1 до 10 тестов для охвата диапазонов наборов, таких как 1-10, но в большинстве случаев не так хорошо писать 100 000 или миллионы тестов для больших наборов. Таким образом, мы можем использовать основанные на спецификации методы проектирования тестов, чтобы сократить количество тестовых случаев до необходимого минимума .

Если мне придется написать ранее упомянутый код для производства, а также протестировать его, я, вероятно, буду использовать Test Driven Development . Затем я спроектирую тестовые сценарии на основе требований спецификации.


private const string GreaterThanZeroExpectionMessage = "The age should be greater than zero.";
private const string SmallerThan123ExpectionMessage = "The age should be smaller than 123.";
private const string ShouldBeIntegerExpectionMessage = "The age input should be an integer value between 0 - 122.";

[TestCase("0", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = GreaterThanZeroExpectionMessage)]
[TestCase("5", 0)]
[TestCase("15", 20)]
[TestCase("25", 40)]
[TestCase("80", 5)]
[TestCase("1000", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = SmallerThan123ExpectionMessage)]
[TestCase("invalid", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
public void ValidateCalculateSubscriptionPrice(string ageInput, decimal expectedPrice)
{
    decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput);

    Assert.AreEqual(expectedPrice, actualPrice);
}

Как вы можете видеть, в своих тестах я использую атрибут NUnit TestCase . Как только метод будет выполнен, будет выполнено семь тестов на основе значений, предоставленных через атрибуты. Первое значение представляет ageInput; вторая — ожидаемая цена.

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

Пример разделов таблицы разделения эквивалентности

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

Ошибки разделения эквивалентности, чтобы помнить

Хотя этот метод относительно прост, люди допускают некоторые распространенные ошибки при его применении.

  1. Разные подмножества не могут иметь общего члена. Если значение присутствует в двух разделах, вы не можете определить, как оно должно вести себя в разных случаях.
  2. Ни одно из подмножеств не может быть пустым. Если вы не можете выбрать тестовое значение из набора, оно не пригодно для тестирования.

Тесты на основе спецификаций: на основе анализа граничных значений

Так что же такое анализ граничных значений?

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

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

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

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

Все ли классы эквивалентности имеют граничные значения?

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

Сколько граничных значений существует?

Существует два представления о том, сколько граничных значений должно существовать. Большинство людей считают, что только два значения должны быть получены из каждого края раздела эквивалентности. Таким образом, в следующем условии 0 <Age> 6 для первого ребра граничные значения будут равны 0, 1, а для второго предела 5, 6 .

В своей книге « Тестирование программной системы и обеспечение качества» Борис Бейзер объясняет другой вариант: три значения на границу, где каждое ребро считается одним из тестовых значений в дополнение к каждому из его соседей. Для предыдущего условия 0 <Age> 6, для 0 тестовые значения будут -1, 0 и 1. Для 6 тестовыми значениями будут сами 6, 5 и 7.

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

Тесты с использованием анализа граничных значений

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

private const string GreaterThanZeroExpectionMessage = "The age should be greater than zero.";
private const string SmallerThan123ExpectionMessage = "The age should be smaller than 123.";
private const string ShouldBeIntegerExpectionMessage = "The age input should be an integer value between 0 - 122.";

[TestCase("-1", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = GreaterThanZeroExpectionMessage)]
[TestCase("0", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = GreaterThanZeroExpectionMessage)]
[TestCase("1", 0)]
[TestCase("4", 0)]
[TestCase("5", 0)]
[TestCase("6", 20)]
[TestCase("17", 20)]
[TestCase("18", 20)]
[TestCase("19", 40)]
[TestCase("64", 40)]
[TestCase("65", 5)]
[TestCase("66", 5)]
[TestCase("121", 5)]
[TestCase("122", 5)]
[TestCase("123", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = SmallerThan123ExpectionMessage)]
[TestCase("a", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
[TestCase("", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
[TestCase(null, 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
[TestCase("2147483648", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
[TestCase("–2147483649", 0, ExpectedException = typeof(ArgumentException), ExpectedMessage = ShouldBeIntegerExpectionMessage)]
public void ValidateCalculateSubscriptionPrice1(string ageInput, decimal expectedPrice)
{
    decimal actualPrice = TransportSubscriptionCardPriceCalculator.CalculateSubscriptionPrice(ageInput);

    Assert.AreEqual(expectedPrice, actualPrice);
}

Чтобы обеспечить 100% покрытие анализа граничных значений, вам нужны только первые 16 тестов. Однако я добавил еще четыре теста, потому что иногда, даже если значения тестов принадлежат общему разделу эквивалентности, это не значит, что они будут давать одинаковый результат. Итак, я протестировал CalculateSubscriptionPrice со значением null, string.Empty, int.Max + 1 и int.Minimum — 1.

Граничные значения, основанные на требованиях

  1. 0 <Возраст <= 5 — Левый край: -1, 0, 1 Правый край: 4, 5, 6
  2. 5 <Возраст <= 18 — Левый край: 4, 5, 6 Правый край: 17, 18, 19
  3. 18 <Возраст <65 — Левый край: 17, 18, 19 Правый край: 64, 65, 66
  4. 65 <= Возраст <= 122 — Левый край: 64, 65, 66 Правый край: 121, 122, 123

Где бы вы нашли граничные значения?

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

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

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

Вывод

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