Skip to main content

Unit

Unit specs are meant testing for strictly your backend components in isolation. Unlike your feature specs, in your unit specs you are not driving a headless browser through your front end. Instead, you are testing individual units of code in your backend to make sure they behave correctly. This could mean testing the behavior of a single helper function, as well as for testing models, services, and controllers within your app.

Configuration

The configuration for your unit specs is located in spec/unit/vite.config.ts. Composing unit specs, one is enabled to test all components of their app. Since this is all done using vitest under the hood, this will likely be familiar to you already, with the one caviat that we also provide special unit spec helpers for spec'ing your endpoints. Here is a sample config that could enable one to, for example, integrate with pollyjs:

// spec/unit/vite.config.ts

import '../../src/conf/global.js'

import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
dir: './spec/unit',
globals: true,
setupFiles: ['luxon-jest-matchers', './spec/unit/setup/hooks.ts'],
fileParallelism: true,
maxConcurrency: parseInt(process.env.DREAM_PARALLEL_TESTS || '1'),
maxWorkers: parseInt(process.env.DREAM_PARALLEL_TESTS || '1'),
minWorkers: 1,
mockReset: true,
watch: false,

globalSetup: './spec/unit/setup/globalSetup.ts',
},
})

General specs

For the most part, your app can be spec'd using the general tools provided by vitest. We provide some boilerplate setup for your unit spec runs to make sure that the database is truncated between runs, which enables you to freely seed the db for each test without worrying about running into data from a previous test run:

// api/spec/unit/setup/hooks.ts

provideDreamViteMatchers()

beforeAll(async () => {
await initializePsychicApp()
})

beforeEach(async () => {
await truncate(DreamApp)
})

Parallelism

Since each test needs a freshly-truncated database to operate on, vitest is ordinarily forced to run each test, one at a time, performing truncation between each run to clear out the database. However, Dream by default supports parallel spec runs by creating extra databases for you, provided you specify the necessary configuration in conf/dream.ts.

// conf/dream.ts

export default async function configureDream(app: DreamApp) {
...
app.set('parallelTests', Number(process.env.DREAM_PARALLEL_TESTS))
}

By default, the DREAM_PARALLEL_TESTS is set for you in your .env.test file, which will ensure that this feature is enabled out of the box for you. By specifying the parallelTests option, you are instructing dream to create that many parallel databases for you. Dream will then monitor the VITEST_POOL_ID var, which vitest will set to an integer between 1 and the number of tests you specified, and will use it to point to a specific duplicated database, allowing each test to run against a unique database.

If you wish to circumvent this feature, simply adjust the spec/unit/vite.config.ts file, and set parallelism to false, like so:

// spec/unit/vite.config.ts
export default defineConfig({
test: {
fileParallelism: false,
maxConcurrency: 1,
maxWorkers: 1,
minWorkers: 1,
...
},
})

Models

Write model specs to enfore behavior for your models, like so:

// api/spec/unit/models/User.spec.ts

import { Hash } from '@rvoh/psychic'
import createUser from '../../factories/UserFactory'

describe('User', () => {
context('upon saving a password', () => {
it('hashes the password and stores it in the db', async () => {
const user = await createUser({
email: 'how@yadoin',
password: 'password',
})
expect(user.password).toBeUndefined()
expect(await Hash.check('password', user.passwordDigest)).toEqual(true)
})
})

describe('#checkPassword', () => {
let user: User

beforeEach(async () => {
user = await createUser({
email: 'how@yadoin',
password: 'password',
})
})

it('returns true with a valid password', async () => {
expect(await user.checkPassword('password')).toEqual(true)
})

it('returns false with an invalid password', async () => {
expect(await user.checkPassword('passwordz')).toEqual(false)
})
})
})

Serializers

We don't generally encourage developers to write serializer specs (since this is normally covered by endpoint tests), but sometimes it can make sense if the rendering logic gets to be fairly complex. In that case, you can test your serializers like this:

describe('UserSerializer', () => {
it('renders loginCount', async () => {
const user = await createUser({ email: 'how@yadoin', password: 'password' })
expect(new Serializer(user).render()).toEqual(
expect.objectContaining({ loginCount: 0 })
)
})
})

Anything else in the app should be fairly straight forward to test, with the exception of controller/endpoint tests, which we will cover next.

Controller/endpoint specs

Under the hood, psychic provides some test helpers to make our lives easier when needing to run controller/endpoint tests:

import { PsychicServer } from '@rvoh/psychic'
import { specRequest as request } from '@rvoh/psychic-spec-helpers'

describe('a visitor attempts to hit an unauthed route', () => {
beforeEach(async () => {
await request.init(PsychicServer)
})

it('accepts the request', async () => {
await request.get('/ping', 200)
})
})

In addition to routine endpoint tests, one can also test authenticated endpoints using chained sessions, like so:

describe('an authed user attempts to hit an authed route', () => {
beforeEach(async () => {
await createUser({ email: 'how@yadoin', password: 'password' })
await request.init(PsychicServer)
})

it('returns 200', async () => {
const session = await request.session('/api/v1/signin', 204, {
data: { email: 'how@yadoin', password: 'password' },
})
await session.get('/api/v1/users/me').expect(200)
})
})

Calling request.session here is very similar to request.post, but with the caviat that any cookies attached during the first request will now be sent to all subsequent requests.

Openapi

If you are taking advantage of the OpenAPI decorator in your controllers, you can leverage the OpenapiSpecRequest class to issue requests. The api is nearly identical to the specRequest api, except for the way it treats urls that have named uri segments.

import { PsychicServer } from '@rvoh/psychic'
import { OpenapiSpecRequest } from '@rvoh/psychic-spec-helpers'
import { openapiPaths } from '../../../src/types/openapi.d.ts'
import createPost from '../../factories/PostFactory'

const request = new OpenapiSpecRequest<openapiPaths>(openapiPaths)

describe('PostsController', () => {
beforeAll(async () => {
await request.init(PsychicServer)
})

describe('show', () => {
it('renders the post by id', async () => {
const post = await createPost({ title: 'my title', body: 'my body' })
const { body } = await request.get('/posts/{id}', 200, { id: post.id })
expect(body.title).toEqual('my title')
expect(body.body).toEqual('my body')
})
})
})

The OpenapiSpecRequest will integrate with the output of openapi-typescript, which can be automatically activated in your conf/app.ts file during the sync hook, like so:

// conf/app.ts

psy.set('openapi', {
syncTypes: true,
...
})

Since your app may have many different openapi settings, you can actually activate syncTypes on all of them. Typically, you may have one openapi file that covers all your routes, and then many segmented ones that can be read by others. This can be useful for request validation, since it can typically only read one openapi.json file. If this is the case, we recommend that you set syncTypes: true on your validation openapi file, since that one will be the most useful during specs.

// conf/app.ts

psy.set('openapi', 'validation', {
syncTypes: true,
...
})

Then run yarn psy sync to sync the openapi types:

yarn psy sync

and then, in your specs, use the newly generated types:

import { validationOpenapiPaths } from '../../../src/types/validation.openapi.d.ts'

const request = new OpenapiSpecRequest<validationOpenapiPaths>(openapiPaths)

...