Як працює одиничне тестування?


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));

але тоді це просто кодування самої функції в тесті. Чи може хтось надати мені приклад, коли тестування одиниць корисно? FYI Я в даний час кодую здебільшого "процедурні" функції, які приймають ~ 10 булевих і декількох вхідних даних і дають мені результат int на основі цього, я відчуваю, що єдиним тестуванням одиниці, яке я міг би зробити, було б просто перекодувати алгоритм у тест. редагувати: я також повинен був би точно впорядкувати це під час перенесення (можливо, погано розробленого) рубінового коду (що я не робив)


14
How does unit testing work?Ніхто насправді не знає :)
yannis

30
"Ви повинні вручну обчислити бажаний результат". Як це "абсолютно марно"? Як ще можна бути впевненим, що відповідь правильна?
С. Лотт

9
@ S.Lott: Це називається прогрес, в стародавні часи люди використовували комп’ютери для стискання чисел та економії часу, в сучасні дні люди витрачають час, щоб переконатися, що комп'ютери можуть розчавити цифри: D
Coder

2
@Coder: мета тестового опромінення не "стискати числа та економити час";)
Андрес Ф.

7
@lezebulon: приклад з Вікіпедії не дуже хороший, але це проблема в тому конкретному тестовому випадку, а не в одиничному тестуванні в цілому. Близько половини тестових даних із прикладу не додає нічого нового, що робить його зайвим (я боявся думати, що зробить автор цього тесту при складніших сценаріях). Більш змістовний тест розділив би дані тесту принаймні на наступні сценарії: "чи можна додати від'ємні числа?", "Чи нуль нейтральний?", "Чи можна додати від'ємне та додатне число?".
Андрес Ф.

Відповіді:


26

Якщо ви протестуєте досить малі одиниці, одиничні тести завжди стверджують, що сліпо очевидно.

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

Одиничні тести дуже багато стосуються асоціативного принципу: якщо A робить B, а B робить C, то A робить C. "A does 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 булевих і декількох ints і дають мені результат int на основі цього, я відчуваю, що єдине тестування одиниці, яке я могла би зробити, було б просто перекодувати алгоритм у тесті

Я майже впевнений, що кожна з ваших процедурних функцій є детермінованою, тому вона повертає певний результат int для кожного заданого набору вхідних значень. В ідеалі, у вас є функціональна специфікація, з якої ви можете зрозуміти, який результат ви повинні отримати для певних наборів вхідних значень. У відсутності цього, ви можете запустити рубіновий код (який, як вважається, працює правильно) для певних наборів вхідних значень, і записати результати. Тоді вам потрібно ЗАКРИТИ КОДУВАННЯ результатів у свій тест. Тест повинен бути доказом того, що ваш код дійсно дає результати, які, як відомо, є правильними .


+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 ви можете використовувати калькулятор, щоб отримати відповіді.

FYI Я в даний час кодую здебільшого "процедурні" функції, які приймають ~ 10 булевих і декількох вхідних даних і дають мені результат int на основі цього, я відчуваю, що єдиним тестуванням одиниці, яке я міг би зробити, було б просто перекодувати алгоритм у тест

Тому я збираюся міркувати про те, що тут відбувається. Ваші функції начебто складні, тому з входів важко зрозуміти, яким повинен бути вихід. Для цього вам потрібно вручну виконати (в голові) функцію, щоб зрозуміти, що таке вихід. Зрозуміло, що це видається марним і схильним до помилок.

Ключовим є те, що ви хочете знайти правильні виходи. Але ви повинні перевірити ці результати на щось, як відомо, що це правильно. Недоцільно написати власний алгоритм, щоб обчислити це, оскільки це може бути невірним. У цьому випадку занадто важко вручну обчислювати значення.

Я б повернувся до коду рубіну і виконав ці оригінальні функції з різними параметрами. Я б взяв результати рубінового коду і помістив їх у тест одиниці. Таким чином, вам не доведеться робити ручний розрахунок. Але ви тестуєте оригінальний код. Це має допомогти зберегти результати однаковими, але якщо в оригіналі є помилки, це вам не допоможе. В основному, ви можете ставитися до оригінального коду, як до калькулятора, у прикладі sqrt.

Якщо ви показали фактичний код, який ви переносите, ми могли б надати більш детальні відгуки про те, як підійти до проблеми.


І якщо в коді Ruby є помилка, про яку ви нічого не знаєте, це не у вашому новому коді, і ваш код не дає тесту на одиницю на основі виходів Ruby, то розслідування того, чому він не вдалося, в кінцевому підсумку виявить вас і призведе до виявлена ​​латентна помилка Рубі. Так що це круто.
Адам Вюрл

11

Я відчуваю, що єдиним тестуванням одиниці, яке я міг би зробити, було б просто перекодувати алгоритм у тесті

Ви майже правильні для такого простого класу.

Спробуйте його для більш складного калькулятора .. Як калькулятор оцінки боулінгу.

Значення одиничних тестів легше зрозуміти, коли у вас є складніші "ділові" правила з різними сценаріями.

Я не кажу, що ви не повинні перевіряти запуск калькулятора фрези (чи видає ваш обліковий запис калькулятора такі значення, як 1/3, які неможливо представити? Що це стосується ділення на нуль?), Але ви побачите чіткіше значення, якщо ви протестуєте щось із більшою кількістю гілок, щоб отримати охоплення.


4
+1 для відзначення стає кориснішим для складних функцій. Що робити, якщо ви вирішили поширити adder.add () на значення з плаваючою комою? Матриці? Значення облікового запису?
joshin4colours

6

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

В даний час я кодую здебільшого "процедурні" функції, які займають ~ 10 булевих і декількох ввід, і дають мені результат на основі цього

Тут є ваша справжня проблема. Якщо тестування блоку здається неприродно жорстким або безглуздим, то це, ймовірно, з-за вад дизайну. Якби було більш об'єктно орієнтовано, підписи методу не були б такими масивними, і для тестування було б менше можливих входів.

Мені не потрібно входити в мій ОО перевершує процедурне програмування ...


у цьому випадку "підпис" методу не є масовим, я просто читав із std :: vector <bool>, який є членом класу. Я також повинен точно уточнити, що я переношу (можливо, погано розроблений) рубіновий код (який я не робив)
lezebulon

2
@lezebulon Незалежно від того, чи існує багато можливих вхідних даних для цього єдиного методу, тоді цей метод робить занадто багато .
maple_shaft

3

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

У більшості випадків це відбувається на кордоні (я бачу, ви перевіряєте, що вже додаєте ці шаблони ++, -, + -, 00 - час, щоб виконати їх на - +, 0+, 0-, +0, -0). Подумайте, що відбувається в MAX_INT та MIN_INT при додаванні чи відніманні (додавання негативів;)) там. Або спробуйте переконатися, що ваші тести виглядають точно так, як це відбувається в нуль і близько нього.

Загалом секрет дуже простий (можливо, і для складніших;)) для простих класів: продумайте контракти свого класу (див. Дизайн за контрактом), а потім протестуйте проти них. Чим краще ви знаєте ваші запрошення, попередні публікації та публікації - тим "завершальним" буде ваш тест.

Підказка щодо тестових занять: спробуйте написати лише одне твердження методом. Дайте методам хороші імена (наприклад, "testAddingToMaxInt", "testAddingTwoNegatives"), щоб мати найкращі відгуки, коли ваш тест закінчується після зміни коду.


2

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

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

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


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?

У вас є особа працівника. Суб'єкт господарювання містить ім'я та адресу. Клієнт вирішує додати поле 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 для роботи з працівником. Код передасть / провалить щойно внесені вами зміни схеми. Пам'ятайте, що твердження - не єдине, що робить тест. Пробіг через код також гарантує, що жодних винятків не буде замішано.

