Как работает модульное тестирование?

23

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

public class TestAdder {
    public void testSum() {
        Adder adder = new AdderImpl();
        assert(adder.add(1, 1) == 2);
        assert(adder.add(1, 2) == 3);
        assert(adder.add(2, 2) == 4);
        assert(adder.add(0, 0) == 0);
        assert(adder.add(-1, -2) == -3);
        assert(adder.add(-1, 1) == 0);
        assert(adder.add(1234, 988) == 2222);
    }
}

Я чувствую, что этот тест совершенно бесполезен, потому что вы должны вручную вычислить желаемый результат и протестировать его, я чувствую, что лучший тестовый модуль здесь будет

assert(adder.add(a, b) == (a+b));

но тогда это просто кодирование самой функции в тесте. Может кто-нибудь дать мне пример, где юнит-тестирование действительно полезно? К вашему сведению, я в настоящее время кодирую в основном «процедурные» функции, которые принимают ~ 10 логических значений и несколько целочисленных значений и дают мне результат int, основанный на этом, я чувствую, что единственное модульное тестирование, которое я мог бы сделать, это просто перекодировать алгоритм в тест. редактировать: я также должен был уточнить, это при переносе (возможно, плохо разработан) рубиновый код (который я не сделал)

lezebulon
источник
14
How does unit testing work?Никто на самом деле не знает :)
Яннис
30
msgstr "вы должны вручную вычислить желаемый результат". Как это "абсолютно бесполезно"? Как еще вы можете быть уверены, что ответ правильный?
С.Лотт
9
@ S.Lott: это называется прогрессом, в древние времена люди использовали компьютеры для сокращения чисел и экономии времени, в наши дни люди тратят время на то, чтобы компьютеры могли обрабатывать цифры: D
кодер
2
@Coder: цель юнит-тестирования не в том, чтобы «хрустеть цифрами и сэкономить время»;)
Андрес Ф.
7
@lezebulon: пример из Википедии не очень хорош, но это проблема с этим конкретным тестовым примером, а не с модульным тестированием в целом. Около половины данных теста из этого примера не добавляет ничего нового, что делает его излишним (мне страшно подумать, что автор этого теста будет делать с более сложными сценариями). Более значимый тест разделил бы тестовые данные, по крайней мере, в следующих сценариях: «Может ли он добавлять отрицательные числа?», «Ноль нейтрален?», «Может ли он добавлять отрицательные и положительные числа?».
Андрес Ф.

Ответы:

26

Модульные тесты, если вы тестируете достаточно маленькие блоки, всегда утверждают, что они очевидны.

Причина, по которой add(x, y)даже упоминается о модульном тесте, заключается в том, что через некоторое время кто-нибудь зайдет addи поместит специальный код обработки налоговой логики, не понимая, что add используется везде.

Юнит - тесты являются очень много об ассоциативном принципе: если А делает B, и B делает С, то А делает C. «А делает C» тест на более высоком уровне. Например, рассмотрим следующий, полностью допустимый бизнес-код:

public void LoginUser (string username, string password) {
    var user = db.FetchUser (username);

    if (user.Password != password)
        throw new Exception ("invalid password");

    var roles = db.FetchRoles (user);

    if (! roles.Contains ("member"))
        throw new Exception ("not a member");

    Session["user"] = user;
}

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

public void LoginUser (string username, string password) {

    var user = _userRepo.FetchValidUser (username, password);

    _rolesRepo.CheckUserForRole (user, "member");

    _localStorage.StoreValue ("user", user);
}

Теперь мы до единиц. Один модульный тест не заботится о том, что _userRepoсчитается правильным поведением FetchValidUser, только о том, что он называется. Вы можете использовать другой тест, чтобы точно определить, что представляет собой действительный пользователь. Аналогично для CheckUserForRole... вы отделили свой тест от знания, как выглядит структура ролей. Вы также полностью отделили всю свою программу от привязки Session. Я полагаю, что все недостающие части будут выглядеть так:

class UserRepository : IUserRepository
{
    public User FetchValidUser (string username, string password)
    {
        var user = db.FetchUser (username);

        if (user.Password != password)
            throw new Exception ("invalid password");

        return user;
    }
}

class RoleRepository : IRoleRepository
{
    public void CheckUserForRole (User user, string role)
    {
        var roles = db.FetchRoles (user);

        if (! roles.Contains (role))
            throw new Exception ("not a member");
    }
}

class SessionStorage : ILocalStorage
{
    public void StoreValue (string key, object value)
    {
        Session[key] = value;
    }
}

Рефакторинг вы выполнили несколько вещей одновременно. Программа гораздо более полезна для удаления базовых структур (вы можете отказаться от слоя базы данных для NoSQL) или для плавного добавления блокировок, как только вы поймете, Sessionчто они не ориентированы на многопоточность или что-то в этом роде. Вы также дали очень простые тесты для написания этих трех зависимостей.

Надеюсь это поможет :)

Брайан Бетчер
источник
13

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

Я совершенно уверен, что каждая из ваших процедурных функций является детерминированной, поэтому она возвращает конкретный результат int для каждого заданного набора входных значений. В идеале у вас должна быть функциональная спецификация, из которой вы можете определить, какой результат вы должны получить для определенных наборов входных значений. В отсутствие этого вы можете запустить код ruby ​​(который, как предполагается, работает правильно) для определенных наборов входных значений, и записать результаты. Затем вам нужно жестко кодировать результаты в вашем тесте. Предполагается, что тест является доказательством того, что ваш код действительно дает результаты, которые, как известно, являются правильными .

Майк Накис
источник
+1 за запуск существующего кода и запись результатов. В этой ситуации это, вероятно, прагматичный подход.
MarkJ
12

Поскольку никто, кажется, не предоставил фактический пример:

    public void testRoman() {
        RomanNumeral numeral = new RomanNumeral();
        assert( numeral.toRoman(1) == "I" )
        assert( numeral.toRoman(4) == "IV" )
        assert( numeral.toRoman(5) == "V" )
        assert( numeral.toRoman(9) == "IX" )
        assert( numeral.toRoman(10) == "X" )
    }
    public void testSqrt() {
        assert( sqrt(4) == 2 )
        assert( sqrt(9) == 3 )
    }

Ты говоришь:

Я чувствую, что этот тест совершенно бесполезен, потому что вы должны вручную вычислить желаемый результат и протестировать его

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

Какова вероятность того, что вы допустили ошибку в своем десятичном коде? Довольно вероятно. Насколько вероятно, что вы допустили ошибку при ручном преобразовании десятичных чисел в римские? Не очень вероятно. Вот почему мы тестируем по ручным расчетам.

Какова вероятность того, что вы допустите ошибку при реализации функции квадратного корня? Довольно вероятно. Насколько вероятно, что вы допустили ошибку при расчете квадратного корня вручную? Вероятно, более вероятно. Но с sqrt вы можете использовать калькулятор, чтобы получить ответы.

К вашему сведению, я в настоящее время кодирую в основном «процедурные» функции, которые принимают ~ 10 логических значений и несколько целочисленных значений и дают мне результат int, основанный на этом, я чувствую, что единственное модульное тестирование, которое я мог бы сделать, это просто перекодировать алгоритм в тест

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

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

Я вернусь к коду ruby ​​и выполню эти оригинальные функции с различными параметрами. Я бы взял результаты кода ruby ​​и поместил их в модульный тест. Таким образом, вам не нужно делать ручной расчет. Но вы тестируете против исходного кода. Это должно помочь сохранить результаты одинаковыми, но если в оригинале есть ошибки, это не поможет. По сути, вы можете рассматривать исходный код как калькулятор в примере sqrt.

Если вы показали реальный код, который вы переносите, мы могли бы предоставить более подробный отзыв о том, как решить проблему.

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

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

Вы почти правы для такого простого класса.

Попробуйте это для более сложного калькулятора .. Как калькулятор счета в боулинге.

Ценность модульных тестов становится легче увидеть, когда у вас есть более сложные «бизнес» правила с различными сценариями для тестирования.

