>백엔드 개발 >파이썬 튜토리얼 >풀 스택 프로젝트를 위한 설계 도구로서의 기능 테스트. 파트 II: API 서버 및 프로젝트 릴리스

풀 스택 프로젝트를 위한 설계 도구로서의 기능 테스트. 파트 II: API 서버 및 프로젝트 릴리스

DDD
DDD원래의
2024-10-13 06:09:30769검색

이 기사의 이전 부분에서는 풀 스택 프로젝트의 일반적인 디자인을 구현했습니다. 또한 기능 테스트를 사용하여 프로젝트의 웹 파트를 설계하고 구현했습니다. 지금까지 다룬 내용을 보다 명확하게 이해하기 위해 이전 부분을 다시 방문하는 것이 좋습니다.

이 부분에서는 기능 테스트 정의를 통한 설계라는 동일한 접근 방식을 프로젝트의 API 부분과 릴리스에 적용하겠습니다.

API 부분을 구현하기 위해 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 목적 및 디자인 정의

개발된 웹 파트를 기반으로 API의 주요 목적(사용 사례)을 간략하게 설명하겠습니다.

  • 사용자 인증
  • 시스템에 사용자 추가
  • 시스템에서 사용자 삭제
  • 사용자 정보 검색

웹 파트의 기능 테스트를 통해 API에 대한 다음 엔드포인트 정의를 도출했습니다.

  • /user — GET 및 POST 방식 지원
  • /user_info/${username} — GET 방식 지원

전체 기능을 달성하려면 웹 파트에서 활용되지 않았더라도 /user 엔드포인트에 DELETE 메서드를 추가해야 합니다.

API 부분의 일반적인 디자인은 다음과 같습니다.

Functional Testing as a Design Tool for Full Stack Projects. Part II: API server and Project Release

사용할 도구(유사한 대안으로 대체 가능):

  • Falcon — 강력한 앱 백엔드 및 마이크로서비스 구축을 위한 매우 빠르고 미니멀한 Python 웹 API 프레임워크
  • Pytest — 애플리케이션 및 라이브러리에 대한 복잡한 기능 테스트를 지원하기 위해 작고 읽기 쉬운 테스트 및 확장 작성을 단순화하는 프레임워크

테스트 정의

이전 부분과 동일한 접근 방식에 따라 API 부분의 디자인을 구현하겠습니다.

  1. 기능 테스트 정의
  2. API 서버에서 엔드포인트 구현

API의 기능 테스트는 웹 파트의 기능 테스트보다 더 간단합니다. 여기에서 테스트 코드를 찾을 수 있습니다. 예를 들어 사용자 삭제 테스트와 해당 엔드포인트를 살펴보겠습니다.

다음은 사용자 삭제를 위한 테스트입니다(delete_user.py):

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",
        )

API 서버 구현

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

이제 엔드포인트를 스텁할 시간입니다. 이를 통해 API 서버를 실행할 수 있지만 처음에는 모든 테스트가 실패합니다. 스텁의 경우 상태 코드 501(구현되지 않음)으로 응답을 반환하는 코드를 사용합니다.

다음은 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

모든 테스트가 통과할 때까지 웹 파트에 설명된 대로 각 스텁을 필수 코드로 바꾸겠습니다. (엔드포인트의 최종 코드는 여기에서 확인할 수 있습니다.)

이 프로세스를 Red-Green-Refactor라고 합니다.

Functional Testing as a Design Tool for Full Stack Projects. Part II: API server and Project Release

다음은 사용자 삭제를 위해 엔드포인트에서 스텁을 실제 코드로 바꾸는 예입니다.

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)

사용자 추가인증사용자 삭제의 전체 프로세스를 확인하기 위해 E2E 테스트를 추가해야 합니다(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에 대한 기능 테스트를 정의하는 것입니다.

이제 프로젝트의 웹과 API 부분이 모두 완료되고 독립적으로 테스트되었으므로 최종 단계로 넘어갑니다.

Functional Testing as a Design Tool for Full Stack Projects. Part II: API server and Project Release

Release of the Whole Project

The last step is to integrate the Web and API components. We’ll use End-to-End (E2E) testing in the Web part to facilitate this integration. As we defined earlier, the main purpose of the project is to enable user registration and sign-in. Therefore, our E2E test should verify the entire process of user registration and subsequent sign-in in one comprehensive test sequence.

It’s worth noting that E2E tests don’t use mocks. Instead, they interact directly with the Web app connected to the API server, simulating real-world usage. (e2e.spec.ts)

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()
    })
});

As you can see, this is a sequence of tests. All the previously described tests in the Web and API parts are independent and can be run in parallel.

The Web and API components can be run separately as independent services via Docker containers.

Here’s the Dockerfile for the API server:

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"]

Here’s the Dockerfile of the Web app:

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"]

The Docker composition of the both parts:

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

There is a script for a more convenient way of running E2E tests with the services.

It's worth noting that the ability to run the entire project or its parts separately locally demonstrates the project's testability and ease of development.

Testing should be an integral part of the CI/CD process. Therefore, workflows for CI/CD have been added to the project's repository on GitHub.

The following workflows run for each commit to the repository:

  • API - build and testing
  • Web - build, run as a service, and testing
  • E2E - run both services and perform E2E testing

Conclusion

These two parts have demonstrated how to design a Full Stack project through functional test definition. Both the Web and API parts have been designed and developed independently.

This approach allows for progression from general purpose definition to more detailed specifications without losing control of the project's quality and integrity.

While this project serves as a simple example, real-world projects are often more complex. Therefore, this method is particularly useful for designing separate features.

As mentioned earlier, one of the drawbacks of this approach is the time required for development.

Another drawback is the disconnection between project parts during development. This means there's no automatic synchronization of changes between parts. For example, if there's a code change in an endpoint definition in the API part, the Web part won't be automatically aware of it.

This issue can be addressed through human inter-team synchronization (in cases of small teams or low code change frequency) or by implementing Design by Contract methodology.

위 내용은 풀 스택 프로젝트를 위한 설계 도구로서의 기능 테스트. 파트 II: API 서버 및 프로젝트 릴리스의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.