We're using a mix of E2E and unit tests to increase our confidence and maintainability of Remix apps at Seasoned.
Here I'll show one way to unit test a loader, step by step. At the time of this writing, there aren't standard ways of testing components that have Remix code. So we're testing our business logic, loaders and actions separately.
This article will show a sample test without authentication. Our own auth is distinct and is a big topic. The test auth setup is left as an exercise to the reader 😬
If you're interested in E2E, uou can see examples on the remix-forms repo.
What we're building
Let's use the remix-jokes app I built when first following the Remix docs. It's a clean install, without remix-stacks because they didn't exist at the time 😅 This app uses an Express server, a Postgres DB, and Jest to run unit tests.
Setup
We'll test route files. The best place for these test files are close to the routes themselves. But we need to tell Remix the test files aren't routes, or else the server will break. Add ignoredRouteFiles
to Remix config and it's all good.
// remix.config.js
module.exports = {
...
ignoredRouteFiles: ['.*', '**/__tests__/**'],
}
Add the first test (and discover a corner case)
Let's test 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
}
The loader is a named export, so we can import it as any other function. But how do we call it, again? 😕
// 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 to the rescue! It turns out we need 3 params: request
, context
and params
.
The Jest environment must be set to "node" to use the Request class. Let's add a dummy test just to see if the function call works.
// 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)
})
})
💣💥
We called the loader without the id
param and it blew up. The function wasn't dealing with that case before because this route will always have an id, so our function call isn't valid. Let's add an 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)
})
})
The error has changed, so that's progress:
Invalid prisma.joke.findUnique() invocation:
Inconsistent column data: Error creating UUID, invalid length: expected one of [36, 32], found 3
The jokeId
param needs to have a specific length. Let's treat that case in the code.
//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 })
}
...
}
Now we can run our first real test.
// 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
})
})
There's a caveat though. If for some reason we don't fall inside the catch
block, there won't be any expect
on this test and it will be green. There are multiple ways to handle this, here is one of them:
// 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)
})
We could also assert that result
isn't null, for example. But this solution seems good enough.
Testing 'not found'
It's very similar to the test above. Using a fake random id does the trick.
// 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)
})
Testing the happy path
This one is straightforward as long as we have something in the database.
// 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 })
}
Of course that depending on specific DB data can lead to issues as the project grows. The Prisma docs recommend mocking the client.
Full test file:
// 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 })
})
})
We'll end up using a different approach: creating a test database that is reset for each test run. But that's a topic for a future post!
I hope you have enjoyed writing your first Loader test 😊