ts-bdd
Version:
A TypeScript BDD testing framework with typed shared examples and state management
378 lines (290 loc) • 10.5 kB
Markdown
# ts-bdd
[](https://www.npmjs.com/package/ts-bdd)
[](https://www.npmjs.com/package/ts-bdd)
A type-safe BDD testing library for TypeScript that provides lazy variable definitions, shared examples, and subjects with full async support.
## Installation
```bash
npm install ts-bdd
```
```bash
yarn add ts-bdd
```
## Breaking 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:
```typescript
// 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
```typescript
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):
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
// ❌ 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:
```typescript
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:
```typescript
// 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()
```typescript
const [user, posts, config] = get('userData', 'posts', 'config');
```
### Shared Examples with Arguments and Inheritance
```typescript
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:
```typescript
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 variables
- `options.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:
```typescript
// 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 values
- `set`: Function to override state values
- `context`: Function to create nested contexts
- `itBehavesLike`: Function to include shared examples (only in shared examples)
- `subject`: Function to define/get non-caching factories
- `sharedExamplesBuilder`: 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