Модуль 2.9 · Урок 1
Урок 1: TDD с агентом
Содержание
- Чему вы научитесь
- Почему TDD + AI работает идеально
- Часть 1: Python + pytest
- Шаг 1: Пишем тест
- Шаг 2: Даём код агенту
- Шаг 3: Агент пишет код
- Шаг 4: Запускаем тесты
- Часть 2: JavaScript + Jest
- Тесты
- Реализация
- Часть 3: Практический workflow в команде
- Сценарий: исправление багов через TDD
- Таблица: TDD vs обычный подход
- Антипаттерн: YOLO-код
- Попробуйте сами
- Ключевые выводы
- Следующий урок
Чему вы научитесь
- Применять Test-Driven Development вместе с AI-агентами
- Писать тесты, которые служат спецификацией для агента
- Создавать workflow «тест → реализация → итерация»
- Использовать Claude Code для генерации кода под готовые тесты
- Отлаживать код совместно с агентом через красный-зелёный-рефакторинг цикл
Почему TDD + AI работает идеально
Проблема: когда вы просто просите агента “написать функцию”, результат неопределённый. Агент может неправильно понять требования, использовать неподходящий подход, накопить ненужный техдолг.
Решение: TDD даёт агенту явную спецификацию в виде тестов. Вместо расплывчатого описания агент видит конкретные примеры входных и выходных данных. Это снижает неоднозначность на 90%.
graph LR
A["1. Вы пишете<br/>тест"] -->|ясная спецификация| B["2. Агент пишет<br/>реализацию"]
B -->|запускаем| C["3. Тест красный<br/>или зелёный?"]
C -->|красный| D["4. Агент<br/>фиксит"]
D --> C
C -->|зелёный| E["5. Рефакторинг<br/>вместе"]
E --> F["[+] Готовый код"]
Часть 1: Python + pytest
Сценарий: валидация email-адреса с дополнительными бизнес-правилами.
Шаг 1: Пишем тест
Файл test_email_validator.py:
import pytest
from email_validator import validate_email, EmailNotValidError
class TestEmailValidator:
"""Тесты для валидатора email"""
def test_valid_simple_email(self):
"""Простой валидный email"""
result = validate_email("user@example.com")
assert result == "user@example.com"
def test_valid_email_with_plus(self):
"""Email с + (Gmail-стиль)"""
result = validate_email("user+tag@gmail.com")
assert result == "user+tag@gmail.com"
def test_reject_no_at_symbol(self):
"""Отклоняем email без @"""
with pytest.raises(EmailNotValidError):
validate_email("userexample.com")
def test_reject_no_domain(self):
"""Отклоняем email без домена после @"""
with pytest.raises(EmailNotValidError):
validate_email("user@")
def test_reject_multiple_at(self):
"""Отклоняем email с двумя @"""
with pytest.raises(EmailNotValidError):
validate_email("user@@example.com")
def test_lowercase_transformation(self):
"""Приводим email в нижний регистр"""
result = validate_email("User@EXAMPLE.COM")
assert result == "user@example.com"
def test_reject_disposable_domain(self):
"""Отклоняем одноразовые домены"""
with pytest.raises(EmailNotValidError):
validate_email("user@tempmail.com")
def test_reject_spaces(self):
"""Отклоняем email с пробелами"""
with pytest.raises(EmailNotValidError):
validate_email("user @example.com")
Шаг 2: Даём код агенту
Команда для Claude Code:
claude "Напиши реализацию для email_validator.py.
Требования:
- Импортируй EmailNotValidError из email_validator (from email_validator import EmailNotValidError)
- Функция validate_email(email: str) -> str
- Должна пройти все тесты в test_email_validator.py
- Используй встроенные решения для базовой валидации
- Добавь список disposable_domains для проверки временных почт"
Шаг 3: Агент пишет код
Ожидаемый результат email_validator.py:
import re
from typing import Set
# Список распространённых одноразовых доменов
DISPOSABLE_DOMAINS: Set[str] = {
"tempmail.com",
"10minutemail.com",
"guerrillamail.com",
"mailinator.com",
"temp-mail.org",
"throwaway.email",
}
# EmailNotValidError импортируется из email_validator
def validate_email(email: str) -> str:
"""
Валидирует email и возвращает нормализованный адрес.
Args:
email: Email-адрес для проверки
Returns:
Нормализованный (lowercase) email
Raises:
EmailNotValidError: Если email невалидный
"""
# Проверка на пробелы
if " " in email:
raise EmailNotValidError("Email содержит пробелы")
# Базовая проверка на наличие @
at_count = email.count("@")
if at_count == 0:
raise EmailNotValidError("Email должен содержать @")
if at_count > 1:
raise EmailNotValidError("Email содержит больше одного @")
# Разбиваем на локальную часть и домен
local_part, domain = email.rsplit("@", 1)
# Проверка на пустую локальную часть
if not local_part:
raise EmailNotValidError("Email должен содержать часть перед @")
# Проверка на пустой домен
if not domain or "." not in domain:
raise EmailNotValidError("Домен должен содержать точку")
# Проверка на одноразовый домен
domain_lower = domain.lower()
if domain_lower in DISPOSABLE_DOMAINS:
raise EmailNotValidError(f"Домен {domain_lower} в чёрном списке")
# Нормализация (приведение в нижний регистр)
normalized = email.lower()
return normalized
Шаг 4: Запускаем тесты
pytest test_email_validator.py -v
Ожидаемый результат:
test_email_validator.py::TestEmailValidator::test_valid_simple_email PASSED
test_email_validator.py::TestEmailValidator::test_valid_email_with_plus PASSED
test_email_validator.py::TestEmailValidator::test_reject_no_at_symbol PASSED
test_email_validator.py::TestEmailValidator::test_reject_no_domain PASSED
test_email_validator.py::TestEmailValidator::test_reject_multiple_at PASSED
test_email_validator.py::TestEmailValidator::test_lowercase_transformation PASSED
test_email_validator.py::TestEmailValidator::test_reject_disposable_domain PASSED
test_email_validator.py::TestEmailValidator::test_reject_spaces PASSED
============= 8 passed in 0.15s =============
Часть 2: JavaScript + Jest
Другой пример: валидация API-ключа.
Тесты
Файл apiKeyValidator.test.js:
const { validateApiKey, InvalidApiKeyError } = require('./apiKeyValidator');
describe('API Key Validator', () => {
test('accepts valid 32-char hex key', () => {
const key = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
expect(validateApiKey(key)).toBe(key.toUpperCase());
});
test('rejects key shorter than 32 chars', () => {
expect(() => validateApiKey('short')).toThrow(InvalidApiKeyError);
});
test('rejects non-hex characters', () => {
expect(() => validateApiKey('g1g2g3g4e5f6a7b8c9d0e1f2a3b4c5d6')).toThrow(InvalidApiKeyError);
});
test('normalizes to uppercase', () => {
const key = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
expect(validateApiKey(key)).toBe(key.toUpperCase());
});
test('rejects keys with spaces', () => {
expect(() => validateApiKey('a1b2c3d4 e5f6a7b8c9d0e1f2a3b4c5d6')).toThrow(InvalidApiKeyError);
});
test('rejects keys with special characters', () => {
expect(() => validateApiKey('a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6')).toThrow(InvalidApiKeyError);
});
});
Реализация
Файл apiKeyValidator.js:
class InvalidApiKeyError extends Error {
constructor(message) {
super(message);
this.name = 'InvalidApiKeyError';
}
}
function validateApiKey(key) {
// Проверка на пробелы
if (key.includes(' ')) {
throw new InvalidApiKeyError('API ключ содержит пробелы');
}
// Проверка длины
if (key.length !== 32) {
throw new InvalidApiKeyError('API ключ должен быть ровно 32 символа');
}
// Проверка на hex-формат (только 0-9 и A-F)
const hexRegex = /^[0-9a-fA-F]{32}$/;
if (!hexRegex.test(key)) {
throw new InvalidApiKeyError('API ключ должен содержать только hex-символы (0-9, A-F)');
}
return key.toUpperCase();
}
module.exports = { validateApiKey, InvalidApiKeyError };
Часть 3: Практический workflow в команде
Сценарий: исправление багов через TDD
1. QA-инженер находит баг: "функция crash'ит на null-значениях"
↓
2. Разработчик пишет тест:
test_should_handle_null_gracefully() {
assert parse(null) == DEFAULT_VALUE
}
↓
3. Разработчик передаёт Claude Code:
"Напиши реализацию parse(), чтобы прошёл тест test_should_handle_null_gracefully"
↓
4. Claude пишет код, тест проходит
↓
5. Разработчик просит рефакторинг:
"Оптимизируй это для O(n) вместо O(n²), тесты не должны сломаться"
↓
6. Итоговый код: баг исправлен, есть регрессионный тест
Таблица: TDD vs обычный подход
| Параметр | TDD + AI | Обычный подход |
|---|---|---|
| Ясность требований | Тесты = спецификация | Расплывчатое описание |
| Качество AI-кода | Высокое (видит примеры) | Низкое (гадает) |
| Регрессионные баги | Защита тестами | Нет защиты |
| Документация | Тесты служат примерами | Нужна отдельно |
| Time-to-value | Быстро (целевой код) | Долго (много переписываний) |
| Интеграция агента | Простая (агент → зелёные тесты) | Сложная (много уточнений) |
Антипаттерн: YOLO-код
[-] Неправильно:
# Просто просим агента писать
claude "напиши валидатор для email"
# Результат: неопределённое поведение, неясна спецификация
[+] Правильно:
# Даём тесты как спецификацию
claude "напиши валидатор email_validator.py, чтобы прошли тесты в test_email_validator.py"
# Результат: точный код, проходит все проверки
Попробуйте сами
Задача: Написать валидатор для номера телефона.
-
Создайте файл
test_phone_validator.pyс 6-8 тестами:- Валидный номер в формате +7 XXX XXX-XX-XX
- Отклонить номер без +7
- Отклонить с пробелами в начале/конце
- Отклонить с недостаточным количеством цифр
- Привести к нормализованному формату (удалить пробелы и дефисы)
-
Дайте Claude Code команду:
claude "Напиши phone_validator.py с функцией validate_phone(phone: str) -> str, которая пройдёт все тесты в test_phone_validator.py" -
Запустите
pytest test_phone_validator.py -v -
Если тесты не прошли, дайте агенту фидбэк:
claude "Тесты не прошли с ошибкой: [ERROR]. Исправь реализацию, сохранив API функции" -
Повторяйте, пока все тесты не будут зелёными.
Ключевые выводы
- Тесты = спецификация: Чем точнее тесты, тем точнее код агента
- Red-Green-Refactor: Это работает и с AI: напиши тест → агент пишет код → рефакторь вместе
- Итерация через feedback: Не ожидайте идеального кода с первого раза, используйте результаты тестов как feedback
- Регрессионная защита: Тесты защищают от багов, когда агент (или вы) меняет код позже
- Практический опыт: TDD с AI-агентами значительно снижает неоднозначность требований и улучшает качество кода по сравнению с прямым промптом «напиши функцию»
Следующий урок
В уроке 2 мы поговорим о Code Review с агентом: как использовать Claude для автоматического анализа PR, что агент может найти, а что нет, и как настроить автоматический pre-review перед human review.