Appearance
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:
| Parameter | Default | Effect |
|---|---|---|
role | required | Which role to assign (from RolesEnum) |
createAssociations | true | When 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 stringWhen 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)WorkspaceFactoryBrandFactory(requiresworkspaceId)CampaignFactory(requiresbrandIdandworkspaceId)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" --forceThe @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
workspaceIdin addition tobrandId. Theworkspace_idcolumn is NOT NULL. - Stub ALL service methods that the controller calls, not just the main one. Missing stubs can cause unexpected
undefinedvalues. 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.