packages/cli/skills/pikku-testing/SKILL.md
Use when writing tests for Pikku functions, middleware, permissions, or services. Covers unit testing with direct invocation, runPikkuFunc, service mocking, and integration testing with the HTTP runner. TRIGGER when: user asks about testing, writing tests, test setup, mocking services, or integration testing Pikku functions. DO NOT TRIGGER when: user asks about running the existing test suite (use Bash) or CI configuration (not a Pikku skill).
npx skillsauth add pikkujs/pikku pikku-testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill as an execution checklist, not reference material.
pikku-meta when available; otherwise run the relevant pikku meta ... --json command and inspect only the focused output you need..pikku, node_modules, vendored packages, or broad build artifacts.pikku-verify or pikku all when functions, wirings, schemas, or generated clients may have changed.Pikku functions are pure business logic — no HTTP, no framework — making them easy to test. Test at three levels: direct function calls, runPikkuFunc (with middleware/permissions), and integration tests (full HTTP stack).
pikku info functions --verbose # See existing functions and their middleware/permissions
pikku info middleware --verbose # See middleware applied
Never put JSON, inline tables, or raw values inside .feature files. Feature files are for human-readable scenarios. All test data belongs in typed maps that step definitions look up by name.
@pikku/cucumber exports PersonaData<T> for this purpose — a typed map that throws a clear error when a name is missing.
A persona is a named user: their login credentials plus the session they hold after authenticating. Define all personas in one file:
// tests/tests/support/personas.ts
import { PersonaData } from '@pikku/cucumber'
export const logins = new PersonaData({
yasser: { email: '[email protected]', password: 'hunter2' },
guest: { email: '[email protected]', password: 'guest123' },
})
A persona step logs in and stores the session in the world so every subsequent call by that persona carries it automatically:
// tests/tests/support/steps/auth.steps.ts
import { Given } from '@cucumber/cucumber'
import { logins } from '../personas.js'
Given('{string} logs in', async function (name: string) {
await this.call(name, 'auth:login', logins.get(name))
const { token } = this.lastResult as { token: string }
this.setSession(name, { token })
})
Use a separate PersonaData map for each domain concept. Name entries after real-world meaning, not technical fields:
// tests/tests/support/data/cards.ts
import { PersonaData } from '@pikku/cucumber'
export const cards = new PersonaData({
'writing a blog post': { title: 'Writing a blog post', columnId: 'backlog' },
'fix the login bug': { title: 'Fix the login bug', columnId: 'in-progress' },
})
Steps resolve the name and make the call — the feature file never sees raw data:
// tests/tests/support/steps/card.steps.ts
import { When, Then } from '@cucumber/cucumber'
import assert from 'node:assert/strict'
import { cards } from '../data/cards.js'
When('{string} creates a card for {string}', async function (persona: string, cardName: string) {
await this.call(persona, 'kanban:createCard', cards.get(cardName))
})
When('{string} gets the card {string}', async function (persona: string, cardName: string) {
const { title } = cards.get(cardName)
await this.call(persona, 'kanban:getCard', { title })
})
// "the newly created card" — checks the live result against the data map entry
// AND any server-assigned fields (id, createdAt) are present
Then('the result is the newly created card {string}', function (cardName: string) {
const expected = cards.get(cardName)
const result = this.lastResult as typeof expected & { id: string; createdAt: string }
assert.equal(result.title, expected.title)
assert.equal(result.columnId, expected.columnId)
assert.ok(result.id, 'expected server-assigned id')
assert.ok(result.createdAt, 'expected server-assigned createdAt')
})
The feature file reads naturally:
Feature: Card management
Scenario: Create and retrieve a card
Given 'yasser' logs in
When 'yasser' creates a card for 'writing a blog post'
And 'yasser' gets the card 'writing a blog post'
Then the result is the newly created card 'writing a blog post'
tests/tests/support/
personas.ts ← logins PersonaData (one per project)
data/
cards.ts ← cards PersonaData
users.ts ← users PersonaData
steps/
auth.steps.ts ← login / logout steps
card.steps.ts ← card CRUD steps
Keep one PersonaData instance per domain concept. Steps import only what they need — no cross-domain coupling.
When asked to improve or fill test coverage, start with the AI prompt from the coverage command:
# Run tests and emit an AI-ready prompt listing every uncovered/partial function
pikku tests coverage --ai-out coverage-prompt.md
# Or skip re-running if you already have fresh coverage data
pikku tests coverage --no-run --ai-out coverage-prompt.md
# Pipe directly to stdout (e.g. to paste into a chat)
pikku tests coverage --ai-out -
The prompt lists each function that needs work with its status (uncovered/partial), coverage ratio, missed line numbers, and source file path. Use it as your starting point:
pikku meta functions list or pikku meta context to get input/output schemas for those functions..feature files under tests/tests/features/ — one feature per domain, one scenario per case.pikku tests coverage to confirm coverage improved.See pikku-concepts for the core mental model.
Pikku uses Node.js built-in test runner with tsx for TypeScript:
node --import tsx --test src/**/*.test.ts
Standard test file:
import { describe, test, beforeEach } from 'node:test'
import assert from 'node:assert'
The simplest approach — call func directly with mock services:
import { describe, test } from 'node:test'
import assert from 'node:assert'
describe('createTodo', () => {
test('should create a todo', async () => {
const mockServices = {
todoStore: {
add: async (title: string) => ({
id: '1',
title,
completed: false,
}),
},
}
const result = await createTodo.func(mockServices as any, {
title: 'Buy milk',
})
assert.equal(result.title, 'Buy milk')
assert.equal(result.completed, false)
})
})
This tests pure business logic — no middleware, no permissions, no validation.
runPikkuFunc (Full Pipeline)Tests the function through Pikku's middleware, permissions, and schema validation pipeline:
import { runPikkuFunc } from '@pikku/core'
import { addFunction, addMiddleware, addPermission } from '@pikku/core'
import { resetPikkuState, pikkuState } from '@pikku/core'
beforeEach(() => {
resetPikkuState()
})
test('should run function with middleware', async () => {
const mockSingletonServices = {
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
} as any
// Register function metadata
pikkuState(null, 'function', 'meta')['myFunc'] = {
pikkuFuncId: 'myFunc',
inputSchemaName: null,
outputSchemaName: null,
}
// Register the function
addFunction('myFunc', {
func: async (services, data) => {
return { greeting: `Hello ${data.name}` }
},
})
const result = await runPikkuFunc('rpc', 'test-wire', 'myFunc', {
singletonServices: mockSingletonServices,
getAllServices: () => mockSingletonServices,
data: () => ({ name: 'World' }),
auth: false,
wire: {},
})
assert.deepEqual(result, { greeting: 'Hello World' })
})
test('middleware runs in order: wiring tags -> wiring -> func tags -> func', async () => {
const mockSingletonServices = {
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
} as any
const order: string[] = []
const createMiddleware =
(name: string) => async (services: any, wire: any, next: Function) => {
order.push(name)
await next()
}
addMiddleware('apiTag', [createMiddleware('apiTag')])
addMiddleware('funcTag', [createMiddleware('funcTag')])
pikkuState(null, 'function', 'meta')['myFunc'] = {
pikkuFuncId: 'myFunc',
inputSchemaName: null,
outputSchemaName: null,
middleware: [{ type: 'tag', tag: 'funcTag' }],
}
addFunction('myFunc', {
func: async () => {
order.push('main')
return 'ok'
},
middleware: [createMiddleware('funcMiddleware')],
tags: ['funcTag'],
})
await runPikkuFunc('rpc', 'test', 'myFunc', {
singletonServices: mockSingletonServices,
getAllServices: () => mockSingletonServices,
data: () => ({}),
wireMiddleware: [createMiddleware('wiringMiddleware')],
inheritedMiddleware: [{ type: 'tag', tag: 'apiTag' }],
auth: false,
wire: {},
})
assert.deepEqual(order, [
'apiTag',
'wiringMiddleware',
'funcTag',
'funcMiddleware',
'main',
])
})
test('should reject when permission fails', async () => {
const mockSingletonServices = {
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
} as any
addPermission('admin', [
async () => false, // Always deny
])
pikkuState(null, 'function', 'meta')['adminFunc'] = {
pikkuFuncId: 'adminFunc',
inputSchemaName: null,
outputSchemaName: null,
permissions: [{ type: 'tag', tag: 'admin' }],
}
addFunction('adminFunc', { func: async () => 'secret' })
await assert.rejects(
runPikkuFunc('rpc', 'test', 'adminFunc', {
singletonServices: mockSingletonServices,
getAllServices: () => mockSingletonServices,
data: () => ({}),
auth: false,
wire: {},
}),
/Permission/
)
})
Test the full HTTP stack using the fetch export:
import { fetch, wireHTTP } from '@pikku/core/http'
import { resetPikkuState, pikkuState, addFunction } from '@pikku/core'
const mockSingletonServices = {
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
} as any
const listTodos = {
func: async () => ({ todos: [{ id: '1', title: 'Test todo' }] }),
}
beforeEach(() => {
resetPikkuState()
// Set up singleton services in state
pikkuState(null, 'package', 'singletonServices', mockSingletonServices)
pikkuState(null, 'package', 'factories', {
createWireServices: async () => ({}),
})
})
test('GET /todos returns todo list', async () => {
// Register route metadata and function
pikkuState(null, 'http', 'meta')['get'] =
pikkuState(null, 'http', 'meta')['get'] || {}
pikkuState(null, 'http', 'meta')['get']['/todos'] = {
pikkuFuncId: 'listTodos',
method: 'get',
route: '/todos',
}
addFunction('listTodos', listTodos)
wireHTTP({ method: 'get', route: '/todos', func: listTodos })
const request = new Request('http://localhost/todos')
const response = await fetch(request)
const data = await response.json()
assert.equal(response.status, 200)
assert.ok(Array.isArray(data.todos))
})
Test custom services in isolation:
import { describe, test } from 'node:test'
import assert from 'node:assert'
import { LocalVariablesService } from '@pikku/core/services'
describe('LocalVariablesService', () => {
test('should get and set variables', () => {
const service = new LocalVariablesService({ API_KEY: 'test-key' })
assert.equal(service.get('API_KEY'), 'test-key')
service.set('NEW_KEY', 'value')
assert.equal(service.get('NEW_KEY'), 'value')
})
})
For integration testing with a running server:
// services.ts — real service setup for tests
import { pikkuServices, pikkuWireServices } from '#pikku'
import { LocalSecretService, LocalVariablesService } from '@pikku/core/services'
export const createSingletonServices = pikkuServices(async (config) => {
const variables = new LocalVariablesService()
const secrets = new LocalSecretService(variables)
return { config, variables, secrets, logger: new ConsoleLogger() }
})
export const createWireServices = pikkuWireServices(async () => ({}))
// start.ts — bootstrap server for tests
import './.pikku/pikku-bootstrap.gen.js'
import { createSingletonServices, createWireServices } from './services.js'
const config = {}
const singletonServices = await createSingletonServices(config)
const server = new PikkuFastifyServer(
config,
singletonServices,
createWireServices
)
await server.init()
await server.start()
const mockLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
}
const mockSingletonServices = {
logger: mockLogger,
todoStore: new InMemoryTodoStore(),
// Add whatever services your functions need
} as any
Always reset pikku state in beforeEach to isolate tests:
import { resetPikkuState } from '@pikku/core'
beforeEach(() => {
resetPikkuState()
})
await assert.rejects(
async () => await myFunc.func(services, { id: 'nonexistent' }),
{ message: 'Not found' }
)
// functions/todos.functions.ts
export const createTodo = pikkuSessionlessFunc({
description: 'Create a todo',
input: z.object({ title: z.string().min(1) }),
output: z.object({ id: z.string(), title: z.string() }),
func: async ({ todoStore }, { title }) => {
return todoStore.add(title)
},
})
// functions/todos.test.ts
import { describe, test, beforeEach } from 'node:test'
import assert from 'node:assert'
class MockTodoStore {
private todos: any[] = []
async add(title: string) {
const todo = { id: String(this.todos.length + 1), title, completed: false }
this.todos.push(todo)
return todo
}
async list() {
return this.todos
}
}
describe('createTodo', () => {
let todoStore: MockTodoStore
beforeEach(() => {
todoStore = new MockTodoStore()
})
test('creates a todo with the given title', async () => {
const result = await createTodo.func({ todoStore } as any, {
title: 'Buy milk',
})
assert.equal(result.id, '1')
assert.equal(result.title, 'Buy milk')
})
test('increments IDs', async () => {
await createTodo.func({ todoStore } as any, { title: 'First' })
const second = await createTodo.func({ todoStore } as any, {
title: 'Second',
})
assert.equal(second.id, '2')
})
})
Wrong — raw values and JSON in .feature files make scenarios brittle and unreadable:
When I call 'kanban:createCard' with {"title": "My card", "columnId": "backlog"}
And I call 'kanban:getCard' with {"title": "My card"}
Then the result title is "My card"
Right — named references resolved by step definitions:
When 'yasser' creates a card for 'writing a blog post'
And 'yasser' gets the card 'writing a blog post'
Then the result is the newly created card 'writing a blog post'
Steps tied to one feature can't be reused and cause duplication. Organise by domain concept, not by feature:
Wrong: Right:
steps/
edit_work_experience.ts → steps/
edit_languages.ts → auth.steps.ts
edit_education.ts → profile.steps.ts
card.steps.ts
Name step files after the domain they cover. A login step belongs in auth.steps.ts regardless of which feature needs it.
Don't combine multiple actions into a single step — it makes reuse impossible:
# Wrong — two actions in one step
Given 'yasser' is logged in and has created a card
# Right — atomic steps, composable via And
Given 'yasser' logs in
And 'yasser' creates a card for 'writing a blog post'
Use And / But for a reason: each step should do exactly one thing.
When steps perform actions; Then steps assert outcomes. Mixing them hides intent:
# Wrong
When 'yasser' creates a card and the title is 'writing a blog post'
# Right
When 'yasser' creates a card for 'writing a blog post'
Then the call succeeds
Credentials and test inputs embedded in step code can't be reused across scenarios and break when data changes:
// Wrong
Given('{string} logs in', async function (name: string) {
await this.call(name, 'auth:login', { email: '[email protected]', password: 'hunter2' })
})
// Right — look up from PersonaData
Given('{string} logs in', async function (name: string) {
await this.call(name, 'auth:login', logins.get(name))
this.setSession(name, (this.lastResult as { token: string }))
})
documentation
Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.
testing
Use when adding authorization checks to Pikku functions or routes — pikkuPermission, pikkuAuth, per-function permissions, pattern-based permissions, or understanding OR/AND permission logic. TRIGGER when: user wants to restrict who can call a function, check resource ownership, add role-based access, or understand where permission checks belong. DO NOT TRIGGER when: user asks about middleware or request interception (use pikku-middleware), authentication strategies (use pikku-security), or session management.
testing
Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware (including service-to-service bearer auth), per-route middleware, session-setting middleware, or understanding middleware execution order and priority. TRIGGER when: user wants middleware on some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about permissions/authorization checks (use pikku-permissions), auth strategies like authBearer/authCookie (use pikku-security), or deployment.
documentation
Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `pikku create`, `git clone <template>`, or the user says "I cloned the kanban template / starter / template"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.