DEV Community

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

Posted on

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

In the previous part of this article, we implemented the general design of the Full Stack project. We also designed and implemented the web part of the project using functional tests. I recommend revisiting the previous part for a clearer understanding of the material covered so far.

In this part, we’ll apply the same approach — design via functional test definitions — to the API part of the project and its release.

For implementing the API part, we’ll use Python (though any other language could be used as well).

How has the Web part helped?

The implemented web part provides:

  • User registration
  • Sign-in for registered users
  • Display of user information

As demonstrated, the web part can run without a connection to the API part, thanks to the use of mocks.

These mocks can help us define the detailed purposes of the API part.

The mocks defined in the Web part (mocks.ts) are:

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')
}
Enter fullscreen mode Exit fullscreen mode

Defining API Purposes and Design

Based on the developed Web part, let’s outline the API’s key purposes (use cases):

  • User authentication
  • Adding a user to the system
  • Deleting a user from the system
  • Retrieving user information

From the Web part’s functional tests, we’ve derived the following endpoint definitions for the API:

  • /user — supporting GET and POST methods
  • /user_info/${username} — supporting GET method

To achieve full functionality, we should add a DELETE method to the /user endpoint, even though it wasn’t utilized in the Web part.

Here’s the general design of the API part:

API general design

Tools we’ll use (though similar alternatives could be substituted):

  • Falcon — a lightning-fast, minimalist Python web API framework for building robust app back-ends and micro-services
  • Pytest — a framework that simplifies writing small, readable tests and scales to support complex functional testing for applications and libraries

Test Definition

We’ll implement the API part’s design following the same approach as in the previous part:

  1. Define the functional tests
  2. Implement the endpoints in the API server

The API’s functional tests are more straightforward than those in the Web part. You can find the test code here. As an example, let’s look at the tests for deleting a user and the corresponding endpoint.

Here are the tests for deleting a user (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",
        )

Enter fullscreen mode Exit fullscreen mode

API Server Implementation

The endpoints definition 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

Enter fullscreen mode Exit fullscreen mode

Now it’s time to stub the endpoints. This allows us to run the API server, though all tests will initially fail. For our stub, we’ll use code that returns a response with the status code 501 (Not Implemented).

Here’s an example of the stubs in one of the resource files of our 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

Enter fullscreen mode Exit fullscreen mode

Let’s replace each stub with the required code, as demonstrated in the web part, until all tests pass. (The final code for the endpoints can be found here.)

The process is called Red-Green-Refactor:

Red-Green-Refactor

Here’s an example of replacing a stub with real code in the endpoint for deleting a user:

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)

Enter fullscreen mode Exit fullscreen mode

An E2E test should be added to verify the complete process of adding a userauthenticatingdeleting the user (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",
        )

Enter fullscreen mode Exit fullscreen mode

Summary of the API Part Development Process

Overall, the process mirrors that of the Web part but is more streamlined. This is because we’ve already defined the general purposes during the Web development stage. Our focus here is primarily on defining the functional tests for the API.

With both the Web and API parts of the project now complete and independently tested, we move to the final stage.

Deploy

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

Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)