Im vorherigen Teil dieses Artikels haben wir das allgemeine Design des Full-Stack-Projekts implementiert. Wir haben auch den Web-Teil des Projekts mithilfe von Funktionstests entworfen und implementiert. Ich empfehle, den vorherigen Teil noch einmal durchzugehen, um den bisher behandelten Stoff besser zu verstehen.
In diesem Teil wenden wir denselben Ansatz – Design über Funktionstestdefinitionen – auf den API-Teil des Projekts und seine Veröffentlichung an.
Für die Implementierung des API-Teils verwenden wir Python (obwohl auch jede andere Sprache verwendet werden könnte).
Wie hat der Webpart geholfen?
Das implementierte Webpart bietet:
Wie gezeigt, kann der Webpart dank der Verwendung von Mocks ohne Verbindung zum API-Teil ausgeführt werden.
Diese Modelle können uns helfen, die detaillierten Zwecke des API-Teils zu definieren.
Die im Webpart (mocks.ts) definierten Mocks sind:
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-Zwecke und -Design definieren
Lassen Sie uns anhand des entwickelten Webparts die Hauptzwecke (Anwendungsfälle) der API skizzieren:
Aus den Funktionstests des Webparts haben wir die folgenden Endpunktdefinitionen für die API abgeleitet:
Um die volle Funktionalität zu erreichen, sollten wir dem Endpunkt /user eine DELETE-Methode hinzufügen, auch wenn diese im Webpart nicht verwendet wurde.
Hier ist der allgemeine Aufbau des API-Teils:
Tools, die wir verwenden werden (obwohl ähnliche Alternativen als Ersatz verwendet werden könnten):
Testdefinition
Wir werden das Design des API-Teils nach dem gleichen Ansatz wie im vorherigen Teil implementieren:
Die Funktionstests der API sind einfacher als die im Webpart. Den Testcode finden Sie hier. Schauen wir uns als Beispiel die Tests zum Löschen eines Benutzers und des entsprechenden Endpunkts an.
Hier sind die Tests zum Löschen eines Benutzers (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-Server-Implementierung
Die Endpunktdefinition in 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
Jetzt ist es an der Zeit, die Endpunkte zu stubben. Dadurch können wir den API-Server ausführen, obwohl alle Tests zunächst fehlschlagen. Für unseren Stub verwenden wir Code, der eine Antwort mit dem Statuscode 501 (Nicht implementiert) zurückgibt.
Hier ist ein Beispiel für die Stubs in einer der Ressourcendateien unserer Falcon-App:
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
Ersetzen wir jeden Stub durch den erforderlichen Code, wie im Webpart gezeigt, bis alle Tests erfolgreich sind. (Der endgültige Code für die Endpunkte finden Sie hier.)
Der Prozess wird Rot-Grün-Refaktor genannt:
Hier ist ein Beispiel für das Ersetzen eines Stubs durch echten Code im Endpunkt zum Löschen eines Benutzers:
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)
Ein E2E-Test sollte hinzugefügt werden, um den gesamten Prozess des Hinzufügens eines Benutzers → Authentifizierung → Löschen des Benutzers (e2e.py) zu überprüfen:
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", )
Zusammenfassung des API-Teilentwicklungsprozesses
Insgesamt entspricht der Prozess dem des Webparts, ist jedoch schlanker. Dies liegt daran, dass wir die allgemeinen Zwecke bereits während der Webentwicklungsphase definiert haben. Unser Fokus liegt hier vor allem auf der Definition der Funktionstests für die API.
Da sowohl der Web- als auch der API-Teil des Projekts nun abgeschlossen und unabhängig getestet sind, gehen wir zur Endphase über.
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.
Das obige ist der detaillierte Inhalt vonFunktionstests als Designtool für Full-Stack-Projekte. Teil II: API-Server und Projektfreigabe. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!