Эффективное использование тестовых данных в React
Когда я писал тесты в предыдущих проектах React, я часто сталкивался с проблемой, когда "тестовые данные" дублировались и, в некоторых случаях, имели разные значения атрибутов, особенно при тестировании разных сценариев. Допустим, я пишу спецификацию, в которой пользователь имеет доступ к записи и может создать пост, а другой тест проверяет, не может ли пользователь создать пост, если доступ к записи не предоставлен. Спецификации будут выглядеть следующим образом:
describe('user', () => {
let handlers = []
let user
describe('has write posts permission', () => {
beforeAll(() => {
user = {
id: 1000,
name: 'John Doe',
permissions: ['posts.write'],
}
handlers = [
http.get('/me', () => {
return HttpResponse.json(user)
}),
]
server.use(...handlers)
})
test('can create post', async () => {
render(<CreatePostPage />)
fireEvent.change(
screen.getByRole('textbox', { name: /title/i }),
{
target: { value: 'My awesome post' },
},
)
fireEvent.click(
screen.getByRole('button', { name: /create post/i }),
)
expect(
await screen.findByText(/post created/i)
).toBeInTheDocument()
})
})
describe('has no write permission', () => {
beforeAll(() => {
user = {
id: 1000,
name: 'John Doe',
permissions: [],
}
handlers = [
http.get('/me', () => {
return HttpResponse.json(user)
}),
]
server.use(...handlers)
})
test('cannot create post', async () => {
render(<CreatePostPage />)
expect(
await screen.findByText(/the page you're trying access has restricted access./i)
).toBeInTheDocument()
})
})
})
Ладно, спецификация получилась слишком длинной. Но давайте сосредоточимся только на этом блоке кода:
user = {
id: 1000,
name: 'John Doe',
permissions: ['posts.write'],
}
Таким образом, вы можете представить, что этот код будет обычным "аранжировщиком" данных, поскольку он определяет текущего вошедшего пользователя и необходим для тех спецификаций, которые требуют информацию о вошедшем пользователе. Он может появиться в нескольких местах в моих файлах спецификаций. Допустим, мне это нужно в следующих спецификациях: create_post.test.tsx
, update_post.test.tsx
, delete_post.test.tsx
, view_post.test.tsx
и многих других! А что, если требуется новая функция, в которой нужно добавить новый атрибут пользователю? Значит ли это, что мне также нужно обновить упомянутые выше файлы спецификаций?
Решение
Как же решить эту проблему? Навскидку я могу предложить несколько решений.
Пользователь как модуль
Одно из возможных решений - извлечь объект user
и поместить его в отдельный модуль, а затем импортировать его в тех случаях, когда он необходим.
// testUtils/fixtures/user.js
export const user = {
id: 1000,
name: 'John Doe',
permissions: ['posts.write'],
}
И затем в спецификации мы можем просто импортировать его.
...
import { user } from 'testUtils/fixtures/user'
...
beforeAll(() => {
handlers = [
http.get('/me', () => {
return HttpResponse.json(user)
}),
]
server.use(...handlers)
})
...
Но помните, что нам нужно два варианта пользователя. Один, у которого есть posts.write
, и другой, у которого его нет. Поэтому мы можем модифицировать его в:
export const user_with_posts_write_access = {
id: 1000,
name: 'John Doe',
permissions: ['posts.write'],
}
export const user_without_permissions = {
id: 1000,
name: 'John Doe',
permissions: [],
}
Вы можете подумать, почему бы просто не скопировать объект user
и не переопределить атрибут permissions
:
...
import { user } from 'testUtils/fixtures/user'
...
beforeAll(() => {
const user_without_permissions = {
...user,
permissions: [],
}
handlers = [
http.get('/me', () => {
return HttpResponse.json(user_without_permissions)
}),
]
server.use(...handlers)
})
...
Да, вы правы. Это тоже работает! На самом деле это также приводит меня ко второму решению, которое я задумал.
Фабричный паттерн
Итак, в этом решении паттерн аналогичен первому решению, однако вместо объекта мы используем функцию для "создания" нашего клиента user
.
// testUtils/fixtures/user.js
export function createUser(attributes = {}) {
return {
id: 1000,
name: 'John Doe',
permissions: ['posts.write'],
...attributes,
}
}
Как вы можете заметить, createUser
позволяет нам переопределять определенные атрибуты, которые нам нужны. Например:
createUser()
// Returns
// {
// id: 1000,
// name: 'John Doe',
// permissions: ['posts.write']
// }
createUser({ permissions: [] })
// Returns
// {
// id: 1000,
// name: 'John Doe',
// permissions: []
// }
createUser({ id: 1001, name: 'Jane Doe' })
// Returns
// {
// id: 1001,
// name: 'Jane Doe',
// permissions: ['posts.write'],
// }
И разница в спецификации может выглядеть так:
describe('user', () => {
let handlers = []
let user
describe('has write posts permission', () => {
beforeAll(() => {
- user = {
- id: 1000,
- name: 'John Doe',
- permissions: ['posts.write'],
- }
+ user = createUser()
handlers = [
http.get('/me', () => {
return HttpResponse.json(user)
}),
]
server.use(...handlers)
})
test('can create post', async () => {
render(<CreatePostPage />)
fireEvent.change(
screen.getByRole('textbox', { name: /title/i }),
{
target: { value: 'My awesome post' },
},
)
fireEvent.click(
screen.getByRole('button', { name: /create post/i }),
)
expect(
await screen.findByText(/post created/i)
).toBeInTheDocument()
})
})
describe('has no write permission', () => {
beforeAll(() => {
- user = {
- id: 1000,
- name: 'John Doe',
- permissions: [],
- }
+ user = createUser({ permissions: [] })
handlers = [
http.get('/me', () => {
return HttpResponse.json(user)
}),
]
server.use(...handlers)
})
test('cannot create post', async () => {
render(<CreatePostPage />)
expect(
await screen.findByText(/the page you're trying access has restricted access./i)
).toBeInTheDocument()
})
})
})
Кроме того, если мы хотим иметь данные, основанные на "признаках". Например, мы хотим создать пользователя на основе роли, мы можем сделать что-то вроде:
// testUtils/fixtures/user.js
export const USER_TRAITS = {
ADMIN: "admin",
AUTHOR: "author",
READER: "reader",
}
export function createUser(trait = USER_TRAITS.READER, attributes = {}) {
let roleBasedAttributes = {}
if (trait == USER_TRAITS.ADMIN) {
roleBasedAttributes = {
role: 'Admin',
permissions: ['posts.write', 'posts.read'],
}
}
if (trait == USER_TRAITS.AUTHOR) {
roleBasedAttributes = {
role: 'Author',
permissions: ['posts.write'],
}
}
if (trait == USER_TRAITS.READER) {
roleBasedAttributes = {
role: 'Reader',
permissions: ['posts.read'],
}
}
return {
id: 1000,
name: 'John Doe',
...roleBasedAttributes,
...attributes,
}
}
А потом, когда мы его реализуем:
import { createUser, USER_TRAITS } from 'testUtils/fixtures/user.js'
createUser()
// Returns
// {
// id: 1000,
// name: 'John Doe',
// role: 'Reader',
// permissions: ['posts.read']
// }
createUser(USER_TRAITS.ADMIN, { name: 'Jane Doe' })
// Returns
// {
// id: 1000,
// name: 'Jane Doe',
// role: 'Admin',
// permissions: ['posts.write', 'posts.read']
// }
Заключение
В обоих решениях мы устраняем проблему жесткого кодирования пользовательских данных по всей спецификации. В случае если мы захотим добавить новые атрибуты для поддержки нового запроса, мы будем знать, что изменить их можно только в одном месте.
Я знаю, что фабричный паттерн - не новый паттерн, на самом деле я обнаружил его, когда работал над проектом Ruby on Rails. В то время это был мой первый опыт работы на Ruby on Rails, и мне дали задание (запрос функции). После написания бизнес-логики мне нужно было написать спецификации, и именно тогда я понял, что такое фабричный паттерн и как он эффективен в нашей команде, которую к тому времени мы приняли в команду фронтенда!
Спасибо за прочтение! Счастливого кодинга!