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

DDD
Release: 2024-10-13 06:09:30
Original
686 people have browsed it

在本文的前一部分中,我们实现了Full Stack项目的总体设计。我们还使用功能测试来设计和实现该项目的 Web 部分。我建议重新访问前一部分,以便更清楚地了解迄今为止所涵盖的材料。

在这一部分中,我们将应用相同的方法(通过功能测试定义进行设计)到项目的 API 部分及其发布。

为了实现 API 部分,我们将使用 Python(尽管也可以使用任何其他语言)。

Web 部件有何帮助?

实现的 Web 部件提供:

  • 用户注册
  • 注册用户登录
  • 显示用户信息

如演示的,由于使用了模拟,Web 部件可以在不连接到 API 部件的情况下运行。

这些mocks可以帮助我们定义API部分的详细用途。

Web 部件 (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')
}
Copy after login

定义 API 用途和设计

基于开发的 Web 部件,我们概述一下 API 的主要用途(用例):

  • 用户认证
  • 向系统添加用户
  • 从系统中删除用户
  • 检索用户信息

根据 Web 部件的功能测试,我们得出了 API 的以下端点定义:

  • /user — 支持 GET 和 POST 方法
  • /user_info/${username} — 支持 GET 方法

要实现完整功能,我们应该向 /user 端点添加 DELETE 方法,即使 Web 部件中未使用该方法。

API部分的总体设计如下:

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

我们将使用的工具(尽管可以替换类似的替代品):

  • Falcon — 一个闪电般快速、简约的 Python Web API 框架,用于构建强大的应用程序后端和微服务
  • Pytest — 一个框架,可简化编写小型、可读的测试和扩展,以支持应用程序和库的复杂功能测试

测试定义

我们将按照与上一部分相同的方法来实现 API 部分的设计:

  1. 定义功能测试
  2. 在 API 服务器中实现端点

API的功能测试比Web部分的功能测试更简单。您可以在这里找到测试代码。作为示例,让我们看一下删除用户和相应端点的测试。

以下是删除用户的测试(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",
        )

Copy after login

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

Copy after login

现在是时候对端点进行存根了。这允许我们运行 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

Copy after login

让我们用所需的代码替换每个存根,如 Web 部件中所示,直到所有测试通过。 (端点的最终代码可以在这里找到。)

这个过程称为红-绿-重构:

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)

Copy after login

应该添加一个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",
        )

Copy after login

API 部分开发流程总结

总体而言,该过程与 Web 部件的过程相似,但更加简化。这是因为我们在 Web 开发阶段就已经定义了通用目的。我们这里的重点主要是定义 API 的功能测试。

项目的 Web 和 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()
    })
});

Copy after login

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"]
Copy after login

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"]
Copy after login

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

Copy after login

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.

The above is the detailed content of Functional Testing as a Design Tool for Full Stack Projects. Part II: API server and Project Release. For more information, please follow other related articles on the PHP Chinese website!

source:dev.to
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template
About us Disclaimer Sitemap
php.cn:Public welfare online PHP training,Help PHP learners grow quickly!