Мы используем сочетание E2E и модульных тестов для повышения уверенности и удобства обслуживания приложений Remix в Seasoned.
Здесь я покажу один из способов модульного тестирования загрузчика, шаг за шагом. На момент написания этой статьи не существует стандартных способов тестирования компонентов, содержащих код Remix. Поэтому мы тестируем бизнес-логику, загрузчики и действия отдельно.
В этой статье будет показан пример теста без аутентификации. Наш собственный аутентификатор — это отдельная и большая тема. Настройка аутентификации в тесте оставлена на усмотрение читателя 😬.
Если вас интересует E2E, вы можете посмотреть примеры в репо remix-forms.
Что мы создаем
Давайте воспользуемся приложением remix-jokes, которое я создал, когда впервые ознакомился с документацией Remix. Это чистая установка, без remix-стеков, потому что в то время их не существовало 😅 Это приложение использует сервер Express, БД Postgres и Jest для запуска модульных тестов.
Настройка
Мы будем тестировать файлы маршрутов. Лучшее место для этих тестовых файлов — рядом с самими маршрутами. Но нам нужно сказать Remix, что тестовые файлы — это не маршруты, иначе сервер сломается. Добавьте ignoredRouteFiles
в конфигурацию Remix, и все будет хорошо.
// remix.config.js
module.exports = {
...
ignoredRouteFiles: ['.*', '**/__tests__/**'],
}
Добавим первый тест (и обнаружим угловой случай)
Давайте протестируем app/routes/jokes/$jokeId.tsx
.
//app/routes/jokes/$jokeId.tsx
export const loader: LoaderFunction = async ({ request, params }) => {
const userId = await getUserId(request)
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
})
if (!joke) {
throw new Response('What a joke! Not found.', {
status: 404,
})
}
const data: LoaderData = { joke, isOwner: joke.jokester_id === userId }
return data
}
Загрузчик — это именованный экспорт, поэтому мы можем импортировать его как любую другую функцию. Но как же его вызвать? 😕
// app/routes/jokes/__tests__/jokeid.test.ts
import { loader } from '../$jokeId'
describe('loader', () => {
it('first try', async () => {
// What params do we need to call the loader?
expect(loader()).toEqual('foo') // does not work, of course
})
})
Typescript в помощь! Оказывается, нам нужно 3 параметра: request
, context
и params
.
Для использования класса Request окружение Jest должно быть установлено на «node». Давайте добавим фиктивный тест, чтобы проверить, работает ли вызов функции.
// app/routes/jokes/__tests__/jokeid.test.ts
import { loader } from '../$jokeId'
describe('loader', () => {
it('second try', async () => {
const request = new Request('http://foo.ber')
const response = await loader({ request, context: {}, params: {} })
expect(true).toBe(true)
})
})
💣💥
Мы вызвали загрузчик без параметра id
, и он взорвался. Функция не справилась с этим случаем, потому что у этого маршрута всегда будет id, поэтому наш вызов функции некорректен. Давайте добавим идентификатор.
// app/routes/jokes/__tests__/jokeid.test.ts
import { loader } from '../$jokeId'
describe('loader', () => {
it('second try', async () => {
const request = new Request('http://foo.ber')
const response = await loader({ request, context: {}, params: { jokeId: 'foo' } })
expect(true).toBe(true)
})
})
Ошибка изменилась, так что это прогресс:
Invalid prisma.joke.findUnique() invocation:
Inconsistent column data: Error creating UUID, invalid length: expected one of [36, 32], found 3
Параметр jokeId
должен иметь определенную длину. Давайте рассмотрим этот случай в коде.
//app/routes/jokes/$jokeId.tsx
export const loader: LoaderFunction = async ({ request, params }) => {
const userId = await getUserId(request)
const jokeId = params.jokeId || ''
if (![32, 36].includes(jokeId.length)) {
throw new Response('Joke id must be 32 or 36 characters', { status: 400 })
}
...
}
Теперь мы можем провести наш первый реальный тест.
// app/routes/jokes/__tests__/jokeid.test.ts
describe('loader', () => {
it('fails with an invalid id', async () => {
const request = new Request('http://foo.ber')
try {
await loader({ request, context: {}, params: { jokeId: 'foo' } })
} catch (error) {
expect((error as Response).status).toEqual(400)
}
// Todo: assert loader has thrown
})
})
Однако здесь есть одна оговорка. Если по какой-то причине мы не попадем внутрь блока catch
, то для этого теста не будет никакого expect
и он будет зеленым. Есть несколько способов справиться с этим, вот один из них:
// app/routes/jokes/__tests__/jokeid.test.ts
...
it('fails with an invalid id', async () => {
const request = new Request('http://foo.bar')
let result
try {
await loader({ request, context: {}, params: { jokeId: 'foo' } })
} catch (error) {
result = error
}
expect((result as Response).status).toEqual(400)
})
Мы могли бы также утверждать, что result
не является null, например. Но это решение кажется достаточно хорошим.
Тестирование «не найдено
Это очень похоже на тест выше. Использование поддельного случайного идентификатора делает свое дело.
// app/routes/jokes/__tests__/jokeid.test.ts
...
it('returns 404 when joke is not found', async () => {
const request = new Request('http://foo.bar')
let result
try {
await loader({
request,
context: {},
params: { jokeId: '49ed1af0-d122-4c56-ac8c-b7a5f033de88' },
})
} catch (error) {
result = error
}
expect((result as Response).status).toEqual(404)
})
Проверка счастливого пути
Этот тест прост, пока у нас есть что-то в базе данных.
// app/routes/jokes/__tests__/jokeid.test.ts
...
it('returns the joke when it is found', async () => {
const request = new Request('http://foo.bar')
const jokes = await db.joke.findMany({ take: 1 })
const joke = jokes[0]
const { id } = joke
let result
result = await loader({
request,
context: {},
params: { jokeId: id },
})
expect(result).toEqual({ joke, isOwner: false })
}
Конечно, зависимость от конкретных данных БД может привести к проблемам по мере роста проекта. Документация Prisma рекомендует издеваться над клиентом.
Полный тестовый файл:
// app/routes/jokes/__tests__/jokeid.test.ts
import { loader } from '../$jokeId'
import { db } from '~/utils/db.server'
describe('loader', () => {
it('fails with an invalid id', async () => {
const request = new Request('http://foo.bar')
let result
try {
await loader({
request,
context: {},
params: { jokeId: 'foo' },
})
} catch (error) {
result = error
}
expect((result as Response).status).toEqual(400)
})
it('returns not found when joke is not found', async () => {
const request = new Request('http://foo.bar')
let result
try {
await loader({
request,
context: {},
params: { jokeId: '49ed1af0-d122-4c56-ac8c-b7a5f033de88' },
})
} catch (error) {
result = error
}
expect((result as Response).status).toEqual(404)
})
it('returns the joke when it is found', async () => {
const request = new Request('http://foo.bar')
const jokes = await db.joke.findMany({ take: 1 })
const joke = jokes[0]
const { id } = joke
let result
result = await loader({
request,
context: {},
params: { jokeId: id },
})
expect(result).toEqual({ joke, isOwner: false })
})
})
В конечном итоге мы будем использовать другой подход: создание тестовой базы данных, которая сбрасывается при каждом запуске теста. Но это тема для будущего поста!
Надеюсь, вам понравилось писать свой первый тест Loader 😊.