Современная разработка через тестирование на Python
Тестировать рабочий код сложно. Иногда это может занять почти все ваше время при разработке функционала. Более того, даже если у вас 100%-ный охват и тесты прошли успешно, вы все равно можете не быть уверены в том, что новая функция будет должным образом работать в рабочей среде.
В этом руководстве вы познакомитесь с разработкой приложения с использованием Разработки, основанной на тестировании (TDD). Мы рассмотрим, как и что вам следует тестировать. Мы будем использовать pytest для тестирования, pydantic для проверки данных и сокращения количества требуемых тестов и Flask предоставить интерфейс для наших клиентов с помощью RESTful API. В конце концов, у вас будет надежный шаблон, который вы сможете использовать для любого проекта на Python, чтобы быть уверенным в том, что прохождение тестов действительно означает работу программного обеспечения.
Полное руководство по Python:
Содержимое
- Цели
- Как Я должен протестировать Свое Программное Обеспечение?
- Базовая настройка
- Реальное приложение
- Испытательные приспособления
- Откройте API с помощью Flask
- Покрытие кода
- Комплексные испытания
- Тестируем пирамиду
- Что такое модуль?
- Когда следует использовать Mocks?
- Выводы
- Заключение
Цели
К концу этой статьи вы сможете:
- Объясните, как вы должны тестировать свое программное обеспечение
- Сконфигурируйте pytest и настройте структуру проекта для тестирования
- Определяйте модели баз данных с помощью pydantic
- Используйте инструменты pytest для управления состоянием тестирования и выполнения побочных эффектов
- Сверьте ответы JSON с определениями схемы JSON
- Организуйте операции с базой данных с помощью команд (изменение состояния, имеет побочные эффекты) и запросов (только для чтения, без побочных эффектов)
- Пишите модульные, интеграционные и сквозные тесты с помощью pytest
- Объясните, почему важно сосредоточить свои усилия на тестировании поведения, а не на деталях реализации
Как мне следует протестировать Свое Программное Обеспечение?
Разработчики программного обеспечения, как правило, очень самоуверенны в отношении тестирования. Из-за этого у них разные мнения о том, насколько важно тестирование, и идеи о том, как его проводить. Тем не менее, давайте рассмотрим три рекомендации, с которыми (надеюсь) согласится большинство разработчиков и которые помогут вам написать ценные тесты:
-
Тесты должны сообщать вам об ожидаемом поведении тестируемого устройства. Поэтому желательно, чтобы они были краткими и по существу. Приведенная структура , КОГДА, ЗАТЕМ может помочь в этом:
- ДАНО - каковы начальные условия для теста?
- КОГДА - что происходит, что необходимо протестировать?
- ТОГДА - каков ожидаемый ответ?
Таким образом, вы должны подготовить свою среду к тестированию, выполнить поведение и, в конце концов, проверить, соответствует ли результат ожиданиям. -
Каждый элемент поведения должен быть протестирован один раз - и только один раз. Повторное тестирование одного и того же поведения не означает, что вероятность того, что ваше программное обеспечение заработает, выше. Тесты также необходимо поддерживать. Если вы вносите небольшие изменения в свой базовый код, а затем нарушаете работу двадцати тестов, как вы узнаете, какая функциональность нарушена? Когда ошибка не выполняется только в одном тесте, гораздо проще найти ошибку.
-
Каждый тест должен быть независимым от других тестов. В противном случае вам будет сложно поддерживать и запускать набор тестов.
Это руководство слишком самоуверенно. Не принимайте ничего за святой Грааль или серебряную пулю. Не стесняйтесь обращаться к нам в Twitter (@jangiacomelli), чтобы обсудить все, что связано с этим руководством.
Основные настройки
Итак, давайте приступим к делу. Теперь вы готовы увидеть, что все это значит в реальном мире. Самый простой тест с помощью pytest выглядит следующим образом:
def another_sum(a, b):
return a + b
def test_another_sum():
assert another_sum(3, 2) == 5
Это пример, который вы, вероятно, уже видели хотя бы раз. Прежде всего, вы никогда не будете писать тесты внутри своей базы кода, поэтому давайте разделим это на два файла и пакеты.
Создайте новый каталог для этого проекта и перейдите в него:
$ mkdir testing_project
$ cd testing_project
Затем создайте (и активируйте) виртуальную среду.
Подробнее об управлении зависимостями и виртуальными средами читайте в статье Современные среды Python.
В-третьих, установите pytest:
(venv)$ pip install pytest
После этого создайте новую папку под названием "sum". Добавьте __init__.py в новую папку, чтобы превратить ее в пакет, вместе с another_sum.py файлом:
def another_sum(a, b):
return a + b
Добавьте еще одну папку с именем "тесты" и добавьте следующие файлы и папки:
└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py
Теперь у вас должно получиться:
├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py
В test_another_sum.py добавить:
from sum.another_sum import another_sum
def test_another_sum():
assert another_sum(3, 2) == 5
Затем добавьте пустой conftest.py файл, который используется для хранения тестов pytest fixtures, в папку "тесты".
Наконец, добавьте pytest.ini - файл конфигурации pytest - в папку "тесты", которая на данный момент также может быть пустой.
Теперь полная структура проекта должна выглядеть следующим образом:
├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_sum
├── __init__.py
└── test_another_sum.py
Объединение ваших тестов в одном пакете позволяет вам:
- Повторное использование конфигурации pytest во всех тестах
- Повторное использование настроек во всех тестах
- Упрощение выполнения тестов
Вы можете запустить все тесты с помощью этой команды:
(venv)$ python -m pytest tests
Вы должны увидеть результаты тестов, которые в данном случае предназначены для test_another_sum:
============================== test session starts ==============================
platform darwin -- Python 3.10.1, pytest-7.0.1, pluggy-1.0.0
rootdir: /testing_project/tests, configfile: pytest.ini
collected 1 item
tests/test_sum.py/test_another_sum.py . [100%]
=============================== 1 passed in 0.01s ===============================
Реальное применение
Теперь, когда у вас есть общее представление о том, как настраивать и структурировать тесты, давайте создадим простое приложение для блога. Мы создадим его с использованием TDD, чтобы увидеть тестирование в действии. Мы будем использовать Flask для нашего веб-фреймворка и, чтобы сосредоточиться на тестировании, SQLite для нашей базы данных.
К нашему приложению будут предъявляться следующие требования:
- статьи могут быть созданы
- статьи могут быть выбраны
- статьи могут быть перечислены
Сначала давайте создадим новый проект:
$ mkdir blog_app
$ cd blog_app
Во-вторых, создайте (и активируйте) виртуальную среду.
В-третьих, установите pytest и pydantic, библиотеку для анализа и проверки данных:
(venv)$ pip install pytest && pip install "pydantic[email]"
pip install "pydantic[email]"устанавливает pydantic вместе с средством проверки электронной почты, которое будет использоваться для проверки адресов электронной почты.
Далее создайте следующие файлы и папки:
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
└── pytest.ini
Добавьте следующий код в models.py , чтобы определить новую модель Article с помощью pydantic:
import os
import sqlite3
import uuid
from typing import List
from pydantic import BaseModel, EmailStr, Field
class NotFound(Exception):
pass
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE id=?", (article_id,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def get_by_title(cls, title: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE title = ?", (title,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def list(cls) -> List["Article"]:
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles")
records = cur.fetchall()
articles = [cls(**record) for record in records]
con.close()
return articles
def save(self) -> "Article":
with sqlite3.connect(os.getenv("DATABASE_NAME", "database.db")) as con:
cur = con.cursor()
cur.execute(
"INSERT INTO articles (id,author,title,content) VALUES(?, ?, ?, ?)",
(self.id, self.author, self.title, self.content)
)
con.commit()
return self
@classmethod
def create_table(cls, database_name="database.db"):
conn = sqlite3.connect(database_name)
conn.execute(
"CREATE TABLE IF NOT EXISTS articles (id TEXT, author TEXT, title TEXT, content TEXT)"
)
conn.close()
Это модель в стиле Активной записи, которая предоставляет методы для хранения, извлечения отдельной статьи и перечисления всех статей.
Возможно, вам интересно, почему мы не написали тесты для этой модели. Вскоре мы ответим на этот вопрос.
Создать новую статью
Далее давайте рассмотрим нашу бизнес-логику. Мы напишем несколько вспомогательных команд и запросов, чтобы отделить нашу логику от модели и API. Поскольку мы используем pydantic, мы можем легко проверять данные на основе нашей модели.
Создайте пакет "test_article" в папке "тесты". Затем добавьте в него файл с именем test_commands.py .
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
└── test_commands.py
Добавьте следующие тесты в test_commands.py:
import pytest
from blog.models import Article
from blog.commands import CreateArticleCommand, AlreadyExists
def test_create_article():
"""
GIVEN CreateArticleCommand with valid author, title, and content properties
WHEN the execute method is called
THEN a new Article must exist in the database with the same attributes
"""
cmd = CreateArticleCommand(
author="[email protected]",
title="New Article",
content="Super awesome article"
)
article = cmd.execute()
db_article = Article.get_by_id(article.id)
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content
def test_create_article_already_exists():
"""
GIVEN CreateArticleCommand with a title of some article in database
WHEN the execute method is called
THEN the AlreadyExists exception must be raised
"""
Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
cmd = CreateArticleCommand(
author="[email protected]",
title="New Article",
content="Super awesome article"
)
with pytest.raises(AlreadyExists):
cmd.execute()
Эти тесты охватывают следующие варианты использования в бизнесе:
- статьи должны создаваться на основе достоверных данных
- название статьи должно быть уникальным
Запустите тесты из каталога вашего проекта, чтобы убедиться, что они завершились ошибкой:
(venv)$ python -m pytest tests
Теперь мы можем реализовать нашу команду.
Добавьте commands.py файл в папку "блог":
from pydantic import BaseModel, EmailStr
from blog.models import Article, NotFound
class AlreadyExists(Exception):
pass
class CreateArticleCommand(BaseModel):
author: EmailStr
title: str
content: str
def execute(self) -> Article:
try:
Article.get_by_title(self.title)
raise AlreadyExists
except NotFound:
pass
article = Article(
author=self.author,
title=self.title,
content=self.content
).save()
return article
Тестовые приспособления
Мы можем использовать настройки pytest, чтобы очищать базу данных после каждого теста и создавать новую перед каждым тестированием. Настройки - это функции, оформленные с помощью @pytest.fixture декоратора. Обычно они находятся внутри conftest.py , но их можно добавить и в сами тестовые файлы. Эти функции выполняются по умолчанию перед каждым тестом.
Один из вариантов - использовать их возвращаемые значения в ваших тестах. Например:
import random
import pytest
@pytest.fixture
def random_name():
names = ["John", "Jane", "Marry"]
return random.choice(names)
def test_fixture_usage(random_name):
assert random_name
Итак, чтобы использовать значение, возвращаемое из fixture, в тесте, вам просто нужно добавить имя функции fixture в качестве параметра в тестовую функцию.
Другой вариант - выполнить побочный эффект, например, создать базу данных или имитировать модуль.
Вы также можете запустить часть прибора до и часть после теста, используя yield вместо return. Например:
@pytest.fixture
def some_fixture():
# do something before your test
yield # test runs here
# do something after your test
Теперь добавьте следующий параметр в conftest.py , который создает новую базу данных перед каждым тестированием и удаляет ее после:
import os
import tempfile
import pytest
from blog.models import Article
@pytest.fixture(autouse=True)
def database():
_, file_name = tempfile.mkstemp()
os.environ["DATABASE_NAME"] = file_name
Article.create_table(database_name=file_name)
yield
os.unlink(file_name)
Флагу autouse присвоено значение True, чтобы он автоматически использовался по умолчанию до (и после) каждого теста в наборе тестов. Поскольку мы используем базу данных для всех тестов, имеет смысл использовать этот флаг. Таким образом, вам не нужно явно добавлять имя прибора в каждый тест в качестве параметра.
Если вам не нужен доступ к базе данных для тестирования, вы можете отключить
autouseс помощью тестового маркера. Вы можете увидеть пример этого здесь.
Запустите тесты еще раз:
(venv)$ python -m pytest tests
Они должны пройти.
Как вы можете видеть, в нашем тесте проверяется только команда CreateArticleCommand. Мы не тестируем саму модель Article, поскольку она не отвечает за бизнес-логику. Мы знаем, что команда работает так, как ожидалось. Таким образом, нет необходимости писать какие-либо дополнительные тесты.
Список всех статей
Следующее требование - перечислить все статьи. Здесь мы будем использовать запрос вместо команды, поэтому добавьте новый файл с именем test_queries.py в папку "test_article":
from blog.models import Article
from blog.queries import ListArticlesQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="[email protected]",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2
Запустите тесты:
(venv)$ python -m pytest tests
Они должны потерпеть неудачу.
Добавьте queries.py файл в папку "блог":
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ ├── commands.py
│ ├── models.py
│ └── queries.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
├── test_commands.py
└── test_queries.py
Теперь мы можем реализовать наш запрос:
from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles
Несмотря на то, что здесь нет параметров, для согласованности мы унаследовали от BaseModel.
Запустите тесты еще раз:
(venv)$ python -m pytest tests
Теперь они должны пройти.
Получить статью по идентификатору
Получение отдельной статьи по ее идентификатору может быть выполнено аналогично перечислению всех статей. Добавьте новый тест для GetArticleByIDQuery в test_queries.py.:
from blog.models import Article
from blog.queries import ListArticlesQuery, GetArticleByIDQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="[email protected]",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2
def test_get_article_by_id():
"""
GIVEN ID of article stored in the database
WHEN the execute method is called on GetArticleByIDQuery with an ID
THEN it should return the article with the same ID
"""
article = Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
query = GetArticleByIDQuery(
id=article.id
)
assert query.execute().id == article.id
Запустите тесты, чтобы убедиться в их успешности:
(venv)$ python -m pytest tests
Затем добавьте GetArticleByIDQuery к queries.py:
from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles
class GetArticleByIDQuery(BaseModel):
id: str
def execute(self) -> Article:
article = Article.get_by_id(self.id)
return article
Теперь тесты должны быть пройдены:
(venv)$ python -m pytest tests
Приятно. Мы отвечаем всем вышеперечисленным требованиям:
- статьи могут быть созданы
- статьи могут быть выбраны
- статьи могут быть перечислены
И все они покрыты тестами. Поскольку мы используем pydantic для проверки данных во время выполнения, нам не нужно много тестов, чтобы охватить бизнес-логику, поскольку нам не нужно писать тесты для проверки данных. Если author не является допустимым адресом электронной почты, pydantic выдаст сообщение об ошибке. Все, что было необходимо, - это присвоить атрибуту author значение EmailStr. Нам также не нужно его тестировать, потому что он уже тестируется разработчиками pydantic.
Таким образом, мы готовы представить эту функциональность миру с помощью Flask RESTful API.
Откройте API с помощью Flask
Мы представим три конечные точки, которые удовлетворяют этому требованию:
/create-article/- создайте новую статью/article-list/- извлеките все статьи/article/<article_id>/- выберите одну статью
Сначала создайте папку под названием "schemas" внутри "test_article" и добавьте в нее две схемы в формате JSON, Article.json и ArticleList.json.
Статья.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Article",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"author": {
"type": "string"
},
"title": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["id", "author", "title", "content"]
}
ArticleList.json Список статей.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ArticleList",
"type": "array",
"items": {"$ref": "file:Article.json"}
}
Схемы JSON используются для определения ответов от конечных точек API. Прежде чем продолжить, установите библиотеку Python jsonschema, которая будет использоваться для проверки полезной нагрузки в формате JSON на соответствие определенным схемам, и Flask:
(venv)$ pip install jsonschema Flask
Далее давайте напишем интеграционные тесты для нашего API.
Добавьте новый файл с именем test_app.py в "test_article":
import json
import pathlib
import pytest
from jsonschema import validate, RefResolver
from blog.app import app
from blog.models import Article
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def validate_payload(payload, schema_name):
"""
Validate payload with selected schema
"""
schemas_dir = str(
f"{pathlib.Path(__file__).parent.absolute()}/schemas"
)
schema = json.loads(pathlib.Path(f"{schemas_dir}/{schema_name}").read_text())
validate(
payload,
schema,
resolver=RefResolver(
"file://" + str(pathlib.Path(f"{schemas_dir}/{schema_name}").absolute()),
schema # it's used to resolve the file inside schemas correctly
)
)
def test_create_article(client):
"""
GIVEN request data for new article
WHEN endpoint /create-article/ is called
THEN it should return Article in json format that matches the schema
"""
data = {
'author': "[email protected]",
"title": "New Article",
"content": "Some extra awesome content"
}
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_get_article(client):
"""
GIVEN ID of article stored in the database
WHEN endpoint /article/<id-of-article>/ is called
THEN it should return Article in json format that matches the schema
"""
article = Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
f"/article/{article.id}/",
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_list_articles(client):
"""
GIVEN articles stored in the database
WHEN endpoint /article-list/ is called
THEN it should return list of Article in json format that matches the schema
"""
Article(
author="[email protected]",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
"/article-list/",
content_type="application/json",
)
validate_payload(response.json, "ArticleList.json")
Итак, что здесь происходит?
- Во-первых, мы определили тестовый клиент Flask как приспособление, чтобы его можно было использовать в тестах.
- Далее мы добавили функцию для проверки полезной нагрузки. Она принимает два параметра:
payload- JSON-ответ от APIschema_name- имя файла схемы в каталоге "schemas"
- Наконец, есть три теста, по одному для каждой конечной точки. Внутри каждого теста есть вызов API и проверка возвращаемой полезной нагрузки
Запустите тесты, чтобы убедиться, что они завершатся неудачей на этом этапе:
(venv)$ python -m pytest tests
Теперь мы можем написать API.
Обновить app.py вот так:
from flask import Flask, jsonify, request
from blog.commands import CreateArticleCommand
from blog.queries import GetArticleByIDQuery, ListArticlesQuery
app = Flask(__name__)
@app.route("/create-article/", methods=["POST"])
def create_article():
cmd = CreateArticleCommand(
**request.json
)
return jsonify(cmd.execute().dict())
@app.route("/article/<article_id>/", methods=["GET"])
def get_article(article_id):
query = GetArticleByIDQuery(
id=article_id
)
return jsonify(query.execute().dict())
@app.route("/article-list/", methods=["GET"])
def list_articles():
query = ListArticlesQuery()
records = [record.dict() for record in query.execute()]
return jsonify(records)
if __name__ == "__main__":
app.run()
Наши обработчики маршрутов довольно просты, поскольку вся наша логика заключается в командах и запросах. Доступные действия с побочными эффектами (например, мутации) представлены командами - например, создание новой статьи. С другой стороны, действия, которые не имеют побочных эффектов, то есть просто считывают текущее состояние, охватываются запросами.
Шаблон команд и запросов, используемый в этой статье, является упрощенной версией шаблона CQRS. Мы объединяем CQRS и CRUD.
Метод
.dict(), описанный выше, предоставляетсяBaseModelиз pydantic, от которого унаследованы все наши модели.
Тесты должны пройти:
(venv)$ python -m pytest tests
Мы рассмотрели сценарии "счастливого пути". В реальном мире мы должны ожидать, что клиенты не всегда будут использовать API так, как это было задумано. Например, когда запрос на создание статьи отправляется без title, команда ValidationError вызовет CreateArticleCommand, что приведет к внутренней ошибке сервера и HTTP-статусу 500. Это то, чего мы хотим избежать. Поэтому нам нужно обрабатывать такие ошибки, чтобы корректно уведомлять пользователя о неверном запросе.
Давайте напишем тесты для таких случаев. Добавьте следующее к test_app.py:
@pytest.mark.parametrize(
"data",
[
{
"author": "John Doe",
"title": "New Article",
"content": "Some extra awesome content"
},
{
"author": "John Doe",
"title": "New Article",
},
{
"author": "John Doe",
"title": None,
"content": "Some extra awesome content"
}
]
)
def test_create_article_bad_request(client, data):
"""
GIVEN request data with invalid values or missing attributes
WHEN endpoint /create-article/ is called
THEN it should return status 400
"""
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
assert response.status_code == 400
assert response.json is not None
Мы использовали опцию параметризации в pytest, которая упрощает передачу нескольких входных данных в один тест.
На этом этапе тест должен завершиться неудачей, потому что мы еще не обработали ValidationError:
(venv)$ python -m pytest tests
Итак, давайте добавим обработчик ошибок в приложение Flask внутри app.py:
from pydantic import ValidationError
# Other code ...
app = Flask(__name__)
@app.errorhandler(ValidationError)
def handle_validation_exception(error):
response = jsonify(error.errors())
response.status_code = 400
return response
# Other code ...
ValidationError имеет метод errors, который возвращает список всех ошибок для каждого поля, которое либо отсутствовало, либо передало значение, не прошедшее проверку. Мы можем просто вернуть это в теле сообщения и присвоить ответу статус 400.
Теперь, когда ошибка обработана надлежащим образом, все тесты должны пройти:
(venv)$ python -m pytest tests
Покрытие кода
Теперь, когда наше приложение протестировано, пришло время проверить покрытие кода. Итак, давайте установим плагин pytest для покрытия, который называется pytest-cov:
(venv)$ pip install pytest-cov
После установки плагина мы можем проверить покрытие кода нашего приложения для блога следующим образом:
(venv)$ python -m pytest tests --cov=blog
Вы должны увидеть что-то похожее на:
---------- coverage: platform darwin, python 3.10.1-final-0 ----------
Name Stmts Miss Cover
--------------------------------------
blog/__init__.py 0 0 100%
blog/app.py 25 1 96%
blog/commands.py 16 0 100%
blog/models.py 57 1 98%
blog/queries.py 12 0 100%
--------------------------------------
TOTAL 110 2 98%
Достаточно ли 98%-ного охвата? Вероятно, так оно и есть. Тем не менее, помните одну вещь: высокий процент охвата - это здорово, но качество ваших тестов гораздо важнее. Если охвачено только 70% кода или меньше, вам следует подумать об увеличении процента охвата. Но, как правило, нет смысла писать тесты, чтобы увеличить его с 98% до 100%. (Опять же, тесты должны поддерживаться точно так же, как и ваша бизнес-логика!)
Комплексные тесты
На данный момент у нас есть работающий API, который полностью протестирован. Теперь мы можем рассмотреть, как написать несколько сквозных (e2e) тестов. Поскольку у нас есть простой API, мы можем написать один e2e-тест для выполнения следующего сценария:
- создать новую статью
- перечислить статьи
- выберите первую статью из списка
Сначала установите библиотеку запросов:
(venv)$ pip install requests
Во-вторых, добавьте новый тест в test_app.py:
import requests
# other code ...
@pytest.mark.e2e
def test_create_list_get(client):
requests.post(
"http://localhost:5000/create-article/",
json={
"author": "[email protected]",
"title": "New Article",
"content": "Some extra awesome content"
}
)
response = requests.get(
"http://localhost:5000/article-list/",
)
articles = response.json()
response = requests.get(
f"http://localhost:5000/article/{articles[0]['id']}/",
)
assert response.status_code == 200
Есть две вещи, которые нам нужно сделать перед запуском этого теста...
Сначала зарегистрируйте маркер с именем e2e в pytest, добавив следующий код в файл pytest.ini:
[pytest]
markers =
e2e: marks tests as e2e (deselect with '-m "not e2e"')
маркеры pytest используются для исключения некоторых тестов из выполнения или для включения выбранных тестов независимо от их местоположения.
Чтобы запустить только тесты e2e, выполните:
(venv)$ python -m pytest tests -m 'e2e'
Для запуска всех тестов, кроме e2e:
(venv)$ python -m pytest tests -m 'not e2e'
выполнение тестов e2e обходится дороже и требует, чтобы приложение было запущено, поэтому вы, вероятно, не захотите запускать их постоянно.
Поскольку наш e2e-тест проходит на реальном сервере, нам нужно запустить приложение. Перейдите к проекту в новом окне терминала, активируйте виртуальную среду и запустите приложение:
(venv)$ FLASK_APP=blog/app.py python -m flask run
Теперь мы можем запустить наш e2e-тест:
(venv)$ python -m pytest tests -m 'e2e'
Вы должны увидеть ошибку 500. Почему? Разве модульные тесты не проходят успешно? Да. Проблема в том, что мы не создали таблицу базы данных. В наших тестах мы использовали приспособления для этого, которые делают это за нас. Итак, давайте создадим таблицу и базу данных.
Добавьте init_db.py файл в папку "блог":
if __name__ == "__main__":
from blog.models import Article
Article.create_table()
Запустите новый скрипт и запустите сервер заново:
(venv)$ python blog/init_db.py
(venv)$ FLASK_APP=blog/app.py python -m flask run
Если у вас возникнут какие-либо проблемы при запуске init_db.py , возможно, вам потребуется задать путь к Python:
export PYTHONPATH=$PYTHONPATH:$PWD.
Теперь тест должен пройти:
(venv)$ python -m pytest tests -m 'e2e'
Тестируем пирамиду
Мы начали с модульных тестов (для проверки команд и запросов), за которыми последовали интеграционные тесты (для проверки конечных точек API), и закончили тестами e2e. В простых приложениях, таких как в этом примере, вы можете получить одинаковое количество модульных и интеграционных тестов. В целом, чем больше сложность, тем больше вы должны видеть пирамидальную форму взаимосвязи между модульными, интеграционными и e2e-тестами. Вот откуда взялся термин "тестовая пирамида".
Тестовая пирамида - это фреймворк, который может помочь разработчикам создавать высококачественное программное обеспечение.

