ts-bdd
v1.1.2
Published
A TypeScript BDD testing framework with typed shared examples and state management
Maintainers
Readme
ts-bdd
A type-safe BDD testing library for TypeScript that provides lazy variable definitions, shared examples, and subjects with full async support.
Installation
npm install ts-bddyarn add ts-bddBreaking Changes in v2.0.0
⚠️ Breaking Change: The it function is no longer provided by the TestRunner interface or passed to suite callbacks.
Migration: Import it (and other test functions) directly from your test framework:
// Before v2.0.0
const runner = { describe, it, beforeEach };
suite.describe('Tests', ({ get, set, it }) => {
// Used 'it' from suite callback
});
// v2.0.0+
import { it } from 'vitest'; // or jest, etc.
const runner = { describe, beforeEach }; // No 'it' needed
suite.describe('Tests', ({ get, set }) => {
it('works', () => {
/* test */
}); // Use imported 'it'
});This change simplifies the API and eliminates unnecessary dependency injection for functions that weren't used internally.
Features
- Type-safe: Full TypeScript support with proper type inference
- Lazy definitions: Variables computed on-demand with caching
- Shared examples: Reusable test behaviors with type safety
- Subjects: Non-caching factories for testing side effects
- Async support: Full support for async operations
- Framework agnostic: Works with any test runner (vitest, jest, etc.)
Basic Usage
import { createSuite } from 'ts-bdd';
import { describe, it, beforeEach } from 'vitest';
interface AppState {
config: { apiUrl: string; timeout: number };
client: HttpClient;
}
const suite = createSuite({
definitions: {
config: { apiUrl: 'https://api.example.com', timeout: 5000 },
client: (get) => new HttpClient(get('config')),
},
runner: { describe, beforeEach }, // Note: 'it' is imported directly above
});
suite.describe('API Client', ({ get, set, context }) => {
it('should create client with config', () => {
const client = get('client');
expect(client.timeout).toBe(5000);
});
context('with custom timeout', () => {
set('config', { apiUrl: 'https://api.example.com', timeout: 10000 });
it('should use custom timeout', () => {
const client = get('client');
expect(client.timeout).toBe(10000);
});
});
});Async Support
The library provides comprehensive async support:
1. Async Test Functions
Test functions can be async (this is standard vitest/jest behavior):
suite.describe('Async Tests', ({ get }) => {
it('should handle async operations', async () => {
const result = await someAsyncOperation();
expect(result).toBe('success');
});
});2. Async Lazy Definitions
Lazy definitions can be async functions that return promises:
interface AsyncState {
userId: number;
userData: Promise<User>; // Note: Promise type in interface
posts: Promise<Post[]>;
}
const suite = createSuite({
definitions: {
userId: 42,
userData: async (get) => {
const id = get('userId');
return await fetchUser(id); // Returns Promise<User>
},
posts: async (get) => {
const id = get('userId');
return await fetchUserPosts(id); // Returns Promise<Post[]>
},
},
runner,
});
suite.describe('Async Data', ({ get }) => {
it('should fetch user data', async () => {
const userData = await get('userData');
expect(userData.name).toBeTruthy();
});
it('should cache async promises', async () => {
const promise1 = get('userData');
const promise2 = get('userData');
// Same promise instance is returned (cached)
expect(promise1).toBe(promise2);
const [user1, user2] = await Promise.all([promise1, promise2]);
expect(user1).toEqual(user2);
});
});3. Async Subjects
Subjects can have async factories:
suite.describe('Async Subjects', ({ get, subject }) => {
it('should handle async subject factories', async () => {
subject(async () => {
const userData = await get('userData');
return { processedUser: userData.name, timestamp: Date.now() };
});
const result1 = await subject();
expect(result1.processedUser).toBeTruthy();
// Subject executes factory each time (no caching)
await new Promise((resolve) => setTimeout(resolve, 1));
const result2 = await subject();
expect(result2.timestamp).toBeGreaterThan(result1.timestamp);
});
});4. Async Shared Examples
Shared example functions can be async and are created using the builder:
suite.describe(
'Async Shared Examples',
({ get, set, context, sharedExamplesBuilder }) => {
const itBehavesLike = sharedExamplesBuilder
.add('async validation', ({ subject }) => {
it('should validate async data', async () => {
const userData = await get('userData'); // Access outer scope get
set('validationResult', { isValid: true, user: userData }); // Access outer scope set
expect(userData.id).toBeGreaterThan(0);
expect(userData.name).toBeTruthy();
});
})
.build();
context('with async data', () => {
itBehavesLike('async validation');
});
},
);Important Notes on Async Support
Suite Callbacks Must Be Synchronous
While test functions, lazy definitions, subjects, and shared examples can be async, the main suite callback passed to describe() must be synchronous. This is a limitation of most test runners:
// ❌ This won't work - test runner expects synchronous callback
suite.describe('Suite', async ({ get, it }) => {
await someSetup(); // This won't be awaited properly
it('test', () => {
/* ... */
});
});
// ✅ This works - async operations inside test functions
suite.describe('Suite', ({ get, it }) => {
it('test with async operations', async () => {
await someSetup(); // This works fine
const result = await get('asyncData');
expect(result).toBeTruthy();
});
});Context Callbacks Are Also Synchronous
Similar to suite callbacks, context callbacks must be synchronous:
context('async context', () => {
// Synchronous setup only
it('async test', async () => {
// Async operations work here
const result = await get('asyncData'); // Access outer scope get
expect(result).toBeTruthy();
});
});Type Inference with Async Definitions
When using async lazy definitions, you may need to explicitly type your state interface:
// Define the resolved types, not the Promise types
interface MyState {
userData: Promise<User>; // The actual type returned by get()
}
// Or use type assertions in tests
const userData = (await get('userData')) as User;Advanced Features
Multi-argument get()
const [user, posts, config] = get('userData', 'posts', 'config');Shared Examples with Arguments and Inheritance
import { createSuite, SharedExamplesBuilder } from 'ts-bdd';
// Create shared examples using the builder pattern
suite.describe(
'Shared Examples Demo',
({ get, set, context, subject, sharedExamplesBuilder }) => {
const itBehavesLike = sharedExamplesBuilder
.add('basic validation', ({ subject }) => {
it('should be valid', () => {
expect(subject()).toBeTruthy();
});
})
.add('validation with argument', (expectedValue: string, { subject }) => {
it(`should equal ${expectedValue}`, () => {
expect(subject()).toBe(expectedValue);
});
})
.add('extended validation', ({ subject, itBehavesLike }) => {
itBehavesLike('basic validation'); // Inherit behavior
it('should have additional properties', () => {
expect(subject().extra).toBeDefined();
});
})
.build();
// Use shared examples
context('with valid data', () => {
subject(() => ({ value: 'test', extra: true }));
itBehavesLike('basic validation');
itBehavesLike('validation with argument', 'test');
itBehavesLike('extended validation');
});
},
);Subjects for Side Effects
Subjects are perfect for testing operations with side effects since they don't cache results:
it('should handle side effects', async () => {
let counter = 0;
subject(async () => {
counter++;
const data = await get('userData');
return { count: counter, user: data.name };
});
const result1 = await subject();
const result2 = await subject();
expect(result1.count).toBe(1);
expect(result2.count).toBe(2); // Factory executed again
});API Reference
createSuite<TState>(options)
Creates a new test suite builder.
Parameters:
options.definitions: Object defining the state variablesoptions.runner: Test runner interface ({ describe, beforeEach })
Returns: SuiteBuilder<TState>
SharedExamplesBuilder<TState>
Available within suite callbacks via the sharedExamplesBuilder parameter. Use the builder pattern to define reusable test behaviors:
// Import 'it' directly from your test framework
import { it } from 'vitest';
suite.describe('Test Suite', ({ sharedExamplesBuilder }) => {
const itBehavesLike = sharedExamplesBuilder
.add('behavior name', (optionalArg, { subject, itBehavesLike }) => {
it('should behave correctly', () => {
// Define shared behavior using imported 'it'
// Access outer scope functions like get(), set() when needed
});
})
.build();
});Suite Callback Parameters
get: Function to retrieve state valuesset: Function to override state valuescontext: Function to create nested contextsitBehavesLike: Function to include shared examples (only in shared examples)subject: Function to define/get non-caching factoriessharedExamplesBuilder: Builder for creating typed shared examples
Note: Import test functions like it, expect, etc. directly from your test framework (vitest, jest, etc.)
Repository
- GitHub: https://github.com/amirketter/ts-bdd
- npm: https://www.npmjs.com/package/ts-bdd
- Issues: https://github.com/amirketter/ts-bdd/issues
License
ISC
