Extensions
Testing
A test is only worth its resemblance to production. @stone-js/testing boots the actual application in memory and sends it real intentions through the same kernel production uses, so you assert on behaviour a caller would see, not on the shape of your mocks.
#Install
npm i -D @stone-js/testingThe package itself knows about no platform: the main entry imports neither an HTTP package nor a browser one. The event factories sit behind subpaths, so a native application, a CLI one or a worker installs nothing it does not use.
| Entry | Type | Description |
|---|---|---|
| @stone-js/testing | always | createTestApp, the client, container substitutions, and makeIncomingEvent for a platform-agnostic cause. |
| @stone-js/testing/http | needs @stone-js/http-core | makeIncomingHttpEvent, for an application served over HTTP. |
| @stone-js/testing/browser | needs @stone-js/browser-core | makeIncomingBrowserEvent, for a browser or a React Native application. |
#Behaviour over mocks
The principle
Tests built from mocks verify that your code calls your mocks. They pass while the system is broken and break while it is fine. A test that drives the real boundary verifies what actually happens.
In Stone.js
createTestApp boots your real app on an in-memory adapter and returns a client. app.send(makeIncomingHttpEvent(...)) dispatches an event through the full kernel, and you assert on the returned response. No HTTP, no network, full fidelity.
import { createTestApp } from '@stone-js/testing'
import { makeIncomingHttpEvent } from '@stone-js/testing/http'
it('creates a task', async () => {
// No module list: your app is discovered from app/**, the same files the CLI builds.
const app = await createTestApp()
const response = await app.send(makeIncomingHttpEvent({
method: 'POST',
url: '/tasks',
body: { title: 'Ship the docs' }
}))
expect(response.statusCode).toBe(201)
expect(response.json()).toMatchObject({ title: 'Ship the docs' })
})#Frontend apps answer with a page
A rendered page is an HTML string, so it is asserted like any other response. There is no assertion library here on purpose: query that HTML with whatever you already use (happy-dom, jsdom, Testing Library).
const response = await app.send(makeIncomingHttpEvent({ url: '/' }))
expect(response.html()).toContain('<h1>Tasks</h1>')#Substituting a dependency
A fake repository, a fixed clock, a provider made to fail: bindings substitutes container registrations after your own, in the container the kernel builds for each event, so the code under test resolves the fake exactly as it resolves the real one.
const app = await createTestApp({
bindings: { clock: { now: () => '2026-01-01T00:00:00.000Z' } }
})#One config file, tests included
stone test runs your suite with Vitest, configured from stone.config.mjs like the build is. It does two things a bare runner cannot: it loads .env.test before the runner starts, so a value read at module load sees it, and it hands the test process the same file set the build uses, so a suite cannot boot a different application than the one that ships.
export default defineBuilderConfig({
test: {
envFile: '.env.test', // loaded before anything imports
include: ['./tests/**/*.spec.ts'],
vitest: { environment: 'happy-dom' } // raw Vitest config, merged over the defaults
}
})Boot the real app. Send a real intention. Assert on the real response.
#The harness API
| API | Type | Description |
|---|---|---|
| createTestApp(options?) | (opts) => Promise<TestClient> | Boot the app in memory, discovering modules from app/** unless options.modules names them. |
| options.appDir / pattern | string | Where to discover from, for a non-standard layout. |
| options.envFile | string | false | Env file to load before booting. Defaults to .env.test; a missing file is not an error. |
| options.bindings | Record<string, unknown> | Container substitutions by alias, bound after the app's own registrations. |
| app.send(event) | (event) => Promise<Response> | Dispatch an event through the full kernel. |
| options.blueprint | Partial<StoneBlueprint> | Configuration to force, merged after the app's own modules so it wins. The counterpart of bindings: one replaces a service, the other replaces a value. |
| options.platform | string | The context to run as, when an app stacks several. A browser or native renderer registers itself against it. |
| makeIncomingHttpEvent(opts) | (opts) => event | From /http. Build an HTTP event: { method, url, body, headers, ip }. |
| makeIncomingBrowserEvent(opts) | (opts) => event | From /browser. Build the event a browser or native app receives: { url, metadata }. Keeps your own scheme, so myapp://tasks/42 reaches the route a phone reaches. |
| response.statusCode | number | The response status. |
| response.json() | <T>() => T | The body as data: parsed when the payload is a JSON string. |
| response.html() / text() | () => string | The body as text, for a rendered page. |
#Every context, one harness
Because the harness dispatches intentions through the kernel, the same test covers the behaviour whether the app will finally run on Node, on the edge, or as agent tools. You test the domain once; the contexts do not change what it does.
HTTP is not the only cause. makeIncomingHttpEvent builds an HTTP intention; makeIncomingEvent builds a generic one, so the same app.send()exercises a CLI command or an agent tool call, no server and no argv parsing required.
An application that renders needs its own: a browser and a phone receive an IncomingBrowserEvent, and the React renderer keys its hydration snapshot on that event's identity. Name the platform, send the event that platform delivers, and a native application is tested exactly like a web one.
import { createTestApp } from '@stone-js/testing'
import { makeIncomingBrowserEvent } from '@stone-js/testing/browser'
import { REACT_NATIVE_PLATFORM } from '@stone-js/react-native-adapter'
const app = await createTestApp({ platform: REACT_NATIVE_PLATFORM })
const response = await app.send(makeIncomingBrowserEvent({ url: 'myapp://tasks/42' }))
expect(response.statusCode).toBe(200)import { createTestApp, makeIncomingEvent } from '@stone-js/testing'
const app = await createTestApp({ modules: [PruneCommand, TaskService] })
const res = await app.send(makeIncomingEvent({ name: 'tasks:prune', days: 30 }))
expect(res.getContent()).toContain('Pruned')