
Привет, Хабр!
В предыдущей статье мы определили общий дизайн примера Full Stack проекта, а также спроектировали и создали веб-часть с помощью функциональных тестов. Рекомендую ознакомиться с предыдущей частью для лучшего понимания материала ниже.
Далее рассмотрим тот же подход (дизайн через определение функциональных тестов) применительно к оставшейся API части проекта и релизу всего Full Stack проекта. Мы будем использовать Python, хотя можно применить любой другой язык.
Как помогла веб-часть?
Созданная веб-часть обеспечивает:
регистрацию пользователя;
вход зарегистрированного пользователя;
отображение информации о пользователе.
Веб-часть может работать без API благодаря мокам. Они помогут нам определить более детальные цели API части.
Моки, определённые в веб-части (mocks.ts):
const mockAuthRequest = async (page: Page, url: string) => { await page.route(url, async (route) => { if (route.request().method() === 'GET') { if (await route.request().headerValue('Authorization')) { await route.fulfill({status: StatusCodes.OK}) } } }) } export const mockUserExistance = async (page: Page, url: string) => { await mockAuthRequest(page, url) } export const mockUserInfo = async (page: Page, url: string, expectedApiResponse: object) => { await mockRequest(page, url, expectedApiResponse) } export const mockUserNotFound = async (page: Page, url: string) => { await mockRequest(page, url, {}, StatusCodes.NOT_FOUND) } export const mockServerError = async (page: Page, url: string) => { await mockRequest(page, url, {}, StatusCodes.INTERNAL_SERVER_ERROR) } export const mockUserAdd = async (page: Page, userInfo: UserInfo, url: string) => { await mockRequest(page, url, {}, StatusCodes.CREATED, 'POST') } export const mockUserAddFail = async (page: Page, expectedApiResponse: object, url: string) => { await mockRequest(page, url, expectedApiResponse, StatusCodes.BAD_REQUEST, 'POST') } export const mockExistingUserAddFail = async (page: Page, userInfo: UserInfo, url: string) => { await mockRequest(page, url, {}, StatusCodes.CONFLICT, 'POST') } export const mockServerErrorUserAddFail = async (page: Page, url: string) => { await mockRequest(page, url, {}, StatusCodes.INTERNAL_SERVER_ERROR, 'POST') }
Определение целей и дизайн
На основе созданной веб-части можно определить цели API (use cases):
Аутентификация пользователя
Добавление пользователя в систему
Удаление пользователя
Получение информации о пользователе
Из тестов веб-части мы также получаем определения endpoints, которые должны быть реализованы в API:
/user - методы GET и POST
/user_info/${username} - метод GET
Для полноты функционала системы следует добавить метод DELETE для endpoint /user, хотя он и не используется в веб-проекте.

Инструменты (можно использовать любые аналогичные):
Определение тестов
Дизайн проекта создаём так же, как и в веб-части: сначала определяем тесты, а затем реализуем endpoints в API-сервере. По сравнению с веб-частью, тесты API значительно проще.Код тестов для удаления, создания, аутентификации пользователя и получения информации можно посмотреть здесь.
Приведу только пример тестов и endpoint для удаления пользователя:
from hamcrest import assert_that, equal_to from requests import request, codes, Response from src.storage.UserInfoType import UserInfoType from tests.constants import BASE_URL, USR_URL, USR_INFO_URL from tests.functional.utils.user import add_user class TestDeleteUser: @staticmethod def _deleting(user_name: str) -> Response: url = f"{BASE_URL}/{USR_URL}/{user_name}" return request("DELETE", url) def test_delete_user(self, user_info: UserInfoType): add_user(user_info) response = self._deleting(user_info.name) assert_that( response.status_code, equal_to(codes.ok), "Invalid response status code", ) url = f"{BASE_URL}/{USR_INFO_URL}/{user_info.name}" response = request("GET", url) assert_that( response.status_code, equal_to(codes.not_found), "User is not deleted", ) def test_delete_nonexistent_user(self, user_info: UserInfoType): response = self._deleting(user_info.name) assert_that( response.status_code, equal_to(codes.not_found), "Invalid response status code", ) def test_get_info_deleted_user(self, user_info: UserInfoType): add_user(user_info) self._deleting(user_info.name) url = f"{BASE_URL}/{USR_INFO_URL}/{user_info.name}" response = request("GET", url) assert_that( response.status_code, equal_to(codes.not_found), "User is not deleted", )
Имплементация сервера
Определение endpoints в Falcon (app.py):
import falcon.asgi from src.resources.UserInfo import UserInfo from src.resources.UserOperations import UserOperations from .resources.Health import Health from .storage.UsersInfoStorage import UsersInfoStorage from .storage.UsersInfoStorageInMemory import UsersInfoStorageInMemory def create_app(storage: UsersInfoStorage = UsersInfoStorageInMemory()): app = falcon.asgi.App(cors_enable=True) usr_ops = UserOperations(storage) usr_info = UserInfo(storage) app.add_route("/user", usr_ops) app.add_route("/user_info/{name}", usr_info) app.add_route("/user/{name}", usr_ops) app.add_route("/health", Health()) return app
Далее создаем заглушки (stubs) для endpoints, чтобы сервер мог запуститься, а все тесты на данном этапе не проходили. В качестве заглушки используется код, возвращающий ответ со статусом 501 (Not Implemented).
Пример заглушек из одного из файлов ресурсов Falcon:
class UserOperations: def __init__(self, storage: UsersInfoStorage): self._storage: UsersInfoStorage = storage async def on_get(self, req: Request, resp: Response): resp.status = HTTP_501 async def on_post(self, req: Request, resp: Response): resp.status = HTTP_501 async def on_delete(self, _req: Request, resp: Response, name): resp.status = HTTP_501
Теперь, как и в предыдущей части, начинаем заменять заглушки на необходимый код, пока все тесты не будут проходить (конечный код endpoints можно посмотреть тут).

Этот процесс называется "Red-Green-Refactor"
Пример замены заглушки на конечный код для /user с методом DELETE:
class UserOperations: def __init__(self, storage: UsersInfoStorage): self._storage: UsersInfoStorage = storage async def on_get(self, req: Request, resp: Response): resp.status = HTTP_501 async def on_post(self, req: Request, resp: Response): resp.status = HTTP_501 async def on_delete(self, _req: Request, resp: Response, name): try: self._storage.delete(name) resp.status = HTTP_200 except ValueError as e: update_error_response(e, HTTP_404, resp)
Следует добавить Е2Е тест процесса создания → аутентификации → удаления пользователя (e2e.py):
from hamcrest import assert_that, equal_to from requests import request, codes from src.storage.UserInfoType import UserInfoType from tests.constants import BASE_URL, USR_URL, USR_INFO_URL from tests.functional.utils.user import add_user from tests.utils.auth import create_auth_headers class TestE2E: def test_e2e(self, user_info: UserInfoType): add_user(user_info) url = f"{BASE_URL}/{USR_URL}" response = request("GET", url, headers=create_auth_headers(user_info)) assert_that(response.status_code, equal_to(codes.ok), "User is not authorized") url = f"{BASE_URL}/{USR_INFO_URL}/{user_info.name}" response = request("GET", url) assert_that( response.json(), equal_to(dict(user_info)), "Invalid user info", ) url = f"{BASE_URL}/{USR_URL}/{user_info.name}" request("DELETE", url) url = f"{BASE_URL}/{USR_INFO_URL}/{user_info.name}" response = request("GET", url) assert_that( response.status_code, equal_to(codes.not_found), "User should not be found", )
Итог создания API части
В целом, процесс схож с предыдущей частью для веба, но короче, так как все основные цели уже были определены на этапе проектирования и реализации веб-части. Оставалось только определить тесты для API.

Релиз проекта
Итак, Web и API части проекта готовы и тестируются независимо друг от друга.
Осталось их соединить. Сделать это поможет функциональный тест E2E в веб-части.
Исходя из целей проекта, определенных в предыдущей части, проект должен обеспечить пользователю регистрацию и вход в систему. Именно это и будет проверено в E2E тесте.
import {expect, test} from "@playwright/test"; import axios from 'axios'; import {fail} from 'assert' import {faker} from "@faker-js/faker"; import {buildUserInfo, UserInfo} from "./helpers/user_info"; import {RegistrationPage} from "../infra/page-objects/RegisterationPage"; import {RegistrationSucceededPage} from "../infra/page-objects/RegistrationSucceededPage"; import {LoginPage} from "../infra/page-objects/LoginPage"; import {WelcomePage} from "../infra/page-objects/WelcomePage"; const apiUrl = process.env.API_URL; const apiUserUrl = `${apiUrl}/user` async function createUser(): Promise<UserInfo> { const userInfo = { name: faker.internet.userName(), password: faker.internet.password(), last_name: faker.person.lastName(), first_name: faker.person.firstName(), } try { const response = await axios.post(apiUserUrl, userInfo) expect(response.status, "Invalid status of creating user").toBe(axios.HttpStatusCode.Created) } catch (e) { fail(`Error while creating user info: ${e}`) } return userInfo } test.describe('E2E', {tag: '@e2e'}, () => { let userInfo = null test.describe.configure({mode: 'serial'}); test.beforeAll(() => { expect(apiUrl, 'The API address is invalid').toBeDefined() userInfo = buildUserInfo() }) test.beforeEach(async ({baseURL}) => { try { const response = await axios.get(`${apiUrl}/health`) expect(response.status, 'Incorrect health status of the API service').toBe(axios.HttpStatusCode.Ok) } catch (error) { fail('API service is unreachable') } try { const response = await axios.get(`${baseURL}/health`) expect(response.status, 'The Web App service is not reachable').toBe(axios.HttpStatusCode.Ok) } catch (error) { fail('Web App service is unreachable') } }) test("user should pass registration", async ({page}) => { const registerPage = await new RegistrationPage(page).open() await registerPage.registerUser(userInfo) const successPage = new RegistrationSucceededPage(page) expect(await successPage.isOpen(), `The page ${successPage.name} is not open`).toBeTruthy() }) test("user should login", async ({page}) => { const loginPage = await new LoginPage(page).open() await loginPage.login({username: userInfo.name, password: userInfo.password}) const welcomePage = new WelcomePage(userInfo.name, page) expect(await welcomePage.isOpen(), `User is not on the ${welcomePage.name}`).toBeTruthy() }) });
Важно отметить, что это последовательн��сть тестов. В отличие от предыдущих примеров, где тесты были независимы и могли выполняться параллельно, здесь тесты взаимосвязаны и выполняются последовательно. Кроме того, в этих тестах не используются никакие моки.
Web и API части проекта можно запустить как отдельные сервисы с помощью Docker-контейнеров.
Dockerfile для API-части:
FROM python:3.11-alpine ENV POETRY_VERSION=1.8.1 ENV PORT=8000 WORKDIR /app COPY . . RUN apk --no-cache add curl && pip install "poetry==$POETRY_VERSION" && poetry install --no-root --only=dev EXPOSE $PORT CMD ["sh", "-c", "poetry run uvicorn src.asgi:app --log-level trace --host 0.0.0.0 --port $PORT"]
Dockerfile для Web части:
FROM node:22.7.0-alpine WORKDIR /app COPY . . ENV API_URL="http://localhost:8000" ENV WEB_APP_PORT="3000" RUN apk --no-cache add curl && npm install --production && npm run build EXPOSE $WEB_APP_PORT CMD ["npm", "start"]
Либо оба сервиса сразу как докер композиция:
services: web: image: web container_name: web-app ports: - "3000:3000" environment: - API_URL=http://api:8000 depends_on: api: condition: service_healthy healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:3000/health" ] interval: 5s timeout: 5s retries: 3 api: image: api container_name: api-service ports: - "8000:8000" healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] interval: 5s timeout: 5s retries: 3 networks: default: name: my-network
Для удобства локального запуска обоих сервисов вместе с E2E тестом добавлен скрипт.
Стоит отметить, что возможность запуска всего продукта локально или его отдельных частей является одним из признаков его пригодности к тестированию и удобства разработки.
Тесты должны быть частью процесса CI/CD, поэтому для примера я добавил workflows в репозиторий GitHub. После каждого коммита в репозиторий запускаются следующие workflows:
API — сборка этой части проекта и запуск её тестов. Код здесь.
Web — сборка этой части проекта, запуск веб-сервиса и его тестирование. Код здесь.
E2E — запуск Docker-композиции обеих частей и тестирование её с помощью E2E теста. Код здесь.
Итог
В целом, был рассмотрен процесс проектирования Full Stack проекта с помощью определения функциональных тестов последовательно для веб- и API-частей и реализация кода проекта параллельно с реализацией кода тестов. Это даёт возможность постепенного определения и перехода от общих целей проекта к их более детальным частям, при этом не теряя контроля качества и целостности проекта.
Пример проекта очень простой; естественно, в реальности проекты гораздо сложнее. Поэтому данный способ проектирования больше подходит к проектированию отдельных фичей.
Как уже было указано в предыдущей части, один из недостатков подхода — время разработки.
Другим недостатком является разделённость создаваемых частей проекта, то есть отсутствие их синхронизации в отношении последующих изменений. Например, если в API-части внесены изменения в endpoints, то веб-часть об этом не узнает.
Данная проблема может быть решена либо синхронизацией внутри команды разработчиков (если она небольшая и частота изменений в части API низкая), либо использованием design by contract.
Спасибо!
