UNPKG

ts-bdd

Version:

A TypeScript BDD testing framework with typed shared examples and state management

378 lines (290 loc) 10.5 kB
# ts-bdd [![npm version](https://img.shields.io/npm/v/ts-bdd.svg)](https://www.npmjs.com/package/ts-bdd) [![npm downloads](https://img.shields.io/npm/dm/ts-bdd.svg)](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