Puty is a declarative testing framework that allows you to write unit tests using YAML files instead of JavaScript code. It's built on top of Vitest and designed to make testing more accessible and maintainable by separating test data from test logic.
Puty is ideal for testing pure functions - functions that always return the same output for the same input and have no side effects. The declarative YAML format perfectly captures the essence of pure function testing: given these inputs, expect this output.
- 📝 Write tests in simple YAML format
- 📦 Modular test organization with
!include
directive - 🎯 Clear separation of test data and test logic
- 🧪 Mock support for testing functions with dependencies
- ⚡ Powered by Vitest for fast test execution
npm install puty
Get up and running with Puty in just a few minutes!
- Node.js with ES modules support
- Vitest installed in your project
npm install puty
Ensure your package.json
has ES modules enabled:
{
"type": "module"
}
Create utils/validator.js
:
export function isValidEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
Create validator.test.yaml
:
file: './utils/validator.js'
group: validator
suites: [isValidEmail, capitalize]
---
suite: isValidEmail
exportName: isValidEmail
---
case: valid email should return true
in: ['user@example.com']
out: true
---
case: invalid email should return false
in: ['invalid-email']
out: false
---
case: empty string should return false
in: ['']
out: false
---
suite: capitalize
exportName: capitalize
---
case: capitalize first letter
in: ['hello']
out: 'Hello'
---
case: single letter
in: ['a']
out: 'A'
Create puty.test.js
:
import path from 'path'
import { setupTestSuiteFromYaml } from 'puty'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
await setupTestSuiteFromYaml(__dirname);
npx vitest
You should see output like:
✓ validator > isValidEmail > valid email should return true
✓ validator > isValidEmail > invalid email should return false
✓ validator > isValidEmail > empty string should return false
✓ validator > capitalize > capitalize first letter
✓ validator > capitalize > single letter
🎉 That's it! You've just created declarative tests using YAML instead of JavaScript.
To enable automatic test reruns when YAML test files change, create a vitest.config.js
file in your project root:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
forceRerunTriggers: [
'**/*.js',
'**/*.{test,spec}.yaml',
'**/*.{test,spec}.yml'
],
},
});
This configuration ensures that Vitest will re-run your tests whenever you modify either your JavaScript source files or your YAML test files.
Here's a complete example of testing JavaScript functions with Puty:
file: './math.js'
group: math
suites: [add, increment]
---
### Add
suite: add
exportName: add
---
case: add 1 and 2
in:
- 1
- 2
out: 3
---
case: add 2 and 2
in:
- 2
- 2
out: 4
---
### Increment
suite: increment
exportName: default
---
case: increment 1
in:
- 1
out: 2
---
case: increment 2
in:
- 2
out: 3
This under the hood creates a test structure in Vitest like:
describe('math', () => {
describe('add', () => {
it('add 1 and 2', () => { ... })
it('add 2 and 2', () => { ... })
})
describe('increment', () => {
it('increment 1', () => { ... })
it('increment 2', () => { ... })
})
})
See the YAML Structure section for detailed documentation of all available fields.
Puty also supports testing classes with method calls and state assertions:
file: './calculator.js'
group: Calculator
suites: [basic-operations]
---
suite: basic-operations
mode: 'class'
exportName: default
constructorArgs: [10] # Initial value
---
case: add and multiply operations
executions:
- method: add
in: [5]
out: 15
asserts:
- property: value
op: eq
value: 15
- method: multiply
in: [2]
out: 30
asserts:
- property: value
op: eq
value: 30
- method: getValue
in: []
out: 30
-
mode: 'class'
- Indicates this suite tests a class -
constructorArgs
- Arguments passed to the class constructor -
executions
- Array of method calls to execute in sequence-
method
- Name of the method to call (supports nested:user.api.getData
) -
in
- Arguments to pass to the method -
out
- Expected return value (optional) -
asserts
- Assertions to run after the method call- Property assertions: Check instance properties (supports nested:
user.profile.name
) - Method assertions: Call methods and check their return values (supports nested:
settings.getTheme
)
- Property assertions: Check instance properties (supports nested:
-
Puty supports testing factory functions that return objects with methods. When using executions
in a function test, you can omit the out
field to skip asserting the factory's return value:
file: './store.js'
group: store
---
suite: createStore
exportName: createStore
---
case: test store methods
in:
- { count: 0 }
# No 'out' field - skip return value assertion
executions:
- method: getCount
in: []
out: 0
- method: dispatch
in: [{ type: 'INCREMENT' }]
out: 1
- method: getCount
in: []
out: 1
This pattern is useful for:
- Factory functions that return objects with methods
- Builder patterns
- Module patterns that return APIs
- Any function that returns an object you want to test methods on
Key behaviors:
- When
out
field is omitted: The function is called but its return value is not asserted - When
out:
is present (even empty): The return value is asserted (empty value in YAML equalsnull
) - This works for any function test, with or without
executions
Examples:
# No assertion on return value
case: test without return assertion
in: [1, 2]
# Assert return value is null
case: test null return
in: [1, 2]
out:
# Assert return value is 42
case: test specific return
in: [1, 2]
out: 42
To assert that a function returns undefined
, use the special keyword __undefined__
:
# Assert function returns undefined
case: test undefined return
in: []
out: __undefined__
# Also works in executions
executions:
- method: doSomething
in: []
out: __undefined__
# And in mock definitions
mocks:
callback:
calls:
- in: ['data']
out: __undefined__
The __undefined__
keyword works in:
- Function return value assertions (
out: __undefined__
) - Method return value assertions in executions
- Mock return values
- Mock input expectations
- Property assertions (
value: __undefined__
)
You can test that functions or methods throw expected errors:
case: divide by zero
in: [10, 0]
throws: "Division by zero"
Puty supports mocking dependencies using the $mock:
syntax. This is useful for testing functions that have external dependencies like loggers, API clients, or callbacks.
file: './calculator.js'
group: calculator
---
suite: calculate
exportName: calculateWithLogger
---
case: test with mock logger
in:
- 10
- 5
- $mock:logger
out: 15
mocks:
logger:
calls:
- in: ['Calculating 10 + 5']
- in: ['Result: 15']
Mocks can be defined at three levels (case overrides suite, suite overrides global):
file: './service.js'
group: service
mocks:
globalApi: # Global mock - available to all suites
calls:
- in: ['/default']
out: { status: 200 }
---
suite: userService
mocks:
api: # Suite mock - available to all cases in this suite
calls:
- in: ['/users']
out: { users: [] }
---
case: get user with mock
in: [123, $mock:api]
out: { id: 123, name: 'John' }
mocks:
api: # Case mock - overrides suite mock
calls:
- in: ['/users/123']
out: { id: 123, name: 'John' }
Mocks are perfect for testing event-driven code:
case: test event emitter
executions:
- method: on
in: ['data', $mock:callback]
- method: emit
in: ['data', 'hello']
mocks:
callback:
calls:
- in: ['hello']
Puty supports the !include
directive to modularize and reuse YAML test files. This is useful for:
- Sharing common test data across multiple test files
- Organizing large test suites into smaller, manageable files
- Reusing test cases for different modules
You can include entire YAML documents:
file: "./math.js"
group: math-tests
suites: [add]
---
!include ./suite-definition.yaml
---
!include ./test-cases.yaml
You can also include specific values within a YAML document:
case: test with shared data
in: !include ./test-data/input.yaml
out: !include ./test-data/expected-output.yaml
The !include
directive supports recursive includes, allowing included files to include other files:
# main.yaml
!include ./level1.yaml
# level1.yaml
suite: test
---
!include ./level2.yaml
# level2.yaml
case: nested test
in: []
out: "success"
- File paths in
!include
are relative to the YAML file containing the directive - Circular dependencies are detected and will cause an error
- Missing include files will result in a clear error message
- Both single documents and multi-document YAML files can be included
Puty test files use multi-document YAML format with three types of documents:
file: './module.js' # Required: Path to JS file (relative to YAML file)
group: 'test-group' # Required: Test group name (or use 'name')
suites: ['suite1', 'suite2'] # Optional: List of suites to define
mocks: # Optional: Global mocks available to all suites
mockName:
calls:
- in: [args]
out: result
suite: 'suiteName' # Required: Suite name
exportName: 'functionName' # Optional: Export to test (defaults to suite name or 'default')
mode: 'class' # Optional: Set to 'class' for class testing
constructorArgs: [arg1] # Optional: Arguments for class constructor (class mode only)
mocks: # Optional: Suite-level mocks for all cases in this suite
mockName:
calls:
- in: [args]
out: result
For function tests:
case: 'test description' # Required: Test case name
in: [arg1, arg2] # Required: Input arguments (use $mock:name for mocks)
out: expectedValue # Optional: Expected output (omit if testing for errors)
throws: 'Error message' # Optional: Expected error message
mocks: # Optional: Case-specific mocks
mockName:
calls: # Array of expected calls
- in: [args] # Expected arguments
out: result # Optional: Return value
throws: 'error' # Optional: Throw error instead
For class tests:
case: 'test description'
executions:
- method: 'methodName' # Supports nested: 'user.api.getData'
in: [arg1]
out: expectedValue # Optional
throws: 'Error msg' # Optional
asserts:
- property: 'prop' # Supports nested: 'user.profile.name'
op: 'eq' # Currently only 'eq' is supported
value: expected
- method: 'getter' # Supports nested: 'settings.ui.getTheme'
in: []
out: expected
mocks: # Optional: Mocks for the entire test case
mockName:
calls:
- in: [args]
out: result
Puty supports accessing nested properties and calling nested methods using dot notation:
case: 'test nested access'
executions:
- method: 'settings.ui.setTheme' # Call nested method
in: ['dark']
out: 'dark'
asserts:
- property: 'user.profile.name' # Access nested property
op: eq
value: 'John Doe'
- property: 'user.account.balance' # Deep nested property
op: eq
value: 100.50
- method: 'api.client.get' # Call nested method
in: ['/users/123']
out: 'GET /users/123'