Используя пирамиду тестов в качестве ориентира, вы обычно хотите, чтобы 50% тестов в вашем наборе тестов были модульными тестами, 30% - интеграционными тестами и 20% - тестами e2e.
Определения:
- Модульный тест - проверяет один блок кода
- Интеграционные тесты - проверяет совместную работу нескольких блоков
- e2e - тестирует все приложение на реальном производственном сервере
Чем выше вы поднимаетесь по пирамиде, тем более хрупкими и менее предсказуемыми становятся ваши тесты. Более того, e2e-тесты на сегодняшний день выполняются медленнее всего, поэтому, хотя они и дают уверенность в том, что ваше приложение выполняет все, что от него ожидается, их не должно быть так много, как модульных или интеграционных тестов.
Что такое единица измерения?
Довольно просто представить, как выглядят интеграционные и e2e-тесты. О модульных тестах говорится гораздо больше, поскольку сначала нужно определить, что такое "модуль" на самом деле. В большинстве руководств по тестированию приводится пример модульного теста, в котором тестируется одна функция или метод. Производственный код никогда не бывает таким простым.
Прежде чем определить, что такое модуль, давайте разберемся, в чем вообще заключается смысл тестирования и что следует тестировать.
Зачем проводить тестирование?
Мы пишем тесты для:
- Убедитесь, что наш код работает должным образом
- Защитите наше программное обеспечение от регрессий
Тем не менее, когда циклы обратной связи становятся слишком длинными, разработчики, как правило, начинают больше задумываться о типах тестов для написания, поскольку время является основным ограничением при разработке программного обеспечения. Вот почему мы хотим, чтобы модульных тестов было больше, чем других типов тестов. Мы хотим найти и устранить дефект как можно быстрее.
Что нужно протестировать?
Теперь, когда вы знаете почему мы должны тестировать, мы должны рассмотреть что мы должны тестировать.
Нам следует протестировать поведение нашего программного обеспечения. (И, да: это по-прежнему относится к TDD, а не только к BDD.) Это связано с тем, что вам не нужно менять свои тесты каждый раз, когда происходят изменения в базе кода.
Вернемся к примеру реального приложения. С точки зрения тестирования, нам все равно, где хранятся статьи. Это может быть текстовый файл, какая-либо другая реляционная база данных или хранилище ключей/значений - это не имеет значения. Опять же, к нашему приложению предъявлялись следующие требования:
- статьи могут быть созданы
- статьи могут быть выбраны
- статьи могут быть перечислены
До тех пор, пока эти требования не изменятся, изменение носителя информации не должно привести к нарушению результатов наших тестов. Аналогичным образом, мы знаем, что пока эти тесты проходят успешно, мы знаем, что наше программное обеспечение соответствует этим требованиям - значит, оно работает.
Так что же тогда такое Единица измерения?
Технически каждая функция/метод представляет собой единое целое, но мы все равно не должны тестировать каждую из них. Вместо этого сосредоточьте свои усилия на тестировании функций и методов, которые доступны в открытом доступе из модуля/пакета.
В нашем случае это были методы execute. Мы не планируем вызывать модель Article непосредственно из Flask API, поэтому не уделяйте много внимания (если вообще уделяем) ее тестированию. Если быть более точным, то в нашем случае "единицами", которые следует протестировать, являются execute методы из команд и запросов. Если какой-то метод не предназначен для прямого вызова из других частей нашего программного обеспечения или конечным пользователем, это, вероятно, связано с деталями реализации. Следовательно, наши тесты устойчивы к рефакторингу с учетом деталей реализации, что является одним из качеств отличных тестов.
Например, наши тесты все равно должны были пройти, если мы обернули логику для get_by_id и get_by_title в "защищенный" метод, называемый _get_by_attribute:
# other code ...
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE id=?", (article_id,))
@classmethod
def get_by_title(cls, title: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE title = ?", (title,))
@classmethod
def _get_by_attribute(cls, sql_query: str, sql_query_values: tuple):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute(sql_query, sql_query_values)
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
# other code ..
С другой стороны, если вы внесете критические изменения в Article, тесты завершатся неудачей. И это именно то, чего мы хотим. В такой ситуации мы можем либо отменить критическое изменение, либо адаптироваться к нему внутри нашей команды или запроса.
Потому что есть одна вещь, к которой мы стремимся: прохождение тестов означает работу программного обеспечения.
Когда следует использовать Mocks?
Мы не использовали никаких макетов в наших тестах, потому что они нам были не нужны. Макетирование методов или классов внутри ваших модулей или пакетов приводит к созданию тестов, которые не поддаются рефакторингу, поскольку они связаны с деталями реализации. Такие тесты часто ломаются, и их обслуживание обходится дорого. С другой стороны, имеет смысл имитировать внешние ресурсы, когда речь идет о скорости (вызовы внешних API, отправка электронных писем, длительные асинхронные процессы и т.д.).
Например, мы могли бы протестировать модель Article отдельно и смоделировать ее в наших тестах для CreateArticleCommand следующим образом:
def test_create_article(monkeypatch):
"""
GIVEN CreateArticleCommand with valid properties author, title and content
WHEN the execute method is called
THEN a new Article must exist in the database with same attributes
"""
article = Article(
author="[email protected]",
title="New Article",
content="Super awesome article"
)
monkeypatch.setattr(
Article,
"save",
lambda self: article
)
cmd = CreateArticleCommand(
author="[email protected]",
title="New Article",
content="Super awesome article"
)
db_article = cmd.execute()
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content
Да, это совершенно нормально, но теперь нам нужно поддерживать больше тестов - то есть все предыдущие тесты плюс все новые тесты для методов из Article. Кроме того, единственное, что теперь проверяется с помощью test_create_article, это то, что статья, возвращаемая с помощью save, совпадает с статьей, возвращаемой с помощью execute. Когда мы ломаем что-то внутри Article, этот тест все равно проходит, потому что мы его подделали. И этого мы хотим избежать: мы хотим протестировать поведение программного обеспечения, чтобы убедиться, что оно работает так, как ожидалось. В этом случае поведение нарушено, но наш тест этого не покажет.
Блюда на вынос
- Не существует единственно правильного способа тестирования вашего программного обеспечения. Тем не менее, логику легче тестировать, когда она не связана с вашей базой данных. Вы можете использовать шаблон активной записи с командами и запросами (CQRS), чтобы помочь в этом.
- Сосредоточьтесь на бизнес-ценности вашего кода.
- Не тестируйте методы только для того, чтобы сказать, что они протестированы. Вам нужно работающее программное обеспечение, а не проверенные методы. TDD - это всего лишь инструмент для быстрой и надежной доставки лучшего программного обеспечения. То же самое можно сказать и о покрытии кода: старайтесь поддерживать его на высоком уровне, но не добавляйте тесты только для того, чтобы обеспечить 100%-ный охват.
- Тест ценен только тогда, когда он защищает вас от регрессий, позволяет провести рефакторинг и обеспечивает быструю обратную связь. Поэтому вам следует стремиться к тому, чтобы ваши тесты напоминали форму пирамиды (50% единичных, 30% интегрированных, 20% e2e). Хотя в простых приложениях это может больше походить на дом (40% площади, 40% интеграции, 20% e2e), и это нормально.
- Чем быстрее вы заметите регрессии, тем быстрее сможете их перехватить и исправить. Чем быстрее вы их исправите, тем короче цикл разработки. Чтобы ускорить обратную связь, вы можете использовать маркеры pytest для исключения e2e и других медленных тестов во время разработки. Вы можете запускать их реже.
- Используйте mocks только при необходимости (например, для сторонних HTTP API). Они усложняют настройку теста и в целом делают тесты менее устойчивыми к рефакторингу. Кроме того, они могут привести к ложным срабатываниям.
- Еще раз повторю, что ваши тесты - это ответственность, а не актив; они должны учитывать поведение вашего программного обеспечения, но не перегружать его тестами.
Заключение
Здесь многое нужно переварить. Имейте в виду, что это всего лишь примеры, используемые для демонстрации идей. Вы можете использовать те же идеи с Предметно-ориентированным проектированием (DDD), поведенческим проектированием (BDD) и многими другими подходами. Помните, что к тестам следует относиться так же, как и к любому другому коду: они являются помехой, а не преимуществом. Пишите тесты, чтобы защитить свое программное обеспечение от ошибок, но не позволяйте им отнимать у вас время.
Хотите узнать больше?
- TDD - Где все пошло не так?
- TDD с помощью Python
- Шаблон Pytest: использование "parametrize" для настройки вложенных приспособлений
Back to TopПолное руководство по Python: