Agora
Guides

Testing

Unit-testing a filter spec against the shipped recording mock, asserting the allow-list and the server scope, and end-to-end Japa tests against a real database.

A filter is worth testing at two levels, and they prove different things. A unit test drives the spec against a recording query builder and asserts which clauses were produced — it runs in milliseconds and needs no database. A functional test hits the real endpoint with Japa's API client and asserts the rows that come back. The first proves you built the right query; the second proves the query is right.

Unit tests: the shipped recorder

@adonis-agora/filter/testing ships a MockQueryBuilder that satisfies QueryBuilderLike and records every call. Because it is a real implementation of the contract, you can run your actual spec through applyFilterFromRequest against it:

tests/unit/user_filter.spec.ts
import { test } from '@japa/runner'
import { applyFilterFromRequest, defineFilter } from '@adonis-agora/filter'
import { makeMockQueryBuilder } from '@adonis-agora/filter/testing'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'

/** Minimal stand-in for the parts of HttpContext a filter reads. */
function ctxWith(qs: Record<string, unknown>, extra: Record<string, unknown> = {}) {
  return { request: { qs: () => qs }, ...extra }
}

test.group('userFilter', () => {
  test('a contains filter becomes ILIKE with escaped wildcards', ({ assert }) => {
    const qb = makeMockQueryBuilder()

    applyFilterFromRequest(qb, userFilter, ctxWith({ filter: { name: { contains: 'Al' } } }))

    assert.deepInclude(qb.flatten(), { method: 'whereILike', args: ['name', '%Al%'] })
  })

  test('sort direction comes from the leading dash', ({ assert }) => {
    const qb = makeMockQueryBuilder()

    applyFilterFromRequest(qb, userFilter, ctxWith({ sort: '-createdAt' }))

    assert.deepEqual(qb.find('orderBy')?.args, ['createdAt', 'desc'])
  })
})

flatten() returns every leaf call across the builder and its nested groups, so an assertion does not have to know how deeply a clause was grouped. The Testing Utilities page covers find(), findAll(), relation subqueries and raw-SQL assertions.

QueryBuilderLike is a small structural interface, so a hand-rolled recorder works too — see the contract for every member it has to cover. Reach for it only when you need behaviour the shipped recorder does not have; otherwise import the one that already exists.

Test the allow-list like a security boundary

filterable is what stands between a query string and your columns, so assert it directly — both that a disallowed field never reaches the builder, and that strict mode rejects it loudly:

tests/unit/user_filter.spec.ts
test('a column outside filterable never reaches the builder', ({ assert }) => {
  const qb = makeMockQueryBuilder()

  applyFilterFromRequest(
    qb,
    userFilter,
    ctxWith({ filter: { name: 'Al', passwordHash: 'x' } }),
  )

  const calls = qb.flatten()
  assert.deepInclude(calls, { method: 'where', args: ['name', 'Al'] })
  assert.isUndefined(calls.find((call) => call.args.includes('passwordHash')))
})

test('strict mode throws instead of dropping', ({ assert }) => {
  const strict = defineFilter({ model: User, filterable: ['name'], throwOnInvalid: true })

  assert.throws(() =>
    applyFilterFromRequest(
      makeMockQueryBuilder(),
      strict,
      ctxWith({ filter: { passwordHash: 'x' } }),
    ),
  )
})

The same test is worth writing for the server scope, which runs before the allow-list and cannot be relaxed by the request:

tests/unit/user_filter.spec.ts
const scoped = defineFilter({
  model: User,
  filterable: ['name'],
  tenant: { column: 'tenantId', resolve: (ctx) => ctx.auth?.user?.tenantId },
})

test('the client cannot override the tenant scope', ({ assert }) => {
  const qb = makeMockQueryBuilder()

  applyFilterFromRequest(
    qb,
    scoped,
    ctxWith({ filter: { tenantId: 999 } }, { auth: { user: { tenantId: 42 } } }),
  )

  const calls = qb.flatten()
  assert.deepInclude(calls, { method: 'where', args: ['tenantId', 42] })
  assert.isUndefined(calls.find((call) => call.args.includes(999)))
})

