Example of creating a Full Stack project using functional testing as a design tool

In this article I will try to show using a simple example how to plan testing of a Full Stack project together with the development of the project itself and what benefits this provides.

The source code of the project is located Here.

Since I am not a Full Stack developer, please do not scold me for the code of the project being tested. But I will be very glad to receive your advice.

I hope this article will be of interest to Front-End/Back-End/Full Stack developers and automated testing developers (or if you just can't sleep).

Definition of Functional Testing

There are many books and articles written about testing itself and its importance, so I will skip the details of this topic.

Functional testing is testing the functional part of a product (sorry for the tautology) without reference to its implementation.

A more precise definition of functional testing can be found in Wikipedia.

Why is this necessary?

Often, full stack (and not only) projects are created without functional tests.

This is caused by the mistaken assumption that tests are added for a finished product and are only needed to maintain quality.

The need for tests also arises when the number of bugs or user complaints exceeds all limits. In such a situation, the project developers decide to add tests, and then it turns out that this is not as easy as most people think.

The problem with the complexity of tests in such a situation is that at the design stage of the product, no one thought about its testing and the need to make it suitable for testing (automated).

Testability may include:

  • the ability to run the product locally (e.g. creating a tested product/environment from scratch each time testing is run);

  • the completeness of the product API to enable automated testing (for example, the ability to dynamically create/delete a user used by tests);

  • the ability to mock product components or its functionality (for example, replacing a database with its mock or emulator).

At the design stage of the product itself, it is much easier to design tests because you are not limited by the existing product code, frameworks, and utilities included with the product.

In this part of the article, I will show you an example of how you can design a simple web application using functional testing and implement the web application.

Defining goals and overall design

Let's say the project needs to register users and display user information on the page after they log in.

Overall, the project should achieve the following general objectives:

  • user registration;

  • registered user login;

  • display information about this user.

The project will consist of Web and API parts:

  • API – user administration and storage/access to user information;

  • Web – registration of new users, display of user information.

image.png

image.png

Pay attention to Storage, it has real and mock implementations. This method allows the product to be testable and independent of the real storage option. This is a variant of the Adapter design pattern (more details here).

Start of design

Let's start designing from the web part. Why?

The web part interacts with the user and will help to define the goals of the project from the user's point of view (user stories). It will also allow you to define the requirements for the API server, since the web part interacts with it.

Defining and writing tests

Let's define a set of tests for each project goal.

Registration – tests:

  • The user must register with the correct data.

  • A user with incorrect data cannot register (for example, no last name).

  • An existing user in the system cannot be registered again.

  • Registration cannot be completed if there are errors on the server side.

User login – tests:

  • An existing user can log in.

  • After logging in, user information is displayed.

  • A user with incorrect data cannot log in (for example, incorrect password).

  • The user cannot log in if there are errors on the server side.

Tools (any similar ones can be used):

Now we have a breakdown of the overall goals of the project.

Writing tests allows you to understand what web pages should be, with what functionality, how the user should interact with them, and how the web part is connected to the API server.

What should be in each test and how is it constructed?

The test must check some specific action, and therefore it consists of three parts of AAA:

  • Arrange – preparation for action and testing.

  • Act — implementation of the tested action.

  • Assert — checking the result of the action.

In our case, tests will be built on working with page models (which interact with real pages) and API server mocks.

Components involved in testing the project web part:

image.png

image.png

Example of the sequence of the registration page test:

image.png

image.png

Let's add the code for the tests that have been defined. Below is the code for the registration tests:

import {expect, test as base} from "@playwright/test";
import {buildUserInfo, UserInfo} from "./helpers/user_info";

import {RegistrationPage} from "../infra/page-objects/RegisterationPage";
import {RegistrationSucceededPage} from "../infra/page-objects/RegistrationSucceededPage";
import {mockExistingUserAddFail, mockServerErrorUserAddFail, mockUserAdd, mockUserAddFail} from "./helpers/mocks";

const apiUrl = process.env.API_URL;
const apiUserUrl = `${apiUrl}/user`

const test = base.extend<{ userInfo: UserInfo }>({
    userInfo: async ({page}, use) => {
        const info = buildUserInfo()
        await use(info)
    }
})

test.describe("Registration", () => {
    test.beforeAll(() => {
        expect(apiUrl, 'The API address is invalid').toBeDefined()
    })

    test.beforeEach("Open registry page", async ({page}) => {
        const registerPage = await new RegistrationPage(page).open()
        expect(registerPage.isOpen(), `The page ${registerPage.name} is not open`).toBeTruthy()
    })

    test("user should pass registration with valid data", async ({page, userInfo}) => {
        await mockUserAdd(page, userInfo, apiUserUrl)
        const registerPage = new RegistrationPage(page)

        await registerPage.registerUser(userInfo)

        const successPage = new RegistrationSucceededPage(page)
        expect(await successPage.isOpen(), `The page ${successPage.name} is not open`).toBeTruthy()
    })

    test("user should fail registration with invalid data", async ({page, userInfo}) => {
        const responseErrMessage = "Invalid user name"
        await mockUserAddFail(page, {error: responseErrMessage}, apiUserUrl)
        const registerPage = new RegistrationPage(page)

        await registerPage.registerUser(userInfo)

        expect(await registerPage.warningShown(), `The page ${registerPage.name} has no warning`).toBeTruthy()
        const errMsg = `Invalid warning in the page ${registerPage.name}`
        expect(await registerPage.warningTxt(), errMsg).toEqual(responseErrMessage)
    })

    test("an existing user should fail registration", async ({page, userInfo}) => {
        await mockExistingUserAddFail(page, userInfo, apiUserUrl)
        const registerPage = new RegistrationPage(page)

        await registerPage.registerUser(userInfo)

        expect(await registerPage.warningShown(), `The page ${registerPage.name} has no warning`).toBeTruthy()
        const expectedTxt = `User ${userInfo.name} already exists`
        expect(await registerPage.warningTxt(), `Invalid warning in the page ${registerPage.name}`).toEqual(expectedTxt)
    })

    test("should fail user adding because of a server error", async ({page, userInfo}) => {
        await mockServerErrorUserAddFail(page, apiUserUrl)
        const registerPage = new RegistrationPage(page)

        await registerPage.registerUser(userInfo)

        expect(await registerPage.errorShown(), `The page ${registerPage.name} has no error`).toBeTruthy()
        expect(await registerPage.errorTxt(), `Invalid error in the page ${registerPage.name}`).toEqual('Server Error')
    })
})
  • RegistrationPage — functional model of the registration page (its Page Object Model or Page Object).

  • RegistrationSucceededPage is a functional model of the registration success page.

Their methods used in tests cannot be implemented at this stage, since there are no real web pages yet. The same applies to mock functions used in tests, since it is unknown how the API server should react and what requests will be made to it from the web application.

Therefore, all these methods and functions are shut down. stubsreturning an incorrect (!) result so that the tests fail.

So, what do we have at this stage and what does this give us?

  • Understanding what functionality real pages should have (thanks to the test code).

  • A set of tests for the functionality of web application pages.

Ostap Ibrahimovic, when are we going to share our code!?

What remains to be done:

  • create a web server using ExpressJS;

  • implement web application pages;

  • implement interaction with the API server on the web application pages;

  • replace stubs in Page Objects and API server mocks.

I won't go into detail about creating all the web pages and configuring the server. You can see the entire code Here.

Web server (server.ts):

import express from 'express';
import path from 'path';

const app = express();
const port = 3000;

app.use(express.static(path.join(__dirname, '../dist')));

app.get('/login', async (req, res) => {
    res.sendFile(path.join(__dirname, 'index.html'));
});

app.get('/welcome', async (req, res) => {
    res.sendFile(path.join(__dirname, 'welcome.html'));
})

app.get('/register', async (req, res) => {
    res.sendFile(path.join(__dirname, 'register.html'));
})

app.get('/success', async (req, res) => {
    res.sendFile(path.join(__dirname, 'success.html'));
})

app.get('/health', (req, res) => {
    res.sendStatus(200)
})

app.listen(port, '0.0.0.0', () => {
    console.log(`Server is running on http://localhost:${port}`);
});

Example of registration page (register.html):

chrome.png

chrome.png

Linking the registration page to the API Server in a file register.ts:

import {getElementValue, setElementHidden} from "./helpers/html";
import axios, {AxiosResponse} from "axios";

type UserInfo = {
    name: string,
    password: string,
    first_name: string,
    last_name: string,
}

const getUserInfo = (): UserInfo => {
    return {
        name: getElementValue('username'),
        password: getElementValue('password'),
        first_name: getElementValue('firstName'),
        last_name: getElementValue('lastName'),
    }
}

enum RequestResult {
    OK, SERVER_ERROR, USER_EXISTS, INVALID_VALUES
}

type RequestResultInfo = { result: RequestResult, errTxt?: string }

const url: string = process.env.API_URL ?? 'http://localhost:8000'

const errorID = 'error'
const warningID = 'warning'

const buildResultInfo = (response: AxiosResponse): RequestResultInfo => {
    switch (response.status) {
        case axios.HttpStatusCode.Created:
            return {result: RequestResult.OK};
        case axios.HttpStatusCode.Conflict:
            return {result: RequestResult.USER_EXISTS}
        case axios.HttpStatusCode.BadRequest:
            return {result: RequestResult.INVALID_VALUES, errTxt: response.data.error}
        default:
            console.error(`Error while adding user info: Status ${response.status}: ${response.statusText}`);
            return {result: RequestResult.SERVER_ERROR}
    }
}

const sendAddUserRequest = async (userInfo: UserInfo): Promise<RequestResultInfo> => {
    try {
        const nonExceptionalStatuses = (status: RequestResult) => status < axios.HttpStatusCode.InternalServerError
        const response = await axios.post(`${url}/user`, userInfo, {validateStatus: nonExceptionalStatuses});
        return buildResultInfo(response)
    } catch (error) {
        console.error(`Error while adding user info: ${error}`);
    }
    return {result: RequestResult.SERVER_ERROR}
}

const showErrors = (resultInfo: RequestResultInfo, userName: string) => {
    if (resultInfo.result === RequestResult.USER_EXISTS) {
        (document.getElementById(warningID) as HTMLElement).innerText = `User ${userName} already exists`;
        setElementHidden(warningID, false)
    } else if (resultInfo.result === RequestResult.INVALID_VALUES) {
        (document.getElementById(warningID) as HTMLElement).innerText = resultInfo.errTxt ?? "Invalid values";
        setElementHidden(warningID, false)
    } else if (resultInfo.result === RequestResult.SERVER_ERROR) {
        setElementHidden(errorID, false)
    } else {
        (document.getElementById(errorID) as HTMLElement).innerText = "Unknown error";
        setElementHidden(errorID, false)
    }
}

const addNewUser = async (userInfo: UserInfo): Promise<void> => {
    const resultInfo = await sendAddUserRequest(userInfo)
    if (resultInfo.result === RequestResult.OK) {
        window.localStorage.setItem('userName', userInfo.name);
        window.location.href = `/success`;
    } else {
        showErrors(resultInfo, userInfo.name)
    }
}

const sendForm = async () => {
    const userInfo = getUserInfo()

    setElementHidden(errorID, true)
    setElementHidden(warningID, true)
    try {
        await addNewUser(userInfo)
    } catch (error) {
        setElementHidden(errorID, false)
        console.error(`Error while trying to add a user: ${error}`);
    }
}

(window as any).sendForm = sendForm;

What the registration page looks like in different situations:

During the process of creating pages, stubs in tests are replaced with real code, and mocks used in tests are implemented to make the tests pass.

Example of implementing mocks using Playwright (full code) Here):

import {Page} from "@playwright/test";
import {UserInfo} from "./user_info";


const mockRequest = async (page: Page,
                           url: string,
                           expectedApiResponse: object,
                           status = 200,
                           method = 'GET'
) => {
    await page.route(url, async (route) => {
        if (route.request().method() === method) {
            await route.fulfill({
                status: status,
                contentType: 'application/json',
                body: JSON.stringify(expectedApiResponse),
            });
        } else {
            await route.continue();
        }
    });
}

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: 200})
            }
        }
    })
}

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, {}, 404)
}

Once all pages are created and all tests pass, you can consider the web part of the project complete.

The result of creating the web part of the project

Above, the development of the general design of the project and its detailed design of the web part were shown. During the implementation of the web part, the requirements for the API server were determined, which will help in its further design.

The web part design was detailed by designing tests and then implementing the web part in parallel with the tests.

Tests are run independently of the API server (which, in fact, does not exist at this stage).

This method of design and development resembles a gradual transition from the general to the specific and allows you to keep the entire development process under control in terms of understanding and quality. This method also allows you to define in detail all the elements of the project before its code implementation.

This development method is reminiscent of TDD (Test-Driven Development), as applied to functional testing.

The disadvantage is the development time. In a startup environment, where quality is not important, features and speed are the main thing, this approach will not arouse the interest of the management. However, by constantly practicing this method, the time for developing a project can be reduced due to accumulated experience and intuition.

All the code of the web part of the project is available Here.

I would be glad to hear your opinions and comments.

This is my first article, and if most people like it, the next part will be dedicated to the API server and deployment of the created Full Stack project.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *