Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Для будущих учащихся на курсе «JavaScript QA Engineer» и всех интересующихся темой автоматизацией тестирования подготовили перевод полезной статьи.
Также приглашаем принять участие в открытом вебинаре на тему «Что нужно знать о JS тестировщику». На занятии участники вместе с экспертом рассмотрят особенности JS, которые нужно держать в голове при написании тестов.
Юнит-тесты — это здорово… когда они надежно работают! На самом деле, есть старая поговорка, что «плохой тест — это хуже, чем вообще никакой тест». Я могу подтвердить, что недели, проведенные в погоне за случайно «ложным отрицательным» тестом, не эффективны. Вместо этого можно было использовать это время для написания рабочего кода, который поможет пользователю.
Так что поговорим об одной из этих простейших методик написания менее нестабильных тестов: тестирование фабричных данных.
Но прежде чем перейти к тому, что такое фабричные функции и зачем их использовать, давайте сначала попробуем разобраться, какой тип нестабильного теста они устраняют.
Аспекты тестов, которых мы хотим избежать
Высокое зацепление
Отсутствие типобезопасности (что приводит к длительному рефакторингу и ошибкам)
Огромные папки фикстур
Факторные функции все это исправят.
Так что же такое фабричные функции?
Фабричная функция — это функция, которая создает объект. Вот так просто. Да, существует шаблон «абстрактная фабрика», популяризированный книгой "Gang Of Four's Design Pattern" несколько десятилетий назад. Давайте сделаем функцию красивой и простой.
Сделаем функцию, которая упрощает процесс создания, чтобы было легче тестировать.
Вот самый простой пример в мире:
interface ISomeObj {
percentage: string;
}
export const makeSomeObj = () => {
return {
percentage: Math.random()
};
}
Посмотрим, как такой простой шаблон может быть использован для исправления аспектов нестабильных тестов, описанных выше.
Мы начнем с описания того, как обычно пишутся тесты, а затем будем разрабатывать решение итеративно по мере решения каждой из задач.
Пример того, как проводятся нестабильные тесты в реальном мире
Все начинается невинно. Представим, вы или другой мотивированный разработчик в команде хотели бы поделиться опытом и добавить юнит-тест для одной из страниц. Для тестирования функции вы сохраняете некоторые тестовые данные в JSON-файле. Cypress (самая удивительная на момент написания этой статьи библиотека для тестирования пользовательского интерфейса), которая даже поощряет вас использовать JSON файл с тестовыми данными. Но проблема в том, что это даже удаленно не типобезопасность. Поэтому у вас может быть опечатка в вашем JSON и вы будете тратить часы на поиски проблемы.
Чтобы проиллюстрировать это, давайте рассмотрим пример кода для бизнеса и кода для автоматизации тестирования. Для большинства этих примеров мы предположим, что вы работаете в страховой компании, которая объясняет, как работают правила в каждом штате США.
// This file is "src/pages/newYorkInfo.tsx"
import * as React from 'react';
interface IUser {
state: string;
address: string;
isAdmin: boolean;
deleted: boolean | undefined;
}
export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {
if (props.user.state === 'NY' && !props.user.deleted) {
const welcomeMessage = `Welcome`;
return <h1 id="ny-dashboard">{welcomeMessage}</h1>;
} else {
return <div>ACCESS DENIED</div>;
}
};
Код выглядит неплохо, так что давайте напишем JSON для хранения положительного тестового примера.
// fixtures/user.json
{
state: 'NY',
isAdmin: true,
address: '55 Main St',
}
А теперь тестовый код. Я продемонстрирую проблему, используя какой-нибудь psuedo-код для теста Cypress, но вы можете представить себе, что это происходит с любым тестовым кодом, в котором вы загружаете фикстуры и запускаете тестовое утверждение.
// When the UI calls the user endpoint, return the JSON as the mocked return value
cy.route('GET', '/user/**', 'fixture:user.json');
cy.visit('/dashboard');
cy.get('#ny-dashboard').should('exist')
Выглядит неплохо, и он отлично работает, пока вам не понадобится протестировать другой сценарий с участием другого пользователя. Что вы тогда сделаете?
Плохое решение — если один файл заработал, продолжайте создавать JSON-файлы
Стоит ли просто создать еще один JSON-файл фикстуры? К сожалению, это простое решение возникает постоянно, потому что оно самое простое (поначалу). Но с увеличением количества случаев, растет и количество JSON-файлов. Вам понадобится 52 различных JSON-файла, чтобы протестировать каждую страницу для каждого пользователя в США. Когда вы начнете тестирование, если пользователь является или не является администратором, вам придется создать 104 файла. Это много файлов!
Но у вас все равно есть проблема типобезопасности. Допустим, Product Owner приходит в команду и говорит: «Давайте будем отображать имя пользователя, когда мы его приветствуем».
Таким образом, вы добавляете свойство name
в интерфейс и обновляете пользовательский интерфейс для работы в этом примере.
// This file is "src/pages/newYorkInfo.tsx"
import * as React from 'react';
interface IUser {
name: string;
state: string;
address: string;
isAdmin: boolean;
deleted: boolean | undefined;
}
export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {
if (props.user.state === 'NY' && !props.user.deleted) {
const welcomeMessage = `Welcome ${props.user.name.toLowerCase()}!`;
return <h1 id="ny-dashboard">{welcomeMessage}</h1>;
} else {
return <div>ACCESS DENIED</div>;
}
};
Здорово, что вы обновили код для бизнеса, но фикстура JSON устарела. А так как у фикстуры JSON нет свойства name
, вы получаете следующую ошибку:
Uncaught TypeError: Cannot read property 'toLowerCase' of undefined
Теперь вы должны добавить свойство name ко всем 52 пользовательским JSON фикстурам. Это можно решить с помощью Typescript.
Немного лучшее решение: Переместите его в файл TypeScript
Переместив JSON из файла исправления в .ts файл, компилятор Typescript найдет для вас ошибку:
// this file is "testData/users"
import {IUser} from 'src/pages/newYorkInfo';
// Property 'name' is missing in type '{ state: string; isAdmin: true; address: string; deleted: false; }' but required in type 'IUser'.ts(2741)
export const generalUser: IUser = {
state: 'NY',
isAdmin: true,
address: '55 Main St',
deleted: false,
};
И мы обновим тестовый код, чтобы использовать этот новый объект.
import { generalUser } from 'testData/users';
// When the UI calls the user endpoint, return the JSON as the mocked return value
cy.route('GET', '/user/**', generalUser);
cy.visit('/dashboard');
cy.get('#ny-dashboard').should('exist')
Спасибо Typescript! Как только вы решите проблему с компилятором, добавив name: 'Bob Smith'
в GeneralUser:
, код компилируется чисто, а лучше всего то, что ваш тест снова пройдет!
Вы достигли одной из трех наших целей, достигнув типобезопасности. К сожалению, проблема высоко сцепления все еще существует.
Например, что происходит, когда появляется разработчик, который еще новичок в юнит-тестировании. Все, о чем думается, это то, что надо проверить основное свойство, которое включает в себя удаленного пользователя. Поэтому они добавляют deleted: false
в объект generalUser
.
Бабах! Ваш тест не проходит, и их тест проходит. Вот что значит быть высоко сцепленным.
Поэтому разработчик тратит несколько минут (или часов) на дебаггинг и понимает, что оба теста имеют одни и те же базовые данные. Таким образом, разработчик использует простое (но недальновидное решение) из предыдущих и создает другой объект deletedUser
, так что есть 1 объект на тест. Это может быстро выйти из-под контроля — я видел файлы тестовых данных длиной 5000 строк.
Здесь можно увидеть, как странно это может выглядеть.
// this file is "testData/users"
import {IUser} from 'src/pages/newYorkInfo';
export const nonAdminUser: IUser = {
name: 'Bob',
state: 'NY',
isAdmin: false,
address: '55 Main St',
deleted: false,
};
export const adminUser: IUser = {
name: 'Bob',
state: 'NY',
isAdmin: true,
address: '55 Main St',
deleted: false,
};
export const deletedAdminUser: IUser = {
name: 'Bob',
state: 'NY',
isAdmin: true,
address: '55 Main St',
deleted: true,
};
export const deletedNonAdmin: IUser = {
name: 'Bob',
state: 'NY',
isAdmin: false,
address: '55 Main St',
deleted: true,
};
// and on and on and on again...
Должен быть путь получше.
Хорошее решение: Фабричная Функция
Так как же нам рефакторить огромный файл объектов? Сделаем одну функцию!
// src/factories/user
import faker from 'faker';
import {IUser} from 'src/pages/newYorkInfo';
export const makeFakeUser = (): IUser => {
return {
name: faker.name.firstName() + ' ' + faker.name.lastName(),
state: faker.address.stateAbbr(),
isAdmin: faker.random.boolean(),
address: faker.address.streetAddress(),
deleted: faker.random.boolean(),
}
}
Теперь каждый тест может просто вызвать makeFakeUser()
, когда он хочет создать пользователя.
И самое лучшее в этом то, что, делая все случайным в фабричной функции, это показывает, что ни один отдельный тест не принадлежит этой функции. Если тест является особым видом IUser, то позже придется модифицировать его самостоятельно.
И это легко сделать. Давайте представим себе удаленный пользовательский тест, где нас не волнует имя пользователя или что-то в этом роде. Нас волнует только то, что они удалены.
import { makeFakeUser } from 'src/factories/user';
import {IUser} from 'src/pages/newYorkInfo';
// Arrange
const randomUser = makeFakeUser();
const deletedUser: IUser = { ...randomUser, ...{
deleted: true
};
cy.route('GET', '/user/**', deletedUser);
// Act
cy.visit('/dashboard');
// Assert
cy.find('ACCESS DENIED').should('exist')
Для меня, прелесть этого подхода в том, что он сам себя документирует. Любой, кто смотрит на этот тестовый код, должен понимать, что когда API возвращает удаленного пользователя, мы должны найти "Access Denied"
на странице.
Но я думаю, что мы сделаем это еще чище.
Лучшее решение: просто переопределить mergePartially
Выше допускалось использование оператор spread
, так как это был небольшой объект. Но это может быть более раздражающим, когда это вложенный объект, как этот:
interface IUser {
userName: string;
preferences: {
lastUpdated?: Date;
favoriteColor?: string;
backupContact?: string;
mailingAddress: {
street: string;
city: string;
state: string;
zipCode: string;
}
}
}
Вы не захотите, чтобы сотни таких объектов возникали.
Так что если мы позволим пользователям переопределять только то, что они хотят, мы сможем создать действительно простой и базовый код DRY. Представьте себе, что есть очень специфический тест, в котором должен быть пользователь, живущий на "Main Street".
const userOnMainSt = makeFakeUser({
preferences: {
mailingAddress: {
street: 'Main Street'
}
}
});
Ого, нужно было только указать то, что нужно для теста, а не другие 7 свойств. И нам не пришлось хранить одноразовый объект в каком-то огромном тестовом файле. И мы также достигли своих целей.
И как нам улучшить нашу функцию makeFakeUser
для поддержки такого рода частичного переопределения?
Посмотрите, насколько легко это делает библиотека mergePartially (полное раскрытие: я сопровождающий mergePartially
).
const makeFakeUser = (override?: NestedPartial<IDeepObj>): IDeepObj => {
const seed: IDeepObj = {
userName: 'Bob Smith',
preferences: {
mailingAddress: {
street: faker.address.streetAddress(),
city: faker.address.city(),
state: faker.address.stateAbbr(),
zipCode: faker.address.zipCode(),
},
},
};
return mergePartially.deep(seed, override);
};
Подведение итогов
Спасибо за то, что вы прочитали, как мы перешли от нестабильного и огромного тестового кода к маленькому и независимому.
Я был бы рад услышать от вас, что вы думаете об этом подходе.
Узнать больше о курсе «JavaScript QA Engineer».
Зарегистрироваться на открытый вебинар на тему «Что нужно знать о JS тестировщику».