Към съдържанието
wannacode

Тестове с unittest

Проблем

Отстъпката, която се счупи тихо

Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш функцията и я пробваш на ръка:

main.py
def apply_discount(price, percent):
# Отстъпка в проценти. От 100 € нагоре се добавят още 5%.
if price > 100:
percent = percent + 5
return price * (100 - percent) / 100
print(apply_discount(50, 10))
print(apply_discount(200, 10))
Изход
45.0
170.0

50 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.

Никой не е пробвал точно 100 €. А при всяка следваща промяна във функцията трябва да пробваш пак всички случаи на ръка.

Идеята

Код, който проверява код

Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.

Вид Какво проверява Пример
Unit тест едно малко парче, обикновено една функция, отделно от останалото apply_discount(100, 10) връща 85
Интеграционен как парчетата работят заедно: с база данни, файл, друга услуга поръчката се записва в базата и се чете обратно
End-to-end цялото приложение, както го ползва човек клиентът слага в количката, плаща и получава имейл
В този урок пишем unit тестове. В Python за тях има модул unittest в стандартната библиотека: нищо не се инсталира.

Защо си струва:

  • грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
  • тестът показва как се ползва кодът: кой случай какво връща;
  • можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните

Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.

Освен unittest в Python често ще срещнеш pytest: отделна библиотека (pip install pytest), в която тестовете са обикновени функции с assert. Тук ползваме unittest, защото е в самия Python.

Виж го

Първият тест

apply_discount е във файла discount.py. Тестовете за нея са в отделен файл test_discount.py, в клас DiscountTest:
test_discount.py
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase):
def test_ten_percent_off(self):
self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self):
self.assertEqual(apply_discount(100, 10), 85.0)
  • Тестовият клас наследява unittest.TestCase. Всеки метод, чието име започва с test, е тест: unittest го намира сам по името. Как точно, ще видим в последния урок на модула.
  • self.assertEqual(a, b) сравнява двете стойности. Ако се различават, тестът пада и казва a != b. Тук първо пишем това, което върна кодът, а после очакваното.

Пускаме двата теста срещу кода от първата карта:

Резултат
ОК test_ten_percent_off
ПАДА test_extra_five_percent_from_100: 90.0 != 85.0
Тестове: 2, минали: 1, паднали: 1

Първият тест минава. Вторият пада и казва точно какво е станало: очаквал е 85.0, а функцията е върнала 90.0. Поправяш условието на price >= 100, пускаш пак и двата са зелени:

Резултат
ОК test_extra_five_percent_from_100
ОК test_ten_percent_off
Тестове: 2, минали: 2, паднали: 0

Всеки тест прави три неща: подготвя стойностите, вика кода и проверява резултата.

class DiscountTest(unittest.TestCase):
def test_ten_percent_off(self):
self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self):
self.assertEqual(apply_discount(100, 10), 85.0)
Стъпка по стъпка, с думи
  1. DiscountTest има два теста. unittest пуска всеки поотделно и пали лампичка до него.
  2. Тест 1, подготовка: цена 50 € и 10% отстъпка.
  3. Действие: apply_discount(50, 10) връща 45.0.
  4. Проверка: assertEqual сравнява очакваното 45.0 с върнатото 45.0. Еднакви са, тестът минава.
  5. Тест 2: точно 100 € и 10%. Очакваме 85.0, защото от 100 € нагоре има още 5%.
  6. Условието във функцията е price > 100, а 100 не е по-голямо от 100. Функцията връща 90.0.
  7. Очаквано 85.0, върнато 90.0: тестът пада и казва точно това.
  8. Поправяш условието на price >= 100 и пускаш тестовете пак.
  9. Сега 100 минава условието и функцията връща 85.0.
  10. Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
  • В проекта кодът и тестовете са файлове един до друг. Тестовите файлове започват с test, за да ги намери unittest:
Структура
discount.py кодът
test_discount.py тестовете за него
  • Пускат се от терминала. Без -v unittest печата по точка за всеки минал тест и F за паднал, а после подробности за падналите:
Команди
python -m unittest намира test*.py и ги пуска
python -m unittest -v с ред за всеки тест
python -m unittest test_discount.DiscountTest.test_ten_percent_off
  • Тук, на сайта, кодът и тестовете са в един файл, а тестовете се пускат през програмния интерфейс на unittest: свой TestResult събира резултата на всеки тест, а run_tests ги печата, подредени по име. Подредбата пази изхода еднакъв при всяко пускане:
Пускачът
def name_of(test):
# Docstring-ът на теста или името на метода (със случая на subTest).
return test.shortDescription() or test.id().split(".", 2)[2]
class Report(unittest.TestResult):
def __init__(self):
super().__init__()
self.lines = []
self.failed = 0
self.with_cases = set()
def passed(self, name):
self.lines.append(f"ОК {name}")
def fell(self, name, err):
self.failed += 1
self.lines.append(f"ПАДА {name}: {err[1]}")
def addSuccess(self, test):
if test.id() not in self.with_cases:
self.passed(name_of(test))
def addFailure(self, test, err):
self.fell(name_of(test), err)
addError = addFailure
def addSubTest(self, test, subtest, err):
self.with_cases.add(test.id())
name = subtest.id().split(".", 2)[2]
if err is None:
self.passed(name)
else:
self.fell(name, err)
def run_tests(*tests):
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for t in tests:
suite.addTest(t if isinstance(t, unittest.TestSuite) else loader.loadTestsFromTestCase(t))
report = Report()
suite.run(report)
for line in sorted(report.lines):
print(line)
total = len(report.lines)
print(f"Тестове: {total}, минали: {total - report.failed}, паднали: {report.failed}")

Пробвай

ДДС с float

Цената с ДДС е цената по 1,2. Два теста:

Какво ще покажат тестовете?

def with_vat(price):
return price * 1.2
class VatTest(unittest.TestCase):
def test_coffee(self):
self.assertEqual(with_vat(10), 12.0)
def test_tea(self):
self.assertEqual(with_vat(3), 3.6)
Отговор

Идеята

Да или не, и очаквано изключение

Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод can_pay:

account.py
class InsufficientFundsError(Exception):
def __init__(self, shortfall):
super().__init__(f"Не стигат {shortfall} €")
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
def can_pay(self, amount):
return amount <= self._balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Сумата трябва да е положителна")
if amount > self._balance:
raise InsufficientFundsError(amount - self._balance)
self._balance -= amount
test_account.py
import unittest
from account import Account, InsufficientFundsError
class AccountTest(unittest.TestCase):
def test_can_pay_what_is_there(self):
account = Account(100)
self.assertTrue(account.can_pay(100))
self.assertFalse(account.can_pay(101))
def test_withdraw_too_much(self):
account = Account(100)
with self.assertRaises(InsufficientFundsError) as caught:
account.withdraw(130)
self.assertEqual(str(caught.exception), "Не стигат 30 €")
self.assertEqual(account.balance, 100)
Изход
ОК test_can_pay_what_is_there
ОК test_withdraw_too_much
Тестове: 2, минали: 2, паднали: 0
  • assertTrue и assertFalse проверяват условие.
  • with self.assertRaises(вид): пада, ако кодът в блока не хвърли изключение от този вид. С as caught взимаш самото изключение: caught.exception, а str() от него е съобщението.

Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.

Идеята

Всеки тест започва на чисто

Тук двата теста делят една сметка от 100 €:

test_account.py
class AccountTest(unittest.TestCase):
account = Account(100)
def test_withdraw_40(self):
"""теглене на 40 € оставя 60 €"""
self.account.withdraw(40)
self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self):
"""теглене на 70 € оставя 30 €"""
self.account.withdraw(70)
self.assertEqual(self.account.balance, 30)
Изход
ОК теглене на 40 € оставя 60 €
ПАДА теглене на 70 € оставя 30 €: Не стигат 10 €
Тестове: 2, минали: 1, паднали: 1

account е атрибут на класа: един обект за всички тестове. unittest пуска тестовете по азбучен ред на имената, затова тестът за 40 € е първи. На втория са останали 60 €, а той тегли 70 €. Достатъчно е някой да преименува тест или да добави нов, и пада друг. Тест, който зависи от друг, е капан. Затова сметката се създава наново преди всеки тест, в метода setUp:

test_account.py
import unittest
from account import Account
class AccountTest(unittest.TestCase):
def setUp(self):
self.account = Account(100)
def test_withdraw_40(self):
"""теглене на 40 € оставя 60 €"""
self.account.withdraw(40)
self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self):
"""теглене на 70 € оставя 30 €"""
self.account.withdraw(70)
self.assertEqual(self.account.balance, 30)
Изход
ОК теглене на 40 € оставя 60 €
ОК теглене на 70 € оставя 30 €
Тестове: 2, минали: 2, паднали: 0
unittest прави нов обект от класа за всеки тест и вика setUp преди него, затова self.account е нова сметка всеки път. Низът под def (docstring) е описание на теста с думи: първият му ред се показва в резултата вместо името на метода.
По-точно, за любопитните
Още неща от unittest, които ще срещнеш:
unittest
setUp / tearDown преди / след всеки тест (почистване)
setUpClass / tearDownClass веднъж за целия клас (@classmethod)
@unittest.skip("...") тестът временно не се пуска
@unittest.skipIf(условие, "...") пропуска се, ако условието е вярно
@unittest.expectedFailure очаква се да падне
with self.subTest(...): отделен случай в един тест
self.addCleanup(функция) почистване след теста, дори да е паднал
python -m unittest -k withdraw само тестовете с „withdraw“ в името
python -m unittest -f спира при първия паднал тест

Идеята

Една проверка, много случаи

Четири случая за отстъпката значат четири почти еднакви метода. Вместо тях пишеш един метод с цикъл по случаите „цена, процент, очаквано“. with self.subTest(...) прави всеки случай отделна проверка:

test_discount.py
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase):
def test_apply(self):
cases = [
(50, 10, 45.0),
(99, 0, 99.0),
(100, 10, 85.0),
(200, 20, 150.0),
]
for price, percent, expected in cases:
with self.subTest(f"{price} € с {percent}% = {expected} €"):
self.assertEqual(apply_discount(price, percent), expected)
Изход
ОК test_apply [100 € с 10% = 85.0 €]
ОК test_apply [200 € с 20% = 150.0 €]
ОК test_apply [50 € с 10% = 45.0 €]
ОК test_apply [99 € с 0% = 99.0 €]
Тестове: 4, минали: 4, паднали: 0

Всеки случай има отделен резултат, а текстът в subTest е името му. Без subTest първият паднал случай спира целия метод и не разбираш дали другите минават. Нов случай е нов ред в списъка, не нов метод. Важните случаи са на границите: точно 100 €, 99 €, 0%.

Пробвай

Намери грешката по теста

Доставката е 4 €, а от 50 € нагоре е безплатна. Тестът:

test_delivery.py
class DeliveryTest(unittest.TestCase):
def test_price(self):
for total, expected in [(20, 4), (50, 0), (80, 0)]:
with self.subTest(f"поръчка за {total} €: доставка {expected} €"):
self.assertEqual(delivery_price(total), expected)
Изход
ОК test_price [поръчка за 20 €: доставка 4 €]
ОК test_price [поръчка за 80 €: доставка 0 €]
ПАДА test_price [поръчка за 50 €: доставка 0 €]: 4 != 0
Тестове: 3, минали: 2, паднали: 1

На кой ред в кода е грешката?

Избери реда с грешката.

Идеята

Първо тестът

Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:

test_promo.py
class PromoTest(unittest.TestCase):
def test_known_code(self):
self.assertEqual(promo_percent("ESEN10"), 10)
def test_code_is_case_insensitive(self):
self.assertEqual(promo_percent("esen10"), 10)
def test_unknown_code(self):
self.assertEqual(promo_percent("NIKAKAV"), 0)
  1. Червено. Функцията засега връща 0. Тестовете падат, и това е добре: значи наистина проверяват нещо.
def promo_percent(code):
return 0
Изход
ОК test_unknown_code
ПАДА test_code_is_case_insensitive: 0 != 10
ПАДА test_known_code: 0 != 10
Тестове: 3, минали: 1, паднали: 2
  1. Зелено. Пишеш най-простия код, с който минават:
def promo_percent(code):
if code.upper() == "ESEN10":
return 10
return 0
Изход
ОК test_code_is_case_insensitive
ОК test_known_code
ОК test_unknown_code
Тестове: 3, минали: 3, паднали: 0
  1. Подреждане. Сега можеш да изнесеш кодовете в речник (dict), да добавиш нови и да подредиш функцията. Тестовете ще кажат, ако счупиш нещо.

Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави функцията, преди да мислиш как.

По-точно

Сюит и фалшиви обекти

Сюит (unittest.TestSuite) е група тестове, които се пускат заедно, например всички тестове на касата. Функцията го сглобява от трите тестови класа, а TextTestRunner го пуска:

test_checkout.py
def checkout_suite():
loader = unittest.TestLoader()
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(DiscountTest))
suite.addTests(loader.loadTestsFromTestCase(AccountTest))
suite.addTests(loader.loadTestsFromTestCase(DeliveryTest))
return suite
unittest.TextTestRunner().run(checkout_suite())
Изход
ОК test_extra_five_percent_from_100
ОК test_price [поръчка за 20 €: доставка 4 €]
ОК test_price [поръчка за 50 €: доставка 0 €]
ОК test_price [поръчка за 80 €: доставка 0 €]
ОК test_ten_percent_off
ОК теглене на 40 € оставя 60 €
ОК теглене на 70 € оставя 30 €
Тестове: 7, минали: 7, паднали: 0

Изходът е от пускача на сайта. python -m unittest прави същото и без сюит: сам събира тестовете от всички test*.py файлове.

По-точно, за любопитните
  • Понякога кодът зависи от нещо, което в тест не искаш да викаш: курс на валута от банка, плащане, имейл. Тогава подаваш фалшив обект (mock), който връща каквото му кажеш. В Python той е в стандартната библиотека: unittest.mock.Mock:
test_converter.py
import unittest
from unittest.mock import Mock
class Converter:
def __init__(self, rates):
self.rates = rates
def to_usd(self, eur):
return eur * self.rates.eur_to("USD")
class PriceInUsdTest(unittest.TestCase):
def test_converts_with_todays_rate(self):
rates = Mock()
rates.eur_to.return_value = 1.10
converter = Converter(rates)
self.assertAlmostEqual(converter.to_usd(10), 11.0, delta=0.001)
rates.eur_to.assert_called_once_with("USD")
Изход
ОК test_converts_with_todays_rate
Тестове: 1, минали: 1, паднали: 0

Mock() е обект, който приема всеки метод. return_value казва какво да връща eur_to, а assert_called_once_with проверява, че е извикан точно веднъж и с "USD". Интерфейс не трябва: Converter вика eur_to на каквото му подадеш (duck typing).

  • Още проверки освен assertEqual, assertTrue и assertRaises:
Проверки
assertNotEqual(a, b) различни ли са
assertIs(a, b), assertIsNot(a, b) един и същ обект ли е (is), не просто равни
assertIsNone(x), assertIsNotNone(x)
assertIn(x, items), assertNotIn(x, items)
assertCountEqual(a, b) същите елементи, в какъвто и да е ред
assertListEqual(a, b) два списъка с еднакви елементи в същия ред
assertRegex(text, pattern) текстът отговаря на регулярен израз
assertGreater(a, b), assertLess(a, b)
assertAlmostEqual(a, b, delta=0.01)
self.fail("съобщение") тестът пада веднага

Упражнение

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

Реши задачите в редактора си, с unittest (той е в самия Python).

1. Калкулаторът на отстъпки. Напиши test_discount.py за apply_discount от тази карта. Покрий тези случаи: 80 € с 10% са 72 €, 100 € с 0% са 95 €, 150 € с 20% са 112,50 €, 0 € с 30% са 0 € и точно 100 € с 10% са 85 €. Пусни ги и срещу счупената версия с price > 100. Трябва да паднат точно двата случая с 100 €:

Резултат срещу счупената версия
ОК test_apply [0 € с 30% = 0.0 €]
ОК test_apply [150 € с 20% = 112.5 €]
ОК test_apply [80 € с 10% = 72.0 €]
ПАДА test_apply [100 € с 0% = 95.0 €]: 100.0 != 95.0 within 0.001 delta (5.0 difference)
ПАДА test_exactly_100_gets_the_extra: 90.0 != 85.0 within 0.001 delta (5.0 difference)
Тестове: 5, минали: 3, паднали: 2

2. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в setUp. Провери, че:

  • теглене на 30 € оставя 70 €;
  • може да се изтегли всичко и остава 0;
  • теглене на 150 € хвърля InsufficientFundsError със съобщение „Не стигат 50 €“, а балансът остава 100 €;
  • 0 и -20 € хвърлят ValueError (един тест със subTest за двете суми).

3. Бъгът в средната оценка. Функцията изглежда наред:

grades.py
def average(grades):
total = 0
for grade in grades:
total += grade
return total // len(grades)
Напиши два теста: средното на 4 и 6 е 5,0, а средното на 5 и 6 е 5,5 (с допустима разлика 0,001). Единият пада:
Резултат
ОК test_whole_average
ПАДА test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)
Тестове: 2, минали: 1, паднали: 1

Намери причината и поправи функцията, така че и двата да минат.

Тест

Провери се

1. Какво проверява един unit тест?
2. self.assertEqual(apply_discount(100, 10), 85.0) пада с „90.0 != 85.0“. Колко върна функцията?
3. Защо сметката се създава в setUp, а не веднъж като атрибут на класа?
4. Как проверяваш, че withdraw(130) хвърля InsufficientFundsError?
5. Кой е редът при TDD?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·