На протяжении истории люди придумывали различные подходы и приёмы, как разрабатывать более качественные и поддерживаемые приложения. В этой статье я бы хотел рассказать о такой методологии разработки, как BDD (Behaviour Driven Development). Но прежде чем перейти непосредственно к гвоздю программы — небольшое вступление.
Думаю, большинство разработчиков согласятся с мыслью о том, что покрытый юнит-тестами код лучше, чем непокрытый. Действительно, тесты позволяют эффективно следить за работоспособностью кода, вовремя отлавливать нерабочие изменения. А ещё из наличия юнитов обычно следует то, что код разбит на логические модули и каждый класс/функция имеет одну зону ответственности (привет SOLID). Тот, кому доводилось писать тест на большую функцию с несколькими зонами ответственности знает, что тесты на такую функцию обречены быть хрупкими и падать при малейшем изменении. Это заставляет задуматься о том, чтобы не писать всё "в одной портянке", а писать гибкий код, поделённый на модули. С таким кодом, как правило, приятнее работать, т.к. приходится держать в уме меньше информации.
В какой-то момент люди сделали вывод, что раз код хороший если он тестируемый, тогда давайте мы сначала напишем тесты на этот код, а уже потом сам код. И так придумали методологию...
Test Driven Development (TDD)
Test Driven Development (TDD) — это подход к разработке программного обеспечения, который ставит тестирование на первое место. В TDD разработчики сначала пишут тесты для новой функциональности, а затем пишут код, который позволит этим тестам пройти. Это отличается от традиционного подхода, когда тесты пишутся после кода, однако привносит свои преимущества:
Повышение качества кода: Тесты обеспечивают быструю обратную связь о том, работает ли код так, как предполагалось.
Упрощение рефакторинга: Тесты служат "сетью безопасности", позволяя разработчикам вносить изменения в код без страха сломать что-то.
Документация: Тесты могут служить формой документации, показывая, как предполагается использование кода.
Программист при написании тестов по сути занимается проектированием и составлением требований к модулю, заранее продумывая что это будет за модуль и за что он будет отвечать.
Конечно, за всё приходится платить, и TDD не является исключением:
Замедление процесса разработки: Написание тестов занимает время, которое могло бы быть потрачено на написание нового кода.
Трудность написания хороших тестов: Написание эффективных тестов — это навык, который требует практики и опыта.
Риск переосмысления: Существует риск, что разработчики могут проводить слишком много времени, пытаясь заставить тесты пройти, вместо того чтобы думать о лучшем дизайне системы.
Behavior Driven Development как развитие TDD
Людям хотелось использовать TDD подход не только в вопросах реализации того или иного функционала, но и для более широких вещей. Хотелось иметь достаточно ясные тесты, взглянув на которые у человека не составило бы труда понять, что умеет делать определённая часть приложения. Проблема в том, что классические тесты не всегда могут быть сразу понятными для человека, увидевшего ваше приложение в первый раз. Здесь и появляется BDD.
Behavior Driven Development (BDD) — это подход к разработке программного обеспечения, который вырос из TDD. В то время как TDD сосредоточен на тестировании отдельных модулей кода, BDD расширяет этот подход, сосредоточиваясь на поведении системы в целом.
При написании BDD тестов реализуются степы, или шаги — небольшие функции, которые выполняют одно определённое действие юзера. BDD использует естественный язык и конкретные примеры для описания ожидаемого поведения системы. Это помогает улучшить коммуникацию между разработчиками, тестировщиками и непрограммистами, такими как менеджеры проектов или стейкхолдеры.
Принципы BDD
BDD основывается на нескольких ключевых принципах:
Описание поведения: Вместо того чтобы сосредоточиваться на технических деталях реализации, BDD фокусируется на описании ожидаемого поведения системы с точки зрения пользователя или стейкхолдера. Это помогает убедиться, что разрабатываемые функции действительно соответствуют потребностям пользователей.
Использование естественного языка: BDD использует естественный язык для описания сценариев тестирования, что делает их понятными не только для разработчиков и тестировщиков, но и для менеджеров проектов, бизнес-аналитиков и других участников команды.
Сотрудничество и коммуникация: BDD подчеркивает важность сотрудничества и общения между всеми участниками команды. Это помогает обеспечить общее понимание целей и требований проекта.
Примеры: BDD использует конкретные примеры для описания ожидаемого поведения. Это помогает уточнить требования и обеспечивает ясность в отношении того, что должна делать система.
Как и любой подход, BDD имеет свои преимущества и недостатки.
Плюсы:
Повышение качества коммуникации: BDD использует естественный язык для описания ожидаемого поведения, что делает его понятным для всех участников команды, включая непрограммистов.
Сосредоточение на бизнес-целях: BDD помогает команде сосредоточиться на достижении конкретных бизнес-целей, а не просто на написании кода.
Поддержка автоматизации тестирования: BDD поддерживает автоматизацию тестирования, что позволяет быстро и эффективно проверять поведение системы.
Минусы:
Сложность внедрения: Внедрение BDD может потребовать значительных изменений в процессах разработки и тестирования, что может быть сложно для некоторых команд.
Требуется обучение: Для эффективного использования BDD команде может потребоваться обучение, особенно для понимания и написания сценариев на естественном языке.
Риск неправильного понимания: Если сценарии BDD написаны неправильно или нечетко, это может привести к неправильному пониманию требований или ожидаемого поведения системы.
При разработке BDD тестов можно использовать два подхода:
-
Тест-кейсы и реализация степов на классическом ЯП.
Сами тесты должны выглядеть максимально понятно и не содержать сложной логики. Условный BDD-тест на Python может выглядеть так:class FeatureCalculator: """ As a user I want to perform some math ops """ def test_one_plus_one(self): """ I can successfully add one to one """ # Given self.working_calculator() # When self.i_add(1, 1) # Then self.result_is(2) def test_one_plus_one_broken_calculator(self): """ I try to add one to one using broken calculator """ # Given self.broken_calculator() # When self.i_add(1, 1) # Then self.result_is(None) def working_calculator(self): ... def broken_calculator(self): ... def i_add(self, x, y): ... def result_is(self, x): ...
-
Тест-кейсы на Gherkin, реализация степов на классическом ЯП.
Gherkin — это язык, созданный специально для описания поведения систем. Содержит небольшое количество ключевых слов, которое тем не менее является достаточным для составления тестовых сценариев. Тот же самый тест, но на Gherkin:Feature: Calculator As a user I want to perform some math ops Scenario: I can successfully add one to one Given working calculator When I add 1 to 1 Then result is 2 Scenario: I try to add one to one using broken calculator Given broken calculator When I add 1 to 1 Then result is None
Практическая часть: Примеры BDD тестов с использованием библиотеки python-behave
Для практики давайте покроем BDD тестами некоторое приложение. На SwaggerHub выложен в открытом доступе Swagger Petstore — REST API для управления неким "магазином домашних питомцев". Попробуем покрыть тестами endpoint POST /pet
, отвечающий за добавление нового питомца в магазин.
Писать тесты я буду с использованием Gherkin. Для реализации таких тестов я выбрал Python 3.10 + фреймворк python-behave
. Установить его можно через pip:
pip install behave
Впрочем, BDD фреймворков существует много для таких языков как Java, C#, Go, JavaScript и другие.
Запуск BDD тестов происходит через консоль командой behave
, либо через плагины для вашего любимого инструмента разработки (например в PyCharm Professional поддержка BDD есть из коробки).
Примеры BDD тестов
Создадим новый feature-файл:
features/pet.feature
Feature: Everything about your Pets
As user I want to add, update, and delete pets in the pet store
# здесь будут располагаться тесты
# Scenario: ...
Для первого запуска напишем простой тест, который будет посылать POST запрос на API и ожидать, что получит в ответ 200
.
features/pet.feature
Scenario: I successfully create new empty pet
When I make POST request to "/pet" with body
Then Response is 200
Но этот тест не запустится прямо сейчас, потому что мы не реализовали поведение для шагов I make POST request to "/pet" with body
и Response is 200
:
Feature: Everything about your Pets # features/pet.feature:1
As user I want to add, update, and delete pets in the pet store
Scenario: I successfully create new empty pet # features/pet.feature:4
When I make POST request to "/pet" with body # None
Then Response is 200 # None
Failing scenarios:
features/pet.feature:4 I successfully create new empty pet
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 2 undefined
Took 0m0.000s
You can implement step definitions for undefined steps with these snippets:
@when(u'I make POST request to "/pet" with body')
def step_impl(context):
raise NotImplementedError(u'STEP: When I make POST request to "/pet" with body')
@then(u'Response is 200')
def step_impl(context):
raise NotImplementedError(u'STEP: Then Response is 200')
Давайте это исправим:
features/steps/pet.py
import json
import requests
from behave import step
ROOT_URL = r"https://petstore.swagger.io/v2"
@step('I make POST request to "{path}" with body')
def make_post_request_step(context, path):
body = json.loads(context.text or "{}")
url = ROOT_URL + path
context.response = requests.post(url, json=body)
@step("Response is {status_code:d}")
def assert_response_step(context, status_code):
assert (
context.response.status_code == status_code,
f"Expected {status_code}, got {context.response.status_code}"
)
Попробуем запустить ещё раз:
Feature: Everything about your Pets # features/pet.feature:1
As user I want to add, update, and delete pets in the pet store
Scenario: I successfully create new empty pet # features/pet.feature:4
When I make POST request to "/pet" with body # features/steps/pet.py:9
Then Response is 200 # features/steps/pet.py:17
1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
2 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.927s
Удостоверимся, что тест работает, немного поломав его. Пусть мы будем ожидать ответ 300
вместо 200
:
Feature: Everything about your Pets # features/pet.feature:1
As user I want to add, update, and delete pets in the pet store
Scenario: I successfully create new empty pet # features/pet.feature:4
When I make POST request to "/pet" with body # features/steps/pet.py:9
Then Response is 300 # features/steps/pet.py:17
Assertion Failed: Expected 300, got 200
Failing scenarios:
features/pet.feature:4 I successfully create new empty pet
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
1 step passed, 1 failed, 0 skipped, 0 undefined
Took 0m1.026s
Замечательно! Теперь при помощи этих шагов можно написать и негативные сценарии:
features/pet.feature
Scenario: I get an error if I try to create a pet with invalid category id
When I make POST request to "/pet" with body
"""
{
"category": {"id": "zero", "name": "cats"}
}
"""
Then Response is 500
Scenario: I get an error if I try to create a pet with invalid tag id
When I make POST request to "/pet" with body
""" {
"tags": {"id": "zero", "name": "puffy"}
}
"""
Then Response is 500
Всё это здорово, но довольно-таки простовато. Всё же мы бы хотели как-то проверять сам контент возвращаемого JSON-а. Давайте добавим новый шаг, в котором мы бы могли сравнивать контент JSON-а с описанным в тесте.
feature/steps/pet.py
def has_json_response(context) -> bool:
try:
context.response.json()
return True
except ValueError or AttributeError:
return False
@step("Response contains json with items")
def assert_response_contains_json(context):
assert (
has_json_response(context),
f"Expected json, but got {context.response.text}"
)
actual_values = context.response.json()
expected_values = json.loads(context.text)
for field in expected_values:
expected_value = expected_values[field]
actual_value = actual_values[field]
match expected_value:
case "<{any_int}>":
assert (
isinstance(actual_value, int),
f'Expected "{field}" is a number value, got {actual_value}'
)
case _:
assert (
expected_value == actual_value,
f'Expected "{field}" equals {expected_value}, got {actual_value}'
)
Рассмотрев внимательнее реализацию этого степа, можно заметить, что он может делать две вещи:
проверка на равенство значений, если в качестве ожидаемого значения указать число
проверка на принадлежность значения множеству целых чисел, если в качестве ожидаемого значения указать строку
<{any_int}>
Такое поведение пригодится нам, потому что зачастую мы не можем знать наверняка, какое значение имеет то или иное поле, но нам достаточно проверить, что в таком поле содержится любое число.
Обновим первый тест:
features/pet.feature
Scenario: I successfully create new empty pet
When I make POST request to "/pet" with body
Then Response is 200
And Response contains json with items
"""
{
"id": "<{any_int}>",
"tags": [],
"photoUrls": []
}
"""
Напоследок давайте попробуем написать параметризованный сценарий, в котором мы создадим нового питомца с заданными полями:
features/pet.feature
Scenario Outline: I successfully create new pet with specified name, category, status, tags and photo
When I make POST request to "/pet" with body
"""
{
"name": "Barsik",
"category": {"name": "cats"},
"status": "<category>",
"tags": [
{"name": "puffy"},
{"name": "young"}
]
}
"""
Then Response is 200
And Response contains json with items
"""
{
"id": "<{any_int}>",
"name": "Barsik",
"category": {"id": 0, "name": "cats"},
"status": "<category>",
"tags": [
{"id": 0, "name": "puffy"},
{"id": 0, "name": "young"}
],
"photoUrls": []
}
"""
Examples:
| category |
| available |
| pending |
| sold |
Полностью проект доступен по ссылке: github.com/rmksrv/swaggerhub-petstore-bdd
Заключение
Целью этой статьи было познакомить читателя с BDD методологией, и, поскольку это было знакомство, то мы прошлись только по верхам. Тем не менее этого вполне достаточно, чтобы использовать её в своих проектах. Основное преимущество данного подхода заключается в возможности написания тестов на естественном языке, что делает понятным требуемое поведение системы. При вдумчивом подходе, в конце концов, можно получить такие тесты, которые были бы достаточно ясны и читабельны, чтобы их скинуть вашему бизнес-аналитику вместо документации. А в некоторых случаях и бизнес-аналитик сможет сам накидать пару тестовых сценариев.
Материал был написан в феврале 2024-го года на основании опыта, полученного во время работы в ООО «Аурига».
Надеюсь, что статья была интересной и благодарю за внимание!
Комментарии (4)
nronnie
18.04.2024 15:48+1TDD разваливается как только у тестируемого объекта появляются внешние зависимости. Потому что до реализации тестируемого объекта мы практически никогда еще не знаем какие они будут и какими будут сценарии их использования объектом, а не зная этого мы не можем нормально написать тесты. Еще один фактор - я люблю и привык запускать тесте после каждого небольшого изменения или добавления в коде и видеть при этом все зеленое. Лично для меня нет разницы если, допустим, из сотни тестов красный один, десяток, или полсотни - "красный один" для меня эквивалентно "красное все" и требует немедленного исправления (или, в крайнем случае, временного отключения красного теста). Ну а при TDD до того как тестируемый объект будет реализован какая-то часть тестов будет красная. Я сам всегда пишу много юнит-тестов к своему, а часто и к чужому коду, но попытки использовать "канонический" TDD у меня всегда оканчивались фиаско.
Хочу еще вас предостеречь. Тема юнит-тестов, TDD, BDD и иже с ними на Хабре она очень скользкая, почти что на грани табуированной - есть риски нахватать минусов :)) Сам же ставлю вам плюсик :)
rmksrv Автор
18.04.2024 15:48Согласен, что никогда не знаешь какие зависимости понадобятся, пока не сядешь за имплементацию. Можно итеративно подходить к этому: вначале расписать тест-кейсы на уровне `дёрнул метод и проверил результаты` и переходить к реализации. И каждый раз при добавлении новой зависимости в компонент мы ожидаем, что тест будет красным, а это уже побудит сходить в него и добавить эту новую зависимость
dyadyaSerezha
18.04.2024 15:48Не знаю, чем отличается BDD от обычных юз-кейсов (use cases). В данной же статье показано, как эти юз-кейсы автоматизировать с помощью основанной на Cucumber библиотеке для Python, а вовсе не про BDD. Или BDD и есть автоматизация старых добрых юз-кейсов?
bogdan23
Спасибо автору, все ясно и четко, было интересно и полезно!