The UmimaClean Stack, Part 13: Testing an Inertia App with Japa

The final part - three suites split by what each proves, asserting on Inertia props instead of parsing HTML, factories with states that name business situations, organising tests by role because that is what breaks, and an honest account of what is not covered.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Last part. Japa is Adonis’s test runner, and testing an Inertia app is different enough from testing a REST API that it is worth a post of its own.

Three Suites, Split by What They Prove

tests: {
  suites: [
    { files: ['tests/unit/**/*.spec.{ts,js}'], name: 'unit', timeout: 2000 },
    { files: ['tests/functional/**/*.spec.{ts,js}'], name: 'functional', timeout: 30000 },
    { files: ['tests/browser/**/*.spec.{ts,js}'], name: 'browser', timeout: 300000 },
  ],
  forceExit: false,
}
SuiteProvesFilesTimeout
unitPure logic — validators, distance maths, report shaping82s
functionalFull HTTP through the real router, DB, and middleware2530s
browserPlaywright against the real React UI16300s

The timeouts are the honest part. A unit test that takes more than two seconds is doing something it should not — touching the database, probably. A browser test getting five minutes is an admission that Playwright plus a real server plus a real database is slow, and pretending otherwise produces flaky failures on a loaded CI runner.

The split is by what each proves, not by a coverage ratio. I did not target a pyramid shape; each suite answers a question the others cannot.

forceExit: false means the process must close its own handles. It surfaces leaked connections instead of hiding them behind a forced exit — occasionally annoying, and it is a real signal.

Plugins Do the Wiring

export const plugins: Config['plugins'] = [
	assert(),
	pluginAdonisJS(app),
	dbAssertions(app),
	apiClient(),
	authApiClient(app),
	sessionApiClient(app),
	shieldApiClient(),
	inertiaApiClient(app),
	browserClient({ runInSuites: ['browser'] }),
	sessionBrowserClient(app),
	authBrowserClient(app)
]

Each one adds capability to the test context, and three of them remove a category of boilerplate.

authApiClient gives .loginAs(user) — an authenticated request without posting to the login form. Without it, every test of an authenticated route starts by logging in, which tests the login flow 200 times and the thing you meant to test once.

shieldApiClient gives .withCsrfToken(). Without it, every POST test needs a token fetched from a page first.

inertiaApiClient is the one specific to this stack, and the reason the next section exists.

browserClient({ runInSuites: ['browser'] }) only launches Playwright for the browser suite. Otherwise every unit test pays for a browser.

export const configureSuite: Config['configureSuite'] = (suite) => {
	if (['browser', 'functional', 'e2e'].includes(suite.name)) {
		return suite.setup(() => testUtils.httpServer().start())
	}
}

An HTTP server for the suites that need one. Unit tests get none.

Assert on Props, Not HTML

This is the payoff of having no API layer.

Testing a server-rendered app usually means asserting on HTML — parsing the response, finding an element, checking its text. Brittle, because a markup change breaks a test about behaviour.

Testing a REST API means asserting on JSON, which is clean but only covers half the story: the endpoint is right and the page might still render nothing.

With Inertia there is a third option. The contract between controller and page is the props object, and you can assert on it directly:

test('GET /order renders the booking form with the active address', async ({ client, assert }) => {
	const { customer, address } = await createCustomerWithAddress('081211112001')

	const response = await client.get('/order').withInertia().loginAs(customer)

	response.assertInertiaComponent('customer/order/create')
	assert.equal(response.inertiaProps.address.id, address.id)
})

.withInertia() sends the X-Inertia header, so the server responds with the JSON page object rather than HTML. Then:

  • assertInertiaComponent — the right page was chosen
  • inertiaProps — the data it was given

That is exactly the right level. It is not markup, so restyling does not break it. It is not a JSON endpoint, because it is the real page render with the real middleware and the real shared props. It is the actual contract.

The negative case is as valuable and as cheap:

test('GET /order renders without an address so the page can prompt for one', async ({
	client,
	assert
}) => {
	const customer = await UserFactory.merge({ phone: '081211112002' }).create()

	const response = await client.get('/order').withInertia().loginAs(customer)

	assert.isNull(response.inertiaProps.address)
})

null, not missing, not a 500. A customer with no saved address is a normal state, and the page needs to be handed a null to prompt on.

Testing a Write

test('POST /orders books a pickup and starts the order awaiting collection', async ({
	client,
	assert
}) => {
	const { customer, address } = await createCustomerWithAddress('081211112003')
	const pickupDate = DateTime.now().plus({ days: 3 })

	const response = await client
		.post('/orders')
		.loginAs(customer)
		.json({ addressId: address.id, pickupDate: isoDate(pickupDate) })
		.withCsrfToken()

	const order = await Order.query().where('user_id', customer.id).firstOrFail()

	response.assertRedirectsTo(`/orders/${order.orderNumber}`)

	assert.equal(order.status, 'pickup_scheduled')
	assert.equal(order.type, 'online')
	assert.equal(order.addressId, address.id)
	assert.isNull(order.totalPrice)
})

Both halves get checked: the response (redirect to the right place) and the database (the row is what it should be). Asserting only on the redirect would pass for a controller that redirected without saving.

assert.isNull(order.totalPrice) is the interesting one. It asserts something the feature deliberately does not do — an online order has no price until inspection. Tests that pin down what should not happen are the ones that catch a well-meant future change.

And the test whose name is the entire specification:

test('the recipient is taken from the address, not the account', async ({ client, assert }) => {

A rule that would otherwise live only in a comment: a customer may be booking on behalf of somebody else, so staff need the name at the door, not the name on the account. The test name states it and the assertions enforce it.

Factories, With States That Name Situations

export const UserFactory = factory
	.define(User, async ({ faker }) => {
		return {
			name: personName(faker),
			phone: '081387882973',
			password: 'password123',
			role: Role.CUSTOMER
		}
	})
	.relation('orders', () => OrderFactory)
	.relation('addresses', () => AddressFactory)
	.state('admin', (user) => {
		user.role = Role.ADMIN
	})
	.state('staff', (user) => {
		user.role = Role.STAFF
	})
	.build()
const staff = await UserFactory.apply('staff').create()
const admin = await UserFactory.apply('admin').create()
const customer = await UserFactory.merge({ phone: '081211112001' }).create()

.apply() for a named state, .merge() for an override. The default is a customer, because most tests need one.

OrderFactory has nine states, and they are the lifecycle:

.state('offline', (order) => { order.type = OrderType.OFFLINE })
.state('inPickup', (order) => { order.status = OrderStatus.IN_PICKUP })
.state('waitingPayment', (order, { faker }) => {
  order.status = OrderStatus.AWAITING_PAYMENT
  order.totalPrice = faker.number.int({ min: 25000, max: 1000000 })
})
.state('completed', (order, { faker }) => { /* ... */ })

Note that waitingPayment sets a price as well as a status. That is the point of a state: an order awaiting payment without a total is not a state the system can produce, and a factory that lets you build one produces tests that pass against data that cannot exist.

States name business situations, not field values. apply('waitingPayment') reads as the scenario; setting status and totalPrice by hand in twenty tests reads as noise and drifts.

A Flakiness Fix Worth Copying

/**
 * Order numbers are unique in the database, and a random suffix collides often
 * enough to make any test that creates a batch of orders flaky. A counter keeps
 * them unique for the lifetime of the process.
 */
let orderSequence = 0

Order numbers are ORDYYMMDD-NNN with a daily sequence (the project write-up covers the retry logic). A random three-digit suffix in a test creating ten orders collides often — the birthday problem, and 1-in-1000 per pair adds up fast across a suite.

A module-level counter is deterministic and unique for the process. Test data that is random where it does not need to be is a flakiness source, and flaky tests get ignored, and ignored tests are worse than no tests.

The truncate-per-test keeps the state clean:

test.group('Customer Order Creation', (group) => {
  group.each.setup(() => testUtils.db().truncate())

truncate rather than transaction rollback, because the code under test uses its own transactions (Part 3) and nesting them gets confusing. Slower, and unambiguous.

Organised by Role, Because That Is What Breaks

tests/functional/
  admin/     access, dashboard, export, order, profile,
             reconciliation, report, service, signup, user
  customer/  access, address, address_validation, order, profile, transaction
  staff/     access, counter, inspection, order, profile, trip
  guest/     access, auth
  shared/    session

Not by feature. By who is doing it.

Every role folder has an access.spec.ts, and that is the deliberate part:

/**
 * The permission boundary around a staff account.
 *
 * Staff sit between the other two: they can see the shop's work but not its
 * money, and they have no customer-facing screens of their own.
 */
test.group('Staff access', (group) => {
	group.each.setup(() => testUtils.db().truncate())

	const allowed = ['/staff/trips', '/staff/orders/create', '/staff/profile', '/staff/customers']

	for (const path of allowed) {
		test(`staff may open ${path}`, async ({ client }) => {
			const staff = await UserFactory.apply('staff').create()

			const response = await client.get(path).withInertia().loginAs(staff)

			response.assertStatus(200)
		})
	}

	/**
	 * The admin half of the app is where the takings, the price list and the
	 * account register live. A staff member has no business in any of it, and
	 * the customer screens are not theirs either — they have their own.
	 */
	const denied = [
		'/order',
		'/orders',
		'/profile',
		'/address',
		'/admin/dashboard',
		'/admin/users',
		'/admin/signup',
		'/admin/reports',
		'/admin/reconciliations',
		'/admin/services'
	]

	for (const path of denied) {
		test(`staff are turned away from ${path}`, async ({ client }) => {
			/* ... */
		})
	}
})

Two arrays of URLs and a loop. Every route each role may and may not open, as data.

Why this gets its own file per role: access control is the thing most likely to break silently and most expensive to get wrong. A new admin route added without the role() middleware works perfectly for the admin who wrote it. Nothing fails. A customer finds it, or does not, and either way nobody knows.

Adding the URL to two arrays is cheap enough that it actually happens — which is the whole trick. A test that is expensive to write is a test that gets skipped on the busy day.

The denied list mixes admin and customer routes on purpose. Staff are excluded from both, and it is easy to remember the first and forget the second.

Unit Tests: Only Where the Logic Is Pure

Eight files, and all of them test something with no database in sight:

/**
 * The shop, which is where every route starts. Same coordinates as
 * TripController uses in production.
 */
const SHOP_LATITUDE = -6.9555305
const SHOP_LONGITUDE = 107.6540353

test.group('RouteService.calculateDistanceInKm', () => {
  const service = new RouteService()

  test('a stop at the shop itself is zero kilometres away', ({ assert }) => {

new RouteService() — no container, no mocks. It has no dependencies, which is why it is unit-testable. Services with dependencies get tested through HTTP instead, because mocking a dependency graph to test a service tests the mocks.

The unit suite covers RouteService (Haversine and ordering), the validators, ReportService shaping, and the series-fill helper. All pure functions over values.

No mocked-database service tests. I decided early that a test which stubs Lucid is testing my understanding of Lucid, and I would rather run the query. It makes the functional suite bigger and slower, and it means a passing test corresponds to a working feature.

Browser Tests: The Ones That Need a Browser

const photoPath = fileURLToPath(new URL('../../fixtures/photo.png', import.meta.url))

/**
 * A pickup due today with somewhere to actually drive to, which is what the
 * map card and the directions link need.
 */
async function createPickupWithAddress(customerPhone: string) {
	const customer = await UserFactory.merge({ phone: customerPhone }).create()
	const address = await AddressFactory.merge({ userId: customer.id, isActive: true }).create()

	const order = await OrderFactory.merge({
		userId: customer.id,
		addressId: address.id,
		pickupDate: DateTime.now()
	}).create()

	return { order, address }
}

test.group('Staff Trip Queue', (group) => {
	group.each.setup(() => testUtils.db().truncate())

	test('staff can see a pickup order in the queue and open it', async ({
		visit,
		route,
		browserContext
	}) => {
		const staff = await UserFactory.apply('staff').create()
		const order = await OrderFactory.merge({ pickupDate: DateTime.now() }).create()

		await browserContext.loginAs(staff)
		const page = await visit(route('staff.trip.index'))

		await page.assertPath('/staff/trips')
		await page.assertTextContains('body', order.orderNumber)
	})
})

Three things make these bearable.

browserContext.loginAs(staff) seeds the session cookie directly. No logging in through the form for every test.

route('staff.trip.index') resolves by name, so a URL change does not break sixteen files.

A real fixture file for photo uploads. Proof photos are required at several stages (Part 12) and a real .png through a real file input is the only way to test that path honestly.

Browser tests cover what genuinely needs a browser: multi-step forms with dynamic fields, the map picker, file uploads, and flows that span several pages. Not “does this page render” — the functional suite already answered that, faster.

What Is Not Covered

The honest part, because a testing post that only lists what works is not much use.

Realtime delivery. Transmit broadcasts are called from tested code paths, but “does an open page receive the message” is not tested. It needs two clients and timing assertions. Trusted.

Export file contents. Functional tests assert an export returns the right content type and a non-empty body. Nothing opens the workbook and checks that a currency cell is numeric — which is precisely the bug that shipped (Part 10). Parsing the buffer back with ExcelJS would be a genuinely valuable ten-line test, and it is the single gap I would close first.

External services. No Midtrans sandbox calls, no real Fonnte sends. The signature verification is pure and testable; the network round trips are not exercised.

The claim-lock race. The conditional UPDATE is tested for correct behaviour — claim, release, expiry — but not under genuine concurrency. Testing “two requests in the same millisecond” reliably is hard, and the guarantee comes from Postgres rather than from my code, which is most of why I am comfortable.

Load. Nothing. This is a single shop.

What I Would Change

Test the export cells. Named above; it is the clearest gap.

The denied arrays are maintained by hand. A new route added without a corresponding entry is silently untested. Enumerating the route table and asserting every route is classified would turn “somebody remembered” into “the suite noticed”.

Browser tests are slow enough to skip. Five-minute timeouts, real database, real browser. They run in CI and locally I run functional only. That is a reasonable compromise and it does mean UI regressions are found later than they could be.

Closing the Series

Thirteen parts, one application. If there is a thread running through all of it, it is that the interesting decisions were rarely about which library.

Frozen prices, a conditional UPDATE, a marker that does not move, a signature check, passwordChangedAt, filling gaps in a chart series — none of that is framework knowledge. Each came from asking what happens when the real world misbehaves: two people tap at once, a webhook is dropped, a phone dies mid-route, somebody edits a price list.

The stack’s job was to make those the only hard problems. AdonisJS decided the boring things so I did not have to. Inertia removed an entire architectural layer. Postgres held the invariants my code could not. Where I picked the smaller, more boring option and spent the saved effort on the parts specific to this business, I was right. Where I took the convenient path past something that would matter later — a signed URL where a storage key belonged, CSP left off — I was wrong, and predictably so.

Source at github.com/r3p-dev/skripsi. The product decisions are in Building an Operations Platform for a Shoe Cleaning Shop, the stack overview in The Stack Behind UmimaClean, and the infrastructure it runs on starts at Setting Up a VPS, Part 1.

© 2026 r3p.dev. All rights reserved.