Search
Write a publication
Pull to refresh

Пример создания Full Stack проекта c использованием функционального тестирования как инструмента дизайна (продолжение)

Level of difficultyMedium
Reading time9 min
Views1.8K
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.

Спасибо!

Only registered users can participate in poll. Log in, please.
Когда вы обдумываете дизайн создаваемого продукта, важна ли для вас его пригодность к автоматическому тестированию?
28.57% Да2
42.86% Нет3
28.57% Не остается времени и сил на это2
0% А что это, ваапче?0
7 users voted. Nobody abstained.
Tags:
Hubs:
Total votes 2: ↑2 and ↓0+4
Comments0

Articles