Python · Тестування з pytest · Експертний
Test-Driven Development
Керуйте проектуванням через тести: спочатку пишіть тести, що падають, потім реалізуйте код.
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
TDD на практиці: побудова BankAccount крок за кроком
#Довідник TDD: цикл, іменування та шаблони
#Вправи:
Реалізуйте клас Stack через TDD
## stack.py
class Stack:
pass
# test_stack.py
import pytest
from stack import Stack
def test_new_stack_is_empty():
pass
def test_push_makes_stack_non_empty():
pass
def test_pop_returns_last_pushed_item():
pass
def test_pop_removes_the_item():
pass
def test_peek_returns_top_without_removing():
pass
def test_pop_raises_on_empty_stack():
pass
def test_peek_raises_on_empty_stack():
pass
Рішення
# stack.py
class Stack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
if self.is_empty():
raise IndexError("pop from empty stack")
return self._items.pop()
def peek(self):
if self.is_empty():
raise IndexError("peek at empty stack")
return self._items[-1]
def is_empty(self):
return len(self._items) == 0
# test_stack.py
import pytest
from stack import Stack
def test_new_stack_is_empty():
stack = Stack()
assert stack.is_empty() is True
def test_push_makes_stack_non_empty():
stack = Stack()
stack.push(1)
assert stack.is_empty() is False
def test_pop_returns_last_pushed_item():
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)
assert stack.pop() == 3
def test_pop_removes_the_item():
stack = Stack()
stack.push(42)
stack.pop()
assert stack.is_empty() is True
def test_peek_returns_top_without_removing():
stack = Stack()
stack.push(10)
stack.push(20)
assert stack.peek() == 20
assert stack.is_empty() is False
def test_pop_raises_on_empty_stack():
stack = Stack()
with pytest.raises(IndexError):
stack.pop()
def test_peek_raises_on_empty_stack():
stack = Stack()
with pytest.raises(IndexError):
stack.peek()
Спочатку напишіть тести, потім реалізуйте validate_email
## email_validator.py
def validate_email(s: str) -> bool:
pass
# test_email.py
from email_validator import validate_email
def test_valid_email_returns_true():
pass
def test_missing_at_sign_returns_false():
pass
def test_missing_local_part_returns_false():
pass
def test_missing_domain_returns_false():
pass
def test_empty_string_returns_false():
pass
Рішення
# email_validator.py
def validate_email(s: str) -> bool:
if not s:
return False
if '@' not in s:
return False
local, _, domain = s.partition('@')
if not local:
return False
if not domain:
return False
return True
# test_email.py
from email_validator import validate_email
def test_valid_email_returns_true():
assert validate_email("[email protected]") is True
def test_missing_at_sign_returns_false():
assert validate_email("userexample.com") is False
def test_missing_local_part_returns_false():
assert validate_email("@example.com") is False
def test_missing_domain_returns_false():
assert validate_email("user@") is False
def test_empty_string_returns_false():
assert validate_email("") is False
Тести-характеристики для застарілого коду, потім безпечний рефакторинг
## text_stats.py -- дано, не змінювати у Частині 1
class TextStats:
def __init__(self, text: str):
self.text = text
def word_count(self):
return len(self.text.split())
def char_count(self):
return len(self.text)
def most_common_word(self):
words = self.text.lower().split()
counts = {}
for word in words:
if word in counts:
counts[word] += 1
else:
counts[word] = 1
best, best_count = None, 0
for word, count in counts.items():
if count > best_count:
best, best_count = word, count
return best
# test_text_stats.py
from text_stats import TextStats
def test_word_count():
pass
def test_char_count():
pass
def test_most_common_word():
pass
def test_most_common_word_is_case_insensitive():
pass
Рішення
# test_text_stats.py
from text_stats import TextStats
def test_word_count():
stats = TextStats("hello world foo")
assert stats.word_count() == 3
def test_char_count():
stats = TextStats("hello")
assert stats.char_count() == 5
def test_most_common_word():
stats = TextStats("cat dog cat")
assert stats.most_common_word() == "cat"
def test_most_common_word_is_case_insensitive():
stats = TextStats("Cat cat Dog")
assert stats.most_common_word() == "cat"
# text_stats.py -- після рефакторингу
from collections import Counter
class TextStats:
def __init__(self, text: str):
self.text = text
def word_count(self):
return len(self.text.split())
def char_count(self):
return len(self.text)
def most_common_word(self):
words = self.text.lower().split()
if not words:
return None
return Counter(words).most_common(1)[0][0]
TDD з часозалежним RateLimiter -- впроваджуємо годинник
## rate_limiter.py
class RateLimiter:
pass
# test_rate_limiter.py
from rate_limiter import RateLimiter
def test_first_call_is_always_allowed():
pass
def test_calls_within_limit_are_allowed():
pass
def test_calls_exceeding_limit_are_denied():
pass
def test_calls_reset_after_one_second():
pass
def test_two_limiters_are_independent():
pass
Рішення
# rate_limiter.py
import time
class RateLimiter:
def __init__(self, calls_per_second, time_fn=None):
self.limit = calls_per_second
self._time = time_fn if time_fn is not None else time.time
self._window_start = self._time()
self._call_count = 0
def allow(self):
now = self._time()
if now - self._window_start >= 1.0:
self._window_start = now
self._call_count = 0
if self._call_count < self.limit:
self._call_count += 1
return True
return False
# test_rate_limiter.py
from rate_limiter import RateLimiter
def test_first_call_is_always_allowed():
fake_time = [0.0]
limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
assert limiter.allow() is True
def test_calls_within_limit_are_allowed():
fake_time = [0.0]
limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
assert limiter.allow() is True
assert limiter.allow() is True
def test_calls_exceeding_limit_are_denied():
fake_time = [0.0]
limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
limiter.allow()
limiter.allow()
assert limiter.allow() is False
def test_calls_reset_after_one_second():
fake_time = [0.0]
limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
limiter.allow()
limiter.allow() # ліміт досягнуто при t=0
fake_time[0] = 1.0 # переводимо фіктивний годинник
assert limiter.allow() is True # нове вікно, знову дозволено
def test_two_limiters_are_independent():
fake_time = [0.0]
a = RateLimiter(1, time_fn=lambda: fake_time[0])
b = RateLimiter(1, time_fn=lambda: fake_time[0])
a.allow() # вичерпуємо ліміт a
assert b.allow() is True # b не зачеплений
TDD з parametrize: спочатку специфікуємо контракт, потім реалізуємо
## roman.py
def to_roman(n: int) -> str:
pass
# test_roman.py -- не змінювати; запустіть pytest і спостерігайте, як всі 10 провалюються
import pytest
from roman import to_roman
@pytest.mark.parametrize("n,expected", [
(1, "I"),
(4, "IV"),
(9, "IX"),
(14, "XIV"),
(40, "XL"),
(90, "XC"),
(400, "CD"),
(900, "CM"),
(1994, "MCMXCIV"),
(2024, "MMXXIV"),
])
def test_to_roman(n, expected):
assert to_roman(n) == expected
Рішення
# roman.py
def to_roman(n: int) -> str:
values = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
(1, "I"),
]
result = ""
for value, numeral in values:
while n >= value:
result += numeral
n -= value
return result
# test_roman.py -- без змін
import pytest
from roman import to_roman
@pytest.mark.parametrize("n,expected", [
(1, "I"),
(4, "IV"),
(9, "IX"),
(14, "XIV"),
(40, "XL"),
(90, "XC"),
(400, "CD"),
(900, "CM"),
(1994, "MCMXCIV"),
(2024, "MMXXIV"),
])
def test_to_roman(n, expected):
assert to_roman(n) == expected