Testing ​
The Boring JavaScript Stack takes a pragmatic approach to testing: unit test your helpers, end-to-end test your pages.
Philosophy ​
Why this approach?
- Helpers are pure logic - They're isolated, stateless functions. Testing them is fast and catches real bugs.
- E2E covers everything else - Actions, policies, models, routing - they all get exercised through page tests. One e2e test covers what would take 5-10 unit tests.
- Less test code to maintain - Every test is code you have to update when things change. Fewer, higher-value tests mean less maintenance burden.
- Tests what users actually do - E2E tests mirror real usage. A passing e2e suite means the app actually works.
- Playwright makes e2e cheap - It's fast, reliable, and the API is clean. The old argument that "e2e is slow and flaky" doesn't hold anymore.
Directory structure ​
tests/
├── util/
│ └── get-sails.js # Sails singleton for unit tests
├── unit/
│ └── helpers/ # One file per helper
│ ├── capitalize.test.js
│ └── get-user-initials.test.js
└── e2e/
└── pages/ # Mirrors your pages in assets/js/pages and views/pages
├── home.test.js
├── features.test.js
├── contact.test.js
├── auth/
│ └── login.test.js
└── billing/
└── pricing.test.jsSetup ​
If you scaffolded with a Boring Stack template, these scripts are already in your package.json:
{
"scripts": {
"test:unit": "node --test --test-concurrency=1 './tests/unit/**/*.test.js'",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test": "npm run test:unit && npm run test:e2e"
}
}Running tests ​
# Run unit tests
npm run test:unit
# Run e2e tests
npm run test:e2e
# Run e2e with UI mode (interactive)
npm run test:e2e:ui
# Run e2e with visible browser
npm run test:e2e:headed
# Run all tests
npm testUnit tests ​
Unit tests use Node.js's built-in test runner - no extra dependencies needed.
The getSails() utility ​
The tests/util/get-sails.js file provides a singleton Sails instance that's shared across all tests:
const Sails = require('sails').constructor
// Singleton instance - initialized once, never torn down (process exits after tests)
let sailsInstance = null
let initPromise = null
async function getSails() {
if (sailsInstance) {
return sailsInstance
}
// Prevent multiple concurrent initializations
if (initPromise) {
return initPromise
}
initPromise = new Promise((resolve, reject) => {
const sailsApp = new Sails()
sailsApp.load(
{ environment: 'test', hooks: { shipwright: false, content: false } },
(err, sails) => {
if (err) {
return reject(err)
}
sailsInstance = sails
resolve(sails)
}
)
})
return initPromise
}
module.exports = { getSails }This singleton pattern means:
- Sails is initialized once on the first test
- All subsequent tests reuse the same instance
- No teardown needed - the process exits after tests complete
Writing a helper test ​
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { getSails } = require('../../util/get-sails')
describe('sails.helpers.capitalize()', () => {
it('capitalizes single word correctly', async () => {
const sails = await getSails()
const capitalized = sails.helpers.capitalize('hello')
assert.equal(capitalized, 'Hello')
})
it('capitalizes multiple words correctly', async () => {
const sails = await getSails()
const capitalized = sails.helpers.capitalize('the quick brown fox')
assert.equal(capitalized, 'The quick brown fox')
})
})Notice:
- No
before/afterhooks needed - Each
itblock callsgetSails()directly - Tests are
asyncbecausegetSails()returns a promise
Test environment config ​
Create config/env/test.js to configure the test environment:
module.exports = {
port: 3333,
log: {
level: 'error'
},
models: {
migrate: 'drop'
},
datastores: {
default: {
adapter: 'sails-disk'
}
},
mail: {
default: 'log',
mailers: {
log: {
transport: 'log'
}
}
}
}E2E tests ​
E2E tests use Playwright and are organized by page/route.
Writing a page test ​
import { test, expect } from '@playwright/test'
test.describe('Home Page', () => {
test('homepage loads successfully', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/My App/)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})
})Testing guest protection ​
import { test, expect } from '@playwright/test'
test.describe('Guest Protection', () => {
test('dashboard redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/login/)
})
test('settings redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('/settings/profile')
await expect(page).toHaveURL(/login/)
})
})Selector priority ​
Prefer user-facing selectors:
getByRole()- Buttons, headings, forms, linksgetByLabel()- Form inputs with labelsgetByText()- Visible text contentgetByPlaceholder()- Input placeholders- CSS/ID selectors - Only as last resort
Playwright config ​
The playwright.config.js at your project root:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3333',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'sails_environment="test" sails lift',
port: 3333,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI
}
})CI/CD ​
GitHub Actions ​
If you scaffolded with a Boring Stack template, .github/workflows/test.yml is already set up. If not, create it:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: |
rm -rf node_modules package-lock.json
npm install
- name: Run unit tests
run: npm run test:unit
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7Note on npm install
The rm -rf node_modules package-lock.json && npm install step ensures platform-specific native bindings (like rspack) are installed correctly for the CI environment.
Inertia test helpers ​
For integration testing Inertia responses, inertia-sails provides test helpers that work with sails.request(). These give you fluent assertions for testing your pages without spinning up a browser.
Setup ​
const { describe, it } = require('node:test')
const { getSails } = require('../util/get-sails')
describe('Dashboard', () => {
it('shows user stats', async () => {
const sails = await getSails()
const inertia = require('inertia-sails/test')(sails)
const page = await inertia.request('GET /dashboard')
page
.assertStatus(200)
.assertComponent('Dashboard/Index')
.assertHas('stats')
.assertHas('recentActivity', 5)
})
})Available assertions ​
// Status and component
page.assertStatus(200)
page.assertComponent('Users/Index')
page.assertUrl('/users')
// Props
page.assertHas('users') // Prop exists
page.assertHas('users', 10) // Array with 10 items
page.assertMissing('adminData') // Prop doesn't exist
page.assertProps({ 'user.name': 'John' }) // Exact value match (dot notation)
page.assertProp('user', (user) => {
// Custom assertion
assert.equal(user.role, 'admin')
})
// Flash
page.assertFlash('success') // Flash key exists
page.assertFlash('success', ['Saved!']) // Flash with value
page.assertNoFlash('error') // No flash key
// Special props
page.assertMergeProps(['items']) // Merge props
page.assertDeepMergeProps(['settings']) // Deep merge props
page.assertDeferredProps(['stats']) // Deferred propsPOST requests with data ​
it('creates a user', async () => {
const sails = await getSails()
const inertia = require('inertia-sails/test')(sails)
const page = await inertia.request({
url: 'POST /users',
data: { name: 'Jane', email: '[email protected]' }
})
page
.assertStatus(200)
.assertComponent('Users/Show')
.assertProps({ 'user.name': 'Jane' })
})Testing partial reloads ​
it('reloads only users prop', async () => {
const sails = await getSails()
const inertia = require('inertia-sails/test')(sails)
const page = await inertia.partialRequest('/users', 'Users/Index', ['users'])
page.assertHas('users')
// Other props won't be included in partial reload
})Getting raw data ​
const page = await inertia.request('GET /users')
const pageObject = page.getPage() // Full Inertia page object
const props = page.getProps() // Just the propsTips ​
- Tests use the
testenvironment (port 3333, sails-disk adapter) - Database migrates to
dropmode (fresh DB for each run) - Email transport is set to
log(no actual emails sent) - Use unique timestamps for test data:
test-${Date.now()}@example.com - Run unit tests with
--test-concurrency=1to ensure the Sails singleton works correctly