Я не говорю, что вы не должны проверять прогон обычного калькулятора (Выдает ли ваш счет калькулятора значения, такие как 1/3, которые не могут быть представлены? Что это делает с делением на ноль?), Но вы увидите цените более четко, если вы тестируете что-то с большим количеством веток, чтобы охватить

Брайан
источник
4
+1 для того, чтобы отметить, что это становится более полезным для сложных функций. Что если вы решили расширить adder.add () на значения с плавающей запятой? Матрицы? Значения счета Леже?
joshin4colours
6

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

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

Здесь есть ваша настоящая проблема. Если модульное тестирование кажется неестественно сложным или бессмысленным, то это, вероятно, из-за недостатка дизайна. Если бы он был более объектно-ориентированным, ваши сигнатуры методов не были бы такими массивными, и было бы меньше возможных входных данных для тестирования.

Мне не нужно идти в мой ОО превосходит процедурное программирование ...

maple_shaft
источник
в этом случае «подпись» метода не массивна, я просто читаю из std :: vector <bool>, который является членом класса. Я должен был также уточнить, что я портирую (возможно, плохо спроектированный) код рубина (который я не сделал)
lezebulon
2
@lezebulon Независимо от того, есть ли так много возможных входных данных для этого единственного метода, чтобы принять, тогда этот метод делает слишком много .
maple_shaft
3

На мой взгляд, юнит-тесты даже полезны для вашего маленького класса сумматоров: не думайте о «перекодировании» алгоритма и думайте о нем как о чёрном ящике, единственное знание о котором вы знаете - это функциональное поведение (если вы знакомы) с быстрым умножением вы знаете несколько более быстрых, но более сложных попыток, чем использование "a * b") и вашего открытого интерфейса. Чем вы должны спросить себя "Что, черт возьми, может пойти не так?" ...

В большинстве случаев это происходит на границе (я вижу, вы тестируете уже добавление этих шаблонов ++, -, + -, 00 - время, чтобы завершить их к - +, 0+, 0-, +0, -0). Подумайте о том, что происходит в MAX_INT и MIN_INT при сложении или вычитании (добавлении негативов;)). Или постарайтесь убедиться, что ваши тесты выглядят совершенно точно так, как это происходит в районе нуля.

В целом секрет очень прост (может быть, и для более сложных;)) для простых классов: подумайте о контрактах вашего класса (см. Дизайн по контракту), а затем протестируйте их. Чем лучше вы будете знать свои инв, пре и пост, тем «полнее» ваши тесты.

Подсказка для ваших тестовых классов: попробуйте написать только одно утверждение в методе. Дайте методам хорошие имена (например, «testAddingToMaxInt», «testAddingTwoNegatives»), чтобы получить наилучшую обратную связь, если ваш тест не пройден после изменения кода.

Себастьян Бауэр
источник
2

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

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

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

JGWeissman
источник
2

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

I feel that this test is totally useless, because you are required to manually compute the wanted result and test it, I feel like a better unit test here would be

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

Can someone provide me with an example where unit testing is actually useful?

У вас есть сотрудник Employee. Сущность содержит имя и адрес. Клиент решает добавить поле ReportsTo.

void TestBusinessLayer()
{
   int employeeID = 1234
   Employee employee = Employee.GetEmployee(employeeID)
   BusinessLayer bl = new BusinessLayer()
   Assert.isTrue(bl.Add(employee))//assume Add returns true on pass
}

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

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

I feel like the only unit testing I could do would be to simply re-code the algorithm in the test.

Даже процедурная логика легко инкапсулируется внутри функции. Инкапсулируйте, создайте экземпляр и передайте int / примитив для тестирования (или фиктивный объект). Не копируйте вставьте код в модульный тест. Это побеждает СУХОЙ. Он также полностью игнорирует тест, потому что вы тестируете не код, а копию кода. Если код, который должен был быть проверен, изменяется, тест все равно проходит!

P.Brian.Mackey
источник
<pedantry> «гам», а не «гамбит». </
pedantry
@chao LOL каждый день узнавать что-то новое.
P.Brian.Mackey
2

Принимая ваш пример (с небольшим рефакторингом),

assert(a + b, math.add(a, b));

не помогает:

  • понять, как math.addведет себя внутренне,
  • знать, что будет с крайними случаями.

Это почти как сказать:

  • Если вы хотите узнать, что делает метод, посмотрите сами сотни строк исходного кода (потому что, да, они math.add могут содержать сотни LOC; см. Ниже).
  • Я не беспокоюсь, если метод работает правильно. Это нормально, если ожидаемые и фактические значения отличаются от того, что я действительно ожидал .

Это также означает, что вам не нужно добавлять тесты, такие как:

assert(3, math.add(1, 2));
assert(4, math.add(2, 2));

Они не помогают ни, по крайней мере, после того, как вы сделали первое утверждение, второе не принесло ничего полезного.

Вместо этого, как насчет:

const numeric Pi = 3.1415926535897932384626433832795;
const numeric Expected = 4.1415926535897932384626433832795;
assert(Expected, math.add(Pi, 1),
    "Adding an integer to a long numeric doesn't give a long numeric result.");
assert(Expected, math.add(1, Pi),
    "Adding a long numeric to an integer doesn't give a long numeric result.");

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

Test TestNumeric() failed on assertion 2, line 5: Adding a long numeric to an
integer doesn't give a long numeric result.

Expected value: 4.1415926535897932384626433832795
Actual value: 4

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

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

По той же причине два следующих утверждения могут иметь смысл в некоторых языках:

// We don't expect a concatenation. `math` library is not intended for this.
assert(0, math.add("Hello", "World"));

// We expect the method to convert every string as if it was a decimal.
assert(5, math.add("0x2F", 5));

Кроме того, как насчет:

assert(numeric.Infinity, math.add(numeric.Infinity, 1));

Это говорит само за себя: вы хотите, чтобы ваш метод мог правильно справляться с бесконечностью. Собирается за пределы бесконечности или создание исключения не является ожидаемым поведением.

Или, может быть, в зависимости от вашего языка, это будет иметь больше смысла?

/**
 * Ensures that when adding numbers which exceed the maximum value, the method
 * fails with OverflowException, instead of restarting at numeric.Minimum + 1.
 */
TestOverflow()
{
    UnitTest.ExpectException(ofType(OverflowException));

    numeric result = math.add(numeric.Maximum, 1));

    UnitTest.Fail("The tested code succeeded, while an OverflowException was
        expected.");
}
Арсений Мурзенко
источник
1

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

Подумайте о том, что вы делаете, когда программируете (без юнит-тестирования). Обычно вы пишете некоторый код, запускаете его, видите, что он работает, и переходите к следующему, верно? По мере того, как вы пишете больше кода, особенно в очень больших системах / графических интерфейсах / веб-сайтах, вы обнаруживаете, что вам нужно все больше и больше «запускать и проверять, работает ли он». Вы должны попробовать это и попробовать это. Затем вы вносите несколько изменений, и вам приходится пробовать одни и те же вещи снова и снова. Становится очень очевидным, что вы могли бы сэкономить время, написав модульные тесты, которые бы автоматизировали всю часть «запуска и проверки работоспособности».

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

Асаф
источник
1

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

утес
источник
0

Возможно, вы предполагаете, что add () был реализован с помощью инструкции ADD. Если какой-то младший программист или инженер по аппаратному обеспечению повторно реализовал функцию add (), используя ANDS / ORS / XORS, инвертирование битов и сдвиги, вы можете захотеть протестировать его по команде ADD.

В общем, если вы замените кишки add () или тестируемого модуля на генератор случайных чисел или выходных данных, как вы узнаете, что что-то сломалось? Закодируйте эти знания в своих модульных тестах. Если никто не может сказать, сломался ли он, просто зарегистрируйте код для rand () и идите домой, ваша работа выполнена.

hotpaw2
источник
0

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

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

Вот где ваши юнит-тесты действительно вступают в свои права.

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

Фил В.
источник