Pagination is a return value, so assert it

applyFilterFromRequest returns the resolved { page, size } — clamping is part of the contract:

tests/unit/user_filter.spec.ts
test('size is clamped to maxSize and page floors at 1', ({ assert }) => {
  const qb = makeMockQueryBuilder()

  assert.deepEqual(applyFilterFromRequest(qb, userFilter, ctxWith({})), { page: 1, size: 25 })

  assert.deepEqual(
    applyFilterFromRequest(qb, userFilter, ctxWith({ page: '3', size: '500' })),
    { page: 3, size: 100 },
  )
})

The parse step on its own

parseFilterRequest is pure — feed it a decoded query object and assert the structured output. This is the test to write when you are debugging a wire-format question rather than a SQL one:

tests/unit/parse_request.spec.ts
import { test } from '@japa/runner'
import { parseFilterRequest } from '@adonis-agora/filter'

test('parses bracket-notation operators and a sort list', ({ assert }) => {
  const input = parseFilterRequest({
    filter: { age: { gte: '18' }, status: 'active' },
    sort: '-createdAt,name',
  })

  assert.deepEqual(input.filters, [
    { field: 'age', operator: 'gte', value: '18' },
    { field: 'status', operator: 'equals', value: 'active' },
  ])
  assert.deepEqual(input.sort, [
    { field: 'createdAt', direction: 'desc' },
    { field: 'name', direction: 'asc' },
  ])
})

Functional tests against a real database

The mock proves the calls; the functional test proves the SQL. Build the query string with the client package so the test also round-trips the wire format — if the client and the server ever disagree on encoding, this test is where it surfaces:

tests/functional/users.spec.ts
import { test } from '@japa/runner'
import { filterQuery } from '@adonis-agora/filter-client'
import testUtils from '@adonisjs/core/services/test_utils'
import User from '#models/user'

test.group('GET /users', (group) => {
  group.each.setup(() => testUtils.db().withGlobalTransaction())

  group.each.setup(async () => {
    await User.createMany([
      { name: 'Alice', status: 'active', age: 30 },
      { name: 'Bob', status: 'inactive', age: 20 },
    ])
  })

  test('filters by status and age', async ({ client, assert }) => {
    const qs = filterQuery().equals('status', 'active').gte('age', 25).toQueryString()

    const response = await client.get(`/users?${qs}`)

    response.assertStatus(200)
    assert.lengthOf(response.body().data, 1)
    assert.equal(response.body().data[0].name, 'Alice')
  })

  test('a disallowed field is a 400 in strict mode', async ({ client }) => {
    const response = await client.get('/users?filter[passwordHash]=x')

    response.assertStatus(400)
  })
})

Testing the client builder

The builder is pure and synchronous, so its terminators can be asserted without a server:

tests/unit/filter_query.spec.ts
import { test } from '@japa/runner'
import { filterQuery } from '@adonis-agora/filter-client'

test('builds the expected query string', ({ assert }) => {
  const qs = filterQuery()
    .contains('name', 'Al')
    .gte('age', 18)
    .sortDesc('createdAt')
    .toQueryString()

  assert.equal(qs, 'filter%5Bname%5D[contains]=Al&filter%5Bage%5D[gte]=18&sort=-createdAt')
})

test('where replaces a field, add accumulates on it', ({ assert }) => {
  const result = filterQuery()
    .add('createdAt', 'gte', '2026-01-01')
    .add('createdAt', 'lte', '2026-12-31')
    .build()

  assert.lengthOf(result.filter.where, 2)
})

test('an operator that requires an array rejects a scalar', ({ assert }) => {
  assert.throws(() => filterQuery().where('status', 'in', 'active' as any))
})

On this page