Привет, Хабр!
В предыдущей статье мы определили общий дизайн примера 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.
Спасибо!