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:
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:
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:
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:
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:
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:
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:
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))
})Client Builder
@adonis-agora/filter-client — a zero-dependency, framework-agnostic filter query builder, its typed variant, the reactive store contract, and TanStack Table sync.
Defining Filters
defineFilter + applyFilterFromRequest — a declarative, reusable filter definition (filterable/sortable allow-lists, relation whitelist with a depth cap, tenant scope, and server defaults) applied to a Lucid query in one call.