Create the first unit test for your Remix app loaders

Felipe Freitag Vargas - Aug 9 '22 - - Dev Community

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__/**'],
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  })
})
Enter fullscreen mode Exit fullscreen mode

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

💣💥

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 })
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

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
  })
})
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
  })
})
Enter fullscreen mode Exit fullscreen mode

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 😊

. . . . . . . . . . . . . . . . . . . . . . . . .