在本文的前一部分中,我们实现了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') }
定义 API 用途和设计
基于开发的 Web 部件,我们概述一下 API 的主要用途(用例):
根据 Web 部件的功能测试,我们得出了 API 的以下端点定义:
要实现完整功能,我们应该向 /user 端点添加 DELETE 方法,即使 Web 部件中未使用该方法。
API部分的总体设计如下:
我们将使用的工具(尽管可以替换类似的替代品):
测试定义
我们将按照与上一部分相同的方法来实现 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", )
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
让我们用所需的代码替换每个存根,如 Web 部件中所示,直到所有测试通过。 (端点的最终代码可以在这里找到。)
这个过程称为红-绿-重构:
以下是在端点中用真实代码替换存根以删除用户的示例:
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 部分开发流程总结
总体而言,该过程与 Web 部件的过程相似,但更加简化。这是因为我们在 Web 开发阶段就已经定义了通用目的。我们这里的重点主要是定义 API 的功能测试。
项目的 Web 和 API 部分现已完成并经过独立测试,我们进入最后阶段。
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:
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!