API наносит ответный удар
API наносит ответный удар

Привет, Хабр!

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

Общий дизайн API части
Общий дизайн API части

Инструменты (можно использовать любые аналогичные):

  • Falcon — фреймворк для создания REST API микросервисов

  • Pytest — фреймворк для создания тестов

Определение тестов

Дизайн проекта создаём так же, как и в веб-части: сначала определяем тесты, а затем реализуем 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.

Спасибо!

Комментарии (0)