З часом наявність тестів полегшує загальні зміни. Код автоматично перевіряється на винятки та на ваші твердження. Це дозволяє уникнути значних витрат, які виникли в результаті ручного тестування групою QA. Хоча автоматичний інтерфейс все ще досить важко автоматизувати, інші шари, як правило, дуже прості, якщо правильно використовувати модифікатори доступу.

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

Навіть процедурна логіка легко інкапсулюється всередині функції. Інкапсулювати, інстанціювати та передавати в int / примітиві, що підлягає тестуванню (або знущається над об'єктом). Не копіюйте вставити код у Unit Test. Це перемагає DRY. Він також цілком перемагає тест, оскільки ви не тестуєте код, а копію коду. Якщо код, який слід було перевірити, зміниться, тест все-таки пройде!


<pedantry> "gamut", а не "gambit". </
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

Для такої простої функції, як додавання, тестування може вважатися непотрібною, але, оскільки ваші функції стають складнішими, стає все більш очевидним, чому потрібно тестування.

Подумайте, що ви робите під час програмування (без тестування одиниць). Зазвичай ви пишете якийсь код, запускаєте його, бачите, що він працює, і переходите до наступного, правда? Коли ви пишете більше коду, особливо в дуже великій системі / GUI / веб-сайті, ви виявляєте, що вам доведеться робити все більше і більше "працює і бачить, чи працює він". Ви повинні спробувати це і спробувати. Потім ви зробите кілька змін і вам доведеться спробувати ті ж самі речі знову. Стає дуже очевидно, що ви могли заощадити час, написавши одиничні тести, які б автоматизували всю частину "працює і бачить, чи працює".

У міру того, як ваші проекти стають все більшими і більшими, кількість речей, які вам доведеться «запустити і побачити, чи працює», стає нереальною. Отже, ви просто запустите і спробуєте кілька основних компонентів GUI / проекту, а потім сподіваєтесь, що все інше добре. Це рецепт катастрофи. Звичайно, ви, як людина, не можете багаторазово перевіряти кожну можливу ситуацію, яку можуть використовувати ваші клієнти, якщо графічний інтерфейс використовується буквально сотнями людей. Якщо у вас були одиничні тести, ви можете просто запустити тест перед тим, як відправити стабільну версію або навіть перед тим, як перейти до центрального сховища (якщо на робочому місці використовується такий). І якщо пізніше знайдені помилки, ви можете просто додати блок-тест, щоб перевірити його в майбутньому.


1

Однією з переваг написання одиничних тестів є те, що це допомагає вам написати більш надійний код, змушуючи вас думати про кращі справи. Як щодо тестування деяких крайових випадків, таких як переливання цілих чисел, десяткове укорочення або обробка нулів для параметрів?


0

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

Загалом, якщо ви заміните кишки add () або тестованого одиниці випадковим числом або генератором виводу, як би ви знали, що щось було порушено? Зашифруйте ці знання у ваших одиничних тестах. Якщо ніхто не може сказати, чи зламався він, тоді просто загляньте в якийсь код для rand () і поверніться додому, ваша робота виконана.


0

Я, можливо, пропустив його серед усіх відповідей, але, на мій погляд, головний привід за тестуванням модуля - це не менший доказ правильності методу сьогодні, але те, що він підтверджує постійну правильність цього методу, коли [коли-небудь] ви його зміните .

Візьміть просту функцію, як повернення кількості предметів у деякій колекції. Сьогодні, коли ваш список базується на одній внутрішній структурі даних, яку ви добре знаєте, ви можете подумати, що цей метод настільки болісно очевидний, що для цього вам не потрібен тест. Потім через декілька місяців або років ви (або хтось інший ) вирішите [s] замінити внутрішню структуру списку. Вам все одно потрібно знати, що getCount () повертає правильне значення.

Ось де Ваші тестові одиниці дійсно приходять у свої власні.

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.