Пример создания Full Stack проекта, используя функциональное тестирование как инструмент дизайна
Введение
Привет, Хабр!
Меня зовут Даниэль, и я разработчик автоматического тестирования.
В этой статье я постараюсь показать на простом примере, как планировать тестирование Full Stack проекта вместе с разработкой самого проекта и какие выгоды это дает.
Исходный код проекта находится здесь.
Так как я не являюсь Full Stack разработчиком, прошу не ругать меня за код тестируемого проекта. Но буду очень рад вашим советам.
Надеюсь, эта статья будет интересна разработчикам Front-End/Back-End/Full Stack и разработчикам автоматического тестирования (или если вам просто не заснуть).
Определение функционального тестирования
Про само тестирование и его важность написано множество книг и статей, поэтому я опускаю подробности этой темы.
Функциональное тестирование — это тестирование функциональной части продукта (сорри за тавтологию) без привязки к его имплементации.
Более точное определение функционального тестирования можно найти в Википедии.
Зачем это нужно?
Зачастую full stack (и не только) проекты создаются без функциональных тестов.
Это вызвано ошибочным предположением, что тесты добавляются для уже готового продукта и они нужны только для поддержки качества.
Необходимость в тестах также возникает, когда количество багов или жалоб пользователей переходит все границы. В такой ситуации разработчики проекта решают добавлять тесты, и тут выясняется, что это не так просто, как большинство думает.
Проблема сложности тестов в подобной ситуации кроется в том, что на этапе проектирования продукта никто не думал о его тестировании и необходимости сделать его пригодным для тестирования (автоматического).
Пригодность для тестирования может включать в себя:
возможность локального запуска продукта (например, создание тестируемого продукта/среды с нуля при каждом запуске тестирования);
полноту API продукта для возможности автоматического тестирования (например, возможность динамического создания/удаления пользователя, используемого тестами);
возможность мокинга компонентов продукта или его функциональности (например, замена базы данных на её мок или эмулятор).
На этапе проектирования самого продукта гораздо проще проектировать тесты, потому что вы не ограничены существующим кодом продукта, фреймворками и утилитами, входящими в состав продукта.
В этой части статьи я на примере покажу, как можно спроектировать простое веб-приложение, используя функциональное тестирование, и имплементировать веб-приложение.
Определение целей и общий дизайн
Допустим, проект должен регистрировать пользователей и показывать информацию о пользователе на странице после его входа в систему.
В целом, проект должен обеспечить следующие общие цели:
регистрацию пользователя;
вход зарегистрированного пользователя;
отображение информации о данном пользователе.
Проект будет состоять из Web и API частей:
API — администрирование пользователей и сохранение/доступ к информации пользователей;
Web — регистрация новых пользователей, отображение информации о пользователе.
image.png
Обратите внимание на Storage, в нём реализованы реальные и мок имплементации. Такой способ позволяет продукту быть тестируемым и не зависимым от реального варианта storage. Это вариант паттерна проектирования Адаптер (подробнее тут).
Начало проектирования
Начнём проектирование с веб-части. Почему?
Веб-часть взаимодействует с пользователем и поможет определить цели проекта с точки зрения пользователя (user stories). Также это даст возможность определить требования к API-серверу, так как веб-часть взаимодействует с ним.
Определение и написание тестов
Определим набор тестов для каждой цели проекта.
Регистрация — тесты:
Пользователь должен пройти регистрацию с правильными данными.
Пользователь с неверными данными не может пройти регистрацию (например, отсутствие фамилии).
Существующий в системе пользователь не может быть зарегистрирован повторно.
Регистрация не может быть осуществлена, если есть ошибки на стороне сервера.
Вход пользователя — тесты:
Существующий пользователь может войти.
После входа отображается информация о пользователе.
Пользователь с неправильными данными не может войти (например, неправильный пароль).
Пользователь не может войти, если есть ошибки на стороне сервера.
Инструменты (могут быть использованы любые похожие):
Теперь у нас есть детализация общих целей проекта.
Написание тестов позволяет понять, какие веб-страницы должны быть, с какой функциональностью, как пользователь должен с ними взаимодействовать и как веб-часть связана с API-сервером.
Что должно быть в каждом тесте и как он построен?
Тест должен проверить какое-то конкретное действие, и поэтому он состоит из трёх частей ААА:
Arrange — подготовка к действию и тестированию.
Act — осуществление тестируемого действия.
Assert — проверка результата действия.
В нашем случае тесты будут построены на работе с моделями страниц (которые взаимодействуют с реальными страницами) и моками API-сервера.
Компоненты, участвующие в тестировании веб-части проекта:
image.png
Пример последовательности работы теста регистрационной страницы:
image.png
Добавим код для тестов, которые были определены. Ниже приведен код тестов регистрации:
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 — функциональная модель страницы регистрации (её Page Object Model или Page Object).
RegistrationSucceededPage — функциональная модель страницы успешной регистрации.
Их методы, используемые в тестах, не могут быть имплементированы на данном этапе, так как ещё нет реальных веб-страниц. То же самое относится и к мок-функциям, используемым в тестах, так как неизвестно, как должен реагировать API-сервер и какие будут запросы к нему из веб-приложения.
Поэтому все эти методы и функции затыкаются stubs, возвращающими неправильный (!) результат, чтобы тесты не прошли.
Итак, что мы имеем на данном этапе и что это нам даёт?
Понимание того, какой функционал должен быть у реальных страниц (благодаря коду тестов).
Набор тестов функционала страниц веб-приложения.
Остап Ибрагимович, когда же мы будем делить наш код?!
Что остаётся сделать:
создать веб-сервер, используя ExpressJS;
имплементировать страницы веб-приложения;
имплементировать взаимодействие с API-сервером на страницах веб-приложения;
заменить stubs в Page Objects и моках API-сервера.
Я не буду подробно останавливаться на создании всех веб-страниц и конфигурации сервера. Можно посмотреть весь код здесь.
Веб сервер (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}`);
});
Пример страницы регистрации (register.html):
chrome.png
Связь страницы регистрации с API Server в файле 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 => {
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 => {
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;
Как выглядит страница регистрации в различных ситуациях:
В процессе создания страниц, stubs в тестах заменяются на реальный код, а также имплементируются моки, используемые в тестах, чтобы тесты прошли.
Пример имплементации моков с помощью Playwright (весь код здесь):
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)
}
Когда все страницы созданы и все тесты проходят, можно считать, что веб-часть проекта завершена.
Итог создания веб-части проекта
Выше была показана разработка общего дизайна проекта и его детальный дизайн веб-части. В ходе имплементации веб-части были определены требования к API-серверу, что поможет в его дальнейшем проектировании.
Детализация дизайна веб-части была обеспечена проектированием тестов и последующей имплементацией веб-части параллельно с выполнением тестов.
Тесты запускаются независимо от API-сервера (которого, в общем-то, и нет на данном этапе).
Такой способ проектирования и разработки напоминает постепенный переход от общего к частному и позволяет держать под контролем весь процесс разработки с точки зрения понимания и качества. Также данный способ позволяет детально определить все элементы проекта до его кодовой имплементации.
Этот метод разработки напоминает TDD (Test-Driven Development), применительно к функциональному тестированию.
Недостатком является время разработки. В условиях работы в стартапе, когда качество не важно, главное — фичи и скорость, такой подход не вызовет интереса у начальства. Однако, постоянно практикуя данный способ, время на разработку проекта может сократиться благодаря накопленному опыту и интуиции.
Весь код веб-части проекта доступен здесь.
Буду рад услышать ваши мнения и замечания.
Это моя первая статья, и если она понравится большинству, то следующая часть будет посвящена API-серверу и деплою созданного Full Stack проекта.