Тестове с unittest
Проблем
Отстъпката, която се счупи тихо
Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш функцията и я пробваш на ръка:
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.0170.050 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.
Никой не е пробвал точно 100 €. А при всяка следваща промяна във функцията трябва да пробваш пак всички случаи на ръка.
Идеята
Код, който проверява код
Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.
| Вид | Какво проверява | Пример |
|---|---|---|
| Unit тест | едно малко парче, обикновено една функция, отделно от останалото | apply_discount(100, 10) връща 85 |
| Интеграционен | как парчетата работят заедно: с база данни, файл, друга услуга | поръчката се записва в базата и се чете обратно |
| End-to-end | цялото приложение, както го ползва човек | клиентът слага в количката, плаща и получава имейл |
Защо си струва:
- грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
- тестът показва как се ползва кодът: кой случай какво връща;
- можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните
Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.
Освен unittest в Python често ще срещнеш pytest: отделна библиотека (pip install pytest), в която тестовете са обикновени функции с assert. Тук ползваме unittest, защото е в самия Python.Виж го
Първият тест
apply_discount е във файла discount.py. Тестовете за нея са в отделен файл test_discount.py, в клас DiscountTest: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)Стъпка по стъпка, с думи
- DiscountTest има два теста. unittest пуска всеки поотделно и пали лампичка до него.
- Тест 1, подготовка: цена 50 € и 10% отстъпка.
- Действие: apply_discount(50, 10) връща 45.0.
- Проверка: assertEqual сравнява очакваното 45.0 с върнатото 45.0. Еднакви са, тестът минава.
- Тест 2: точно 100 € и 10%. Очакваме 85.0, защото от 100 € нагоре има още 5%.
- Условието във функцията е price > 100, а 100 не е по-голямо от 100. Функцията връща 90.0.
- Очаквано 85.0, върнато 90.0: тестът пада и казва точно това.
- Поправяш условието на price >= 100 и пускаш тестовете пак.
- Сега 100 минава условието и функцията връща 85.0.
- Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
- В проекта кодът и тестовете са файлове един до друг. Тестовите файлове започват с
test, за да ги намери unittest:
discount.py кодътtest_discount.py тестовете за него- Пускат се от терминала. Без
-vunittest печата по точка за всеки минал тест и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)3 по 1,2 във float не е точно 3,6. Дробните числа се пазят в двоичен вид и при сметките се губи малка част. assertEqual сравнява точно, затова тестът пада:
ОК test_coffeeПАДА test_tea: 3.5999999999999996 != 3.6Тестове: 2, минали: 1, паднали: 1assertAlmostEqual с допустима разлика: self.assertAlmostEqual(with_vat(3), 3.6, delta=0.001) минава, защото разликата е под 0,001. А 12.0 != 12 не би паднал: в Python 12.0 == 12 е вярно.Идеята
Да или не, и очаквано изключение
Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод can_pay:
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 -= amountimport 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, паднали: 0assertTrueиassertFalseпроверяват условие.with self.assertRaises(вид):пада, ако кодът в блока не хвърли изключение от този вид. Сas caughtвзимаш самото изключение:caught.exception, аstr()от него е съобщението.
Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.
Идеята
Всеки тест започва на чисто
Тук двата теста делят една сметка от 100 €:
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, паднали: 1account е атрибут на класа: един обект за всички тестове. unittest пуска тестовете по азбучен ред на имената, затова тестът за 40 € е първи. На втория са останали 60 €, а той тегли 70 €. Достатъчно е някой да преименува тест или да добави нов, и пада друг. Тест, който зависи от друг, е капан. Затова сметката се създава наново преди всеки тест, в метода setUp:
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, паднали: 0setUp преди него, затова self.account е нова сметка всеки път. Низът под def (docstring) е описание на теста с думи: първият му ред се показва в резултата вместо името на метода.По-точно, за любопитните
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(...) прави всеки случай отделна проверка:
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 € нагоре е безплатна. Тестът:
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На кой ред в кода е грешката?
Избери реда с грешката.
Падналият тест казва: при поръчка за точно 50 € доставката е 4 €, а трябва да е 0. Значи 50 не минава условието. total > 50 изключва самото 50. Трябва да е total >= 50. Тестът сочи случая, а кодът показва причината.
Идеята
Първо тестът
Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:
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)- Червено. Функцията засега връща 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- Зелено. Пишеш най-простия код, с който минават:
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- Подреждане. Сега можеш да изнесеш кодовете в речник (dict), да добавиш нови и да подредиш функцията. Тестовете ще кажат, ако счупиш нещо.
Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави функцията, преди да мислиш как.
По-точно
Сюит и фалшиви обекти
Сюит (unittest.TestSuite) е група тестове, които се пускат заедно, например всички тестове на касата. Функцията го сглобява от трите тестови класа, а TextTestRunner го пуска:
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:
import unittestfrom 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, паднали: 0Mock() е обект, който приема всеки метод. 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, паднали: 22. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в setUp. Провери, че:
- теглене на 30 € оставя 70 €;
- може да се изтегли всичко и остава 0;
- теглене на 150 € хвърля
InsufficientFundsErrorсъс съобщение „Не стигат 50 €“, а балансът остава 100 €; - 0 и -20 € хвърлят
ValueError(един тест със subTest за двете суми).
3. Бъгът в средната оценка. Функцията изглежда наред:
def average(grades): total = 0 for grade in grades: total += grade return total // len(grades)ОК test_whole_averageПАДА test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)Тестове: 2, минали: 1, паднали: 1Намери причината и поправи функцията, така че и двата да минат.
Тест