@kyrwing/zest
v0.6.3
Published
Type-aware CLI that generates production-ready Jest tests for Zustand stores
Downloads
117
Maintainers
Readme
🧪 @kyrwing/zest
⚡ Type-aware test generator for Zustand stores — automatically generate Jest/Vitest tests with typed mocks, assertions, and behavioral testing. Cut boilerplate, enforce best practices, ship faster.
📋 Table of Contents
- ✨ Features
- 📦 Installation
- 🚀 Quick Start
- 🎛 CLI Reference
- ⚙️ Configuration
- 🧠 How It Works
- 📝 Examples
- 🔌 Integration
- 🔍 Troubleshooting
- 👨💻 API
- 🤝 Contributing
- 📜 License
✨ Features
| Feature | Description |
|---------|-------------|
| 🔹 Type-Aware Parsing | Uses ts-morph AST + TypeChecker to infer exact mock values (null, false, {}, []) |
| 🔹 Dynamic Store Names | Extracts useAuthStore from code — no hardcoded useTestStore |
| 🔹 Action Signature Mapping (v0.6.0) | Generates act(() => action(...)) + toHaveBeenCalledWith() for behavior testing |
| 🔹 Framework Auto-Detect | Supports jest / vitest — auto-detected from package.json or explicit via --framework |
| 🔹 Middleware Support | Recursively finds store object inside persist(), devtools(), custom wrappers |
| 🔹 Configurable Output | Auto-saves next to source, or use [name] placeholder in .zestrc.js |
| 🔹 Zero Runtime Dependencies | CLI-only tool — generated tests use your project's existing deps |
📦 Installation
As a dev dependency (recommended)
npm install -D @kyrwing/zest
# or
yarn add -D @kyrwing/zest
# or
pnpm add -D @kyrwing/zestGlobal install (for CLI usage anywhere)
npm install -g @kyrwing/zestRequirements
| Dependency | Version | Purpose |
|------------|---------|---------|
| Node.js | >=16.0.0 | Runtime for CLI |
| TypeScript | >=4.5.0 | Required for type inference |
| zustand | >=4.0.0 | Stores you want to test |
| @testing-library/react | >=12.0.0 | For running generated tests (devDependency of your project) |
🚀 Quick Start
1. Create a Zustand store
// src/stores/authStore.ts
import { create } from 'zustand';
interface AuthState {
token: string | null;
user: { id: string; name: string } | null;
isLoading: boolean;
login: (token: string, userId: string) => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
user: null,
isLoading: false,
login: async (token, userId) => {
// API call...
set({ token, user: { id: userId, name: 'User' }, isLoading: false });
},
logout: () => set({ token: null, user: null }),
}));2. Generate a test
# Basic usage
npx @kyrwing/zest src/stores/authStore.ts --assert
# With custom output path
npx @kyrwing/zest src/stores/authStore.ts -o src/stores/authStore.test.ts --assert
# With explicit framework
npx @kyrwing/zest src/stores/authStore.ts --framework vitest --assert3. Review the generated test
// src/stores/authStore.test.ts — auto-generated
import { renderHook, act } from '@testing-library/react';
import { create } from 'zustand';
const useAuthStore = create((set) => ({
token: null,
user: null,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
}));
describe('useAuthStore', () => {
it('initializes correctly', () => {
const { result } = renderHook(() => useAuthStore());
expect(result.current).toBeDefined();
});
it('initializes with correct values', () => {
const { result } = renderHook(() => useAuthStore());
expect(result.current.token).toBe(null);
expect(result.current.user).toBe(null);
expect(result.current.isLoading).toBe(false);
expect(typeof result.current.login).toBe('function');
expect(typeof result.current.logout).toBe('function');
});
// 🆕 v0.6.0: Behavioral tests with typed mocks
it('calls actions with typed mocks', () => {
const { result } = renderHook(() => useAuthStore());
act(() => result.current.login("", ""));
expect(result.current.login).toHaveBeenCalledWith("", "");
act(() => result.current.logout());
expect(result.current.logout).toHaveBeenCalledWith();
});
});4. Run your tests
# With Jest
npm test -- authStore.test.ts
# With Vitest
npx vitest run authStore.test.ts🎛 CLI Reference
Syntax
zest <store-path> [options]Arguments
| Argument | Description | Example |
|----------|-------------|---------|
| <store-path> | Path to your Zustand store file | src/stores/authStore.ts |
Options
| Flag | Short | Description | Default |
|------|-------|-------------|---------|
| --output <path> | -o | Output path for generated test. Supports [name] placeholder | Auto: ${dirname}/${name}.test.ts |
| --framework <type> | — | Test framework: jest, vitest, or auto | auto (detects from package.json) |
| --assert | — | Generate basic assertions for state and actions | false |
| --help | -h | Show help | — |
| --version | -V | Show version | — |
Examples
# 🎯 Basic generation (mocks only, no assertions)
npx @kyrwing/zest src/store.ts
# ✅ With assertions + auto output path
npx @kyrwing/zest src/store.ts --assert
# 📁 Custom path with placeholder
npx @kyrwing/zest src/store.ts -o "tests/[name].spec.ts" --assert
# 🧪 Explicit framework selection
npx @kyrwing/zest src/store.ts --framework vitest --assert
# 🔍 Full combination
npx @kyrwing/zest src/auth/store.ts -o "__tests__/auth/[name].test.ts" --framework jest --assert⚙️ Configuration: .zestrc.js
Create a .zestrc.js file in your project root for global defaults:
// @ts-check
/** @type {import('@kyrwing/zest').ZestConfig} */
module.exports = {
// 'jest', 'vitest', or 'auto' (default)
framework: 'auto',
// Generate basic assertions by default
assert: true,
// Default output path ([name] = filename without extension)
output: '__tests__/[name].test.ts',
// Ignore these middleware wrappers during parsing
ignoreMiddleware: ['devtools', 'persist', 'subscribeWithSelector'],
// AI settings (coming in v0.7.0)
// ai: { provider: 'openai', model: 'gpt-4', enabled: false },
};Priority Order
CLI flags > .zestrc.js > built-in defaultsExample: If config has assert: true but you run without --assert, assertions will be generated. To disable: zest file.ts --assert false.
🧠 How It Works
Architecture Overview
┌─────────────────┐
│ CLI (index.ts) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ loadConfig() │ → reads .zestrc.js
└────────┬────────┘
│
▼
┌─────────────────┐
│ parseStore() │ → ts-morph AST parsing
│ (storeParser) │ • extracts storeName
│ │ • identifies state/actions
│ │ • infers types via TypeChecker
└────────┬────────┘
│
▼
┌─────────────────┐
│ generateTestFile│ → template rendering
│ (testGenerator) │ • mocks for state
│ │ • jest.fn()/vi.fn() for actions
│ │ • assertions: toBe/toEqual/typeof
│ │ • 🆕 v0.6.0: act() + toHaveBeenCalledWith
└────────┬────────┘
│
▼
┌─────────────────┐
│ writeFileSync │ → saves .test.ts
└─────────────────┘Type Inference Strategy
Two-pass approach for accurate mocks:
PRIORITY 1: Explicit Literals (fast & precise)
├─ ObjectLiteral → '{}'
├─ ArrayLiteral → '[]'
├─ NullKeyword → 'null'
├─ True/False → 'true'/'false'
├─ String regex → '"value"'
└─ Number regex → '42'
PRIORITY 2: Type Inference via ts-morph (complex cases)
├─ Union<string | null> → 'null' (if initialized as null)
├─ Record<K, V> → '{}'
├─ Array<T> → '[]'
├─ Interface → '{}'
└─ Fallback → initText || '{}'Action Signature Mapping (v0.6.0)
For functions, the parser extracts parameter signatures:
// Source code
setLive: (roomId: number, title: string, cover: string) => { ... }
// Extracted params
[
{ name: 'roomId', typeString: 'number', mockValue: '0' },
{ name: 'title', typeString: 'string', mockValue: '""' },
{ name: 'cover', typeString: 'string', mockValue: '""' }
]
// Generated test
act(() => result.current.setLive(0, "", ""));
expect(result.current.setLive).toHaveBeenCalledWith(0, "", "");📝 Examples
Example 1: Simple Store with Primitives
// src/stores/counterStore.ts
export const useCounterStore = create((set) => ({
count: 0,
step: 1,
increment: () => set((s) => ({ count: s.count + s.step })),
reset: () => set({ count: 0 }),
}));npx @kyrwing/zest src/stores/counterStore.ts --assert// ✅ Generated test
const useCounterStore = create((set) => ({
count: 0,
step: 1,
increment: jest.fn(),
reset: jest.fn(),
}));
describe('useCounterStore', () => {
it('initializes with correct values', () => {
const { result } = renderHook(() => useCounterStore());
expect(result.current.count).toBe(0);
expect(result.current.step).toBe(1);
expect(typeof result.current.increment).toBe('function');
expect(typeof result.current.reset).toBe('function');
});
it('calls actions with typed mocks', () => {
const { result } = renderHook(() => useCounterStore());
act(() => result.current.increment());
expect(result.current.increment).toHaveBeenCalledWith();
act(() => result.current.reset());
expect(result.current.reset).toHaveBeenCalledWith();
});
});Example 2: Store with Record and Async Actions
// src/stores/usersStore.ts
interface User { id: string; name: string; email: string; }
export const useUsersStore = create((set) => ({
users: {} as Record<string, User>,
isLoading: false,
fetchUser: async (id: string) => {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
set((s) => ({ users: { ...s.users, [id]: user } }));
},
addUser: (user: User) => set((s) => ({
users: { ...s.users, [user.id]: user }
})),
}));npx @kyrwing/zest src/stores/usersStore.ts --assert// ✅ Generated test
const useUsersStore = create((set) => ({
users: {},
isLoading: false,
fetchUser: jest.fn(),
addUser: jest.fn(),
}));
describe('useUsersStore', () => {
it('initializes with correct values', () => {
const { result } = renderHook(() => useUsersStore());
expect(result.current.users).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(typeof result.current.fetchUser).toBe('function');
expect(typeof result.current.addUser).toBe('function');
});
it('calls actions with typed mocks', () => {
const { result } = renderHook(() => useUsersStore());
act(() => result.current.fetchUser(""));
expect(result.current.fetchUser).toHaveBeenCalledWith("");
act(() => result.current.addUser({ id: "", name: "", email: "" }));
expect(result.current.addUser).toHaveBeenCalledWith({ id: "", name: "", email: "" });
});
});Example 3: Store with Middleware (persist, devtools)
// src/stores/settingsStore.ts
import { persist, devtools } from 'zustand/middleware';
export const useSettingsStore = create(
devtools(
persist(
(set) => ({
darkMode: false,
setDarkMode: (v: boolean) => set({ darkMode: v }),
}),
{ name: 'settings' }
)
)
);npx @kyrwing/zest src/stores/settingsStore.ts --assert// ✅ Parser recursively finds store object inside wrappers
const useSettingsStore = create((set) => ({
darkMode: false,
setDarkMode: jest.fn(),
}));
describe('useSettingsStore', () => {
// ... assertions work as usual
});Example 4: export default (fallback name)
// src/stores/anonymousStore.ts
export default create((set) => ({
value: 'test',
setValue: (v: string) => set({ value: v }),
}));npx @kyrwing/zest src/stores/anonymousStore.ts --assert// ⚠️ Name not extracted → safe fallback used
const useTestStore = create((set) => ({
value: "test",
setValue: jest.fn(),
}));
describe('useTestStore', () => {
// ... tests work, but name is generic
});💡 Pro Tip: For best DX, use named exports:
export const useMyStore = create(...).
🔌 Integration
In package.json (scripts)
{
"scripts": {
"test": "jest",
"test:generate": "zest src/stores --assert",
"test:generate:watch": "nodemon --watch src/stores --exec 'zest src/stores --assert'"
}
}In CI/CD (GitHub Actions example)
# .github/workflows/test-gen.yml
name: Generate Tests
on:
push:
paths:
- 'src/stores/**/*.ts'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '18' }
- run: npm ci
- run: npx @kyrwing/zest src/stores --assert
# Optional: verify generated tests haven't changed (catch forgotten commits)
- run: git diff --exit-code -- '*.test.ts' || echo "❌ Generated tests changed. Please commit them."In pre-commit hook (Husky)
# .husky/pre-commit
npx lint-staged// .lintstagedrc.js
module.exports = {
'src/stores/**/*.ts': (files) => {
const storeFiles = files.join(' ');
return [`npx @kyrwing/zest ${storeFiles} --assert`];
},
};🔍 Troubleshooting
❌ Zustand create() not found
Cause: Parser didn't find a create() call in the file.
Fix:
- Ensure the file exports
create(...)fromzustand - Check for typos:
createContext≠create - If using
createfrom another package, add alias intsconfig.json
❌ Store object not found
Cause: No state object found inside create().
Fix:
- Ensure
create()receives an object or function returning an object - Middleware (
persist,devtools) is supported via recursive search, but complex custom wrappers may need parser updates
❌ Inaccurate mocks ("string" instead of "", "boolean" instead of false)
Cause: Type inference couldn't determine a concrete value.
Fix:
- Initialize fields with explicit literals:
token: nullinstead oftoken: undefined as string | null - For complex types, use
as constor concrete values
❌ Cannot find module '@testing-library/react' when running tsc
Cause: This is a dependency of your project, not zest.
Fix:
npm install -D @testing-library/react @types/reactOr ignore during validation: tsc --skipLibCheck
❌ Generated test fails at runtime
Cause: jest.fn() mocks aren't configured, or hook requires context.
Fix:
- Add
mockImplementationmanually in your test:beforeEach(() => { (useAuthStore().login as jest.Mock).mockResolvedValue(undefined); }); - For context-dependent hooks, wrap
renderHookin awrapper
👨💻 API
parseStore(filePath: string): ParseResult
Parses a store file and returns its structure.
// src/parser/storeParser.ts
export interface ParseResult {
storeName: string; // Hook name: 'useAuthStore'
properties: StoreProperty[]; // Array of fields
}
export interface StoreProperty {
key: string; // 'token', 'login'
isAction: boolean; // true for functions
typeString: string; // 'string | null', 'function'
mockValue: string; // 'null', 'jest.fn()'
actionParams?: ActionParam[]; // 🆕 v0.6.0: action parameters
}
export interface ActionParam {
name: string; // 'roomId'
typeString: string; // 'number'
mockValue: string; // '0'
}generateTestFile(props, opts): string
Generates test code.
// src/generator/testGenerator.ts
export interface GeneratorOptions {
framework: 'jest' | 'vitest'; // Framework for mocks
assert: boolean; // Whether to generate assertions
storeName?: string; // Store name (for dynamic substitution)
}loadConfig(cwd: string): ZestConfig
Loads configuration from .zestrc.js.
// src/config/loadConfig.ts
export interface ZestConfig {
framework?: 'jest' | 'vitest' | 'auto';
assert?: boolean;
output?: string; // Supports [name] placeholder
ignoreMiddleware?: string[]; // Middleware to skip during parsing
}🤝 Contributing
# 1. Fork the repository
# 2. Clone and install dependencies
git clone https://github.com/your-username/zest.git
cd zest && npm install
# 3. Create a feature branch
git checkout -b feat/your-feature
# 4. Make changes and test locally
npm run build
npx ts-node src/cli/index.ts example/store.ts --assert
# 5. Open a Pull RequestLocal Development
# Run CLI without building
npm run dev -- <args>
# Build to dist/
npm run build
# Run parser tests
npm test📜 License
MIT © Kirill Poluektov
💡 Pro Tip:
zestdoesn't replace manual test writing — it accelerates the start. The generated skeleton is 80% of the work. The remaining 20% (business logic, edge cases, API mocks) you write manually, focusing on value, not boilerplate.
Happy testing! 🧪✨
