Skip to content

Backend Testing Patterns

Reference for writing functional tests in the AdonisJS backend (Application-Backend).

Framework

  • Test runner: Japa v2 (@japa/runner)
  • Assertions: @japa/assert
  • HTTP client: @japa/api-client (configured via @japa/preset-adonis)
  • Stubs/mocks: sinon
  • DB strategy: Global transactions (begin before each test, rollback after)

Test File Structure

ts
import { test } from '@japa/runner'
import sinon from 'sinon'
import Database from '@ioc:Adonis/Lucid/Database'
import { accessControlTestLoginUserData } from 'TestHelpers/index'
import { RolesEnum } from 'Database/stubs/roles'

test.group('Feature | action', (group) => {
  group.each.setup(async () => {
    await Database.beginGlobalTransaction()

    return () => Database.rollbackGlobalTransaction()
  })

  test('describes expected behavior', async ({ client, route, assert }) => {
    const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)

    const response = await client
      .post(route('Controller.method'))
      .loginAs(loginUser)
      .form({ key: 'value' })

    response.assertStatus(200)
    assert.equal(response.body().data.key, 'value')
  })
})

Authentication in Tests

Use accessControlTestLoginUserData(role, createAssociations) from TestHelpers/index:

ParameterDefaultEffect
rolerequiredWhich role to assign (from RolesEnum)
createAssociationstrueWhen true: creates enterprise, workspace, brand, campaign, and role associations. When false: only creates user + role (no workspace/brand/campaign context).

The function returns { loginUser, brand, campaign, workspace, enterprise }. With createAssociations = false, only loginUser is populated.

Use .loginAs(loginUser) on the Japa API client to authenticate requests.

Route References

Named routes (via .as()) are preferred. Use the route() helper from the test context:

ts
test('example', async ({ client, route }) => {
  const response = await client.get(route('Controller.method', { id: '123' }))
})

Routes without .as() names must use raw URLs with the API prefix (e.g., /v1/assets).

Stubbing External Services

Stub external service calls (BunnyCDN, CloudFlare, etc.) to avoid real network calls:

ts
let uploadStub: sinon.SinonStub

group.each.setup(async () => {
  uploadStub = sinon.stub(BunnyStorage, 'upload').resolves()

  return () => {
    uploadStub.restore()
  }
})

// In test: verify stub was called
assert.isTrue(uploadStub.calledOnce)
const storagePath = uploadStub.firstCall.args[0] as string

When stubbing, make sure to stub ALL methods the controller calls on the service. A common pattern:

ts
sinon.stub(BunnyStorage, 'upload').resolves()
sinon.stub(BunnyStorage, 'delete').resolves()
sinon.stub(BunnyStorage, 'getPublicUrl').callsFake((path) => `https://cdn.test.com/${path}`)

File Upload Tests

Use the file helper from AdonisJS and the .file() method on the API client:

ts
import { file } from '@ioc:Adonis/Core/Helpers'

const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
  ? fakePng.contents
  : require('fs').readFileSync(fakePng.contents as any)

const response = await client
  .post(route('Controller.store'))
  .loginAs(loginUser)
  .field('brand_id', brand.id)        // form fields
  .file('file', pngBuffer, { filename: 'photo.png' })

Factories

Database factories live in database/factories/. They use @ioc:Adonis/Lucid/Factory.

Common factories:

  • UserFactory (default: active account, random email)
  • WorkspaceFactory
  • BrandFactory (requires workspaceId)
  • CampaignFactory (requires brandId and workspaceId)
  • AssetFactory (requires brand relation)

When creating related records, always merge required foreign keys:

ts
const workspace = await WorkspaceFactory.create()
const brand = await BrandFactory.merge({ workspaceId: workspace.id }).create()
const campaign = await CampaignFactory.merge({
  brandId: brand.id,
  workspaceId: workspace.id,   // required NOT NULL column
}).create()

Running Tests

bash
# All tests
node ace test

# Specific file
node ace test -- --files="tests/functional/asset_library/create.spec.ts"

# Specific test by title
node ace test -- --tests="converts png to webp"

# Skip run-failed-tests filter
node ace test -- --force

# Combine
node ace test -- --files="tests/functional/creative_assets/upload.spec.ts" --force

The @japa/run-failed-tests plugin remembers failures and only re-runs failed tests by default. Use --force to run all tests regardless.

Test Organization

tests/
├── bootstrap.ts          # Test runner config (plugins, reporters, seeders)
├── functional/           # Integration tests (HTTP requests through full stack)
│   ├── asset_library/    # Asset library CRUD
│   ├── auth/             # Login, password reset
│   ├── brands/           # Brand CRUD
│   ├── campaigns/        # Campaign CRUD
│   ├── creative_assets/  # Builder uploads
│   ├── creative_groups/  # Creative group CRUD
│   └── ...
└── unit/                 # Unit tests (isolated logic)

Common Gotchas

  • CampaignFactory needs workspaceId in addition to brandId. The workspace_id column is NOT NULL.
  • Stub ALL service methods that the controller calls, not just the main one. Missing stubs can cause unexpected undefined values.
  • file.generatePng() returns either a Buffer or a file path depending on the AdonisJS version. Always handle both cases.
  • route() only works with named routes (routes registered with .as()). Routes without names need raw URL paths.

Internal documentation