Maison >développement back-end >Tutoriel Python >Les tests fonctionnels comme outil de conception pour les projets Full Stack. Partie II : serveur API et version du projet

Les tests fonctionnels comme outil de conception pour les projets Full Stack. Partie II : serveur API et version du projet

DDD
DDDoriginal
2024-10-13 06:09:30774parcourir

Dans la partie précédente de cet article, nous avons implémenté le design général du projet Full Stack. Nous avons également conçu et implémenté la partie web du projet à l'aide de tests fonctionnels. Je recommande de revoir la partie précédente pour une compréhension plus claire du matériel abordé jusqu'à présent.

Dans cette partie, nous appliquerons la même approche — conception via des définitions de tests fonctionnels — à la partie API du projet et à sa sortie.

Pour implémenter la partie API, nous utiliserons Python (bien que n'importe quel autre langage puisse également être utilisé).

Comment le composant WebPart a-t-il aidé ?

Le composant WebPart implémenté fournit :

  • Inscription des utilisateurs
  • Connexion pour les utilisateurs enregistrés
  • Affichage des informations utilisateur

Comme démontré, le webpart peut s'exécuter sans connexion à la partie API, grâce à l'utilisation de mocks.

Ces simulations peuvent nous aider à définir les objectifs détaillés de la partie API.

Les mocks définis dans la partie Web part (mocks.ts) sont :

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')
}

Définir les objectifs et la conception de l'API

Sur la base du WebPart développé, décrivons les principaux objectifs (cas d'utilisation) de l'API :

  • Authentification de l'utilisateur
  • Ajout d'un utilisateur au système
  • Supprimer un utilisateur du système
  • Récupération des informations utilisateur

À partir des tests fonctionnels du composant WebPart, nous avons dérivé les définitions de point de terminaison suivantes pour l'API :

  • /user — prend en charge les méthodes GET et POST
  • /user_info/${username} — prise en charge de la méthode GET

Pour obtenir toutes les fonctionnalités, nous devons ajouter une méthode DELETE au point de terminaison /user, même si elle n'a pas été utilisée dans la partie Web.

Voici la conception générale de la partie API :

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

Outils que nous utiliserons (bien que des alternatives similaires puissent être remplacées) :

  • Falcon - un framework d'API Web Python ultra-rapide et minimaliste pour créer des back-ends d'applications et des micro-services robustes
  • Pytest — un framework qui simplifie l'écriture de petits tests et d'échelles lisibles pour prendre en charge des tests fonctionnels complexes pour les applications et les bibliothèques

Définition du test

Nous implémenterons la conception de la partie API en suivant la même approche que dans la partie précédente :

  1. Définir les tests fonctionnels
  2. Implémenter les points de terminaison dans le serveur API

Les tests fonctionnels de l’API sont plus simples que ceux de la partie Web. Vous pouvez trouver le code du test ici. A titre d'exemple, regardons les tests de suppression d'un utilisateur et du point de terminaison correspondant.

Voici les tests de suppression d'un utilisateur (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",
        )

Implémentation du serveur API

La définition des points de terminaison dans 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

Il est maintenant temps de supprimer les points de terminaison. Cela nous permet d'exécuter le serveur API, même si tous les tests échoueront initialement. Pour notre stub, nous utiliserons un code qui renvoie une réponse avec le code d'état 501 (Non implémenté).

Voici un exemple de stubs dans l'un des fichiers de ressources de notre application 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

Remplacons chaque stub par le code requis, comme démontré dans le composant WebPart, jusqu'à ce que tous les tests soient réussis. (Le code final des points de terminaison peut être trouvé ici.)

Le processus s'appelle Red-Green-Refactor :

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

Voici un exemple de remplacement d'un stub par du vrai code dans le point de terminaison pour supprimer un utilisateur :

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)

Un test E2E doit être ajouté pour vérifier le processus complet de ajout d'un utilisateurauthentificationsuppression de l'utilisateur (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",
        )

Résumé du processus de développement de pièces API

Dans l'ensemble, le processus reflète celui du composant WebPart mais est plus rationalisé. En effet, nous avons déjà défini les objectifs généraux lors de la phase de développement Web. Notre objectif ici est principalement de définir les tests fonctionnels pour l'API.

Les parties Web et API du projet étant désormais terminées et testées indépendamment, nous passons à l'étape finale.

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.

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn