feat(tapbundle): Add global postTask (teardown) and suite lifecycle hooks (beforeAll/afterAll) to tapbundle

This commit is contained in:
2025-11-20 18:22:54 +00:00
parent 8fd114334f
commit ca08bb2e3c
8 changed files with 873 additions and 246 deletions

View File

@@ -91,6 +91,24 @@ tap.testParallel('should fetch user data', async () => {
});
```
**Note:** The `tap.parallel().test()` fluent API is now the recommended way to define parallel tests (see Fluent API section below).
#### `tap.parallel()`
Returns a fluent test builder configured for parallel execution.
```typescript
tap.parallel().test('should fetch data', async () => {
// Parallel test
});
// With full configuration
tap.parallel()
.tags('api')
.retry(2)
.test('configured parallel test', async () => {});
```
#### `tap.describe(description, suiteFunction)`
Create a test suite to group related tests.
@@ -141,22 +159,56 @@ tap
});
```
#### Parallel Tests with Fluent API
Use `tap.parallel()` to create parallel tests with fluent configuration:
```typescript
// Simple parallel test
tap.parallel().test('fetches user data', async () => {
// Runs in parallel with other parallel tests
});
// Parallel test with full configuration
tap
.parallel()
.tags('api', 'integration')
.retry(2)
.timeout(5000)
.test('should fetch data concurrently', async () => {
// Configured parallel test
});
```
**Note:** `tap.parallel().test()` is the recommended way to define parallel tests. The older `tap.testParallel()` method is still supported for backward compatibility.
### Lifecycle Hooks
#### Suite-Level Hooks
```typescript
tap.describe('Database Tests', () => {
tap.beforeAll(async (tapTools) => {
// Runs once before all tests in this suite
await initializeDatabaseConnection();
});
tap.beforeEach(async (tapTools) => {
// Runs before each test in this suite
await clearTestData();
});
tap.test('test 1', async () => { });
tap.test('test 2', async () => { });
tap.afterEach(async (tapTools) => {
// Runs after each test in this suite
});
tap.test('test 1', async () => { });
tap.test('test 2', async () => { });
tap.afterAll(async (tapTools) => {
// Runs once after all tests in this suite
await closeDatabaseConnection();
});
});
```
@@ -267,38 +319,169 @@ TSTEST_FILTER_TAGS=unit tstest test/mytest.node.ts
Each test receives a `tapTools` instance with utilities:
#### Test Control Methods
```typescript
tap.test('should have utilities', async (tapTools) => {
// Mark test as skipped
tap.test('test control examples', async (tapTools) => {
// Skip this test
tapTools.skip('reason');
// Conditionally skip
tapTools.skipIf(condition, 'reason');
// Mark test as skipped before execution
tapTools.markAsSkipped('reason');
// Mark as todo
tapTools.todo('not implemented');
// Allow test to fail without marking suite as failed
tapTools.allowFailure();
// Configure retries
tapTools.retry(3);
// Log test output
tapTools.log('debug message');
// Set timeout
tapTools.timeout(5000);
});
```
#### Utility Methods
```typescript
tap.test('utility examples', async (tapTools) => {
// Delay execution
await tapTools.delayFor(1000); // Wait 1 second
await tapTools.delayForRandom(500, 1500); // Random delay
// Colored console output
tapTools.coloredString('✓ Success', 'green');
tapTools.coloredString('✗ Error', 'red');
});
```
#### Context and Data Sharing
```typescript
tap.test('first test', async (tapTools) => {
// Store data in context
tapTools.context.set('userId', '12345');
// Store in testData property
tapTools.testData = { username: 'alice' };
});
tap.test('second test', async (tapTools) => {
// Retrieve from context
const userId = tapTools.context.get('userId');
// Check existence
if (tapTools.context.has('userId')) {
// Use data
}
// Clear context
tapTools.context.clear();
});
```
#### Fixtures
```typescript
// Define a fixture globally (outside tests)
import { TapTools } from '@git.zone/tstest/tapbundle';
TapTools.defineFixture('database', async () => {
const db = await createTestDatabase();
return {
value: db,
cleanup: async () => await db.close()
};
});
// Use fixtures in tests
tap.test('database test', async (tapTools) => {
const db = await tapTools.fixture('database');
// Use db...
// Cleanup happens automatically
});
```
#### Factory Pattern
```typescript
// Define a factory
TapTools.defineFixture('user', async () => {
return {
value: null, // Not used for factories
factory: async (data) => {
return await createUser(data);
},
cleanup: async (user) => await user.delete()
};
});
// Use factory in tests
tap.test('user test', async (tapTools) => {
const user = await tapTools.factory('user').create({ name: 'Alice' });
// Create multiple
const users = await tapTools.factory('user').createMany([
{ name: 'Alice' },
{ name: 'Bob' }
]);
// Cleanup happens automatically
});
```
#### Snapshot Testing
```typescript
tap.test('snapshot test', async (tapTools) => {
const result = { name: 'Alice', age: 30 };
// Compare with stored snapshot
await tapTools.matchSnapshot(result);
// Named snapshots
await tapTools.matchSnapshot(result, 'user-data');
});
```
To update snapshots, run with:
```bash
UPDATE_SNAPSHOTS=true tstest test/mytest.ts
```
## Advanced Features
### Pre-Tasks
### Pre-Tasks and Post-Tasks
Run setup tasks before any tests execute:
Run setup and teardown tasks before/after all tests:
```typescript
tap.preTask('setup database', async () => {
// Runs before any tests
await initializeDatabase();
});
tap.test('first test', async () => {
// Database is ready
});
tap.test('second test', async () => {
// Tests run...
});
tap.postTask('cleanup database', async () => {
// Runs after all tests complete
await cleanupDatabase();
});
```
**Note:** Post tasks run after all tests but before the global `afterAll` hook.
### Test Priority
Organize tests by priority level:
@@ -334,6 +517,50 @@ import { setProtocolEmitter } from '@git.zone/tstest/tapbundle';
// Events: test:started, test:completed, assertion:failed, suite:started, suite:completed
```
### Additional Tap Methods
#### Configuration and Inspection
```typescript
// Get current test settings
const settings = tap.getSettings();
console.log(settings.timeout, settings.retries);
// Explicitly fail a test
tap.test('validation test', async () => {
if (invalidCondition) {
tap.fail('Custom failure message');
}
});
```
#### Advanced Control
```typescript
// Force stop test execution
tap.stopForcefully(exitCode, immediate);
// Handle thrown errors (internal use)
tap.threw(error);
```
#### Parallel Test Variants
In addition to `tap.parallel().test()`, skip/only/todo modes also support parallel execution:
```typescript
// Skip parallel test
tap.skip.testParallel('not ready', async () => {});
// Only run this parallel test
tap.only.testParallel('focus here', async () => {});
// Todo parallel test
tap.todo.testParallel('implement later');
```
**Note:** Using `tap.parallel()` fluent API is recommended over these direct methods.
## Best Practices
1. **Always export `tap.start()`** at the end of test files:

View File

@@ -0,0 +1,21 @@
import * as plugins from './tapbundle.plugins.js';
import { TapTools } from './tapbundle.classes.taptools.js';
export interface IPostTaskFunction {
(tapTools?: TapTools): Promise<any>;
}
export class PostTask {
public description: string;
public postTaskFunction: IPostTaskFunction;
constructor(descriptionArg: string, postTaskFunctionArg: IPostTaskFunction) {
this.description = descriptionArg;
this.postTaskFunction = postTaskFunctionArg;
}
public async run() {
console.log(`::__POSTTASK: ${this.description}`);
await this.postTaskFunction(new TapTools(null));
}
}

View File

@@ -1,6 +1,7 @@
import * as plugins from './tapbundle.plugins.js';
import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js';
import { type IPostTaskFunction, PostTask } from './tapbundle.classes.posttask.js';
import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
import type { ITapSettings } from './tapbundle.interfaces.js';
@@ -9,6 +10,8 @@ import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
export interface ITestSuite {
description: string;
tests: TapTest<any>[];
beforeAll?: ITestFunction<any>;
afterAll?: ITestFunction<any>;
beforeEach?: ITestFunction<any>;
afterEach?: ITestFunction<any>;
parent?: ITestSuite;
@@ -21,85 +24,89 @@ class TestBuilder<T> {
private _priority: 'high' | 'medium' | 'low' = 'medium';
private _retryCount?: number;
private _timeoutMs?: number;
constructor(tap: Tap<T>) {
private _parallel: boolean = false;
constructor(tap: Tap<T>, parallel: boolean = false) {
this._tap = tap;
this._parallel = parallel;
}
tags(...tags: string[]) {
this._tags = tags;
return this;
}
priority(level: 'high' | 'medium' | 'low') {
this._priority = level;
return this;
}
retry(count: number) {
this._retryCount = count;
return this;
}
timeout(ms: number) {
this._timeoutMs = ms;
return this;
}
test(description: string, testFunction: ITestFunction<T>) {
const test = this._tap.test(description, testFunction, 'normal');
const test = this._parallel
? this._tap.testParallel(description, testFunction)
: this._tap.test(description, testFunction, 'normal');
// Apply settings to the test
if (this._tags.length > 0) {
test.tags = this._tags;
}
test.priority = this._priority;
if (this._retryCount !== undefined) {
test.tapTools.retry(this._retryCount);
}
if (this._timeoutMs !== undefined) {
test.timeoutMs = this._timeoutMs;
}
return test;
}
testOnly(description: string, testFunction: ITestFunction<T>) {
const test = this._tap.test(description, testFunction, 'only');
// Apply settings to the test
if (this._tags.length > 0) {
test.tags = this._tags;
}
test.priority = this._priority;
if (this._retryCount !== undefined) {
test.tapTools.retry(this._retryCount);
}
if (this._timeoutMs !== undefined) {
test.timeoutMs = this._timeoutMs;
}
return test;
}
testSkip(description: string, testFunction: ITestFunction<T>) {
const test = this._tap.test(description, testFunction, 'skip');
// Apply settings to the test
if (this._tags.length > 0) {
test.tags = this._tags;
}
test.priority = this._priority;
if (this._retryCount !== undefined) {
test.tapTools.retry(this._retryCount);
}
if (this._timeoutMs !== undefined) {
test.timeoutMs = this._timeoutMs;
}
return test;
}
}
@@ -122,21 +129,25 @@ export class Tap<T> {
const builder = new TestBuilder<T>(this);
return builder.tags(...tags);
}
public priority(level: 'high' | 'medium' | 'low') {
const builder = new TestBuilder<T>(this);
return builder.priority(level);
}
public retry(count: number) {
const builder = new TestBuilder<T>(this);
return builder.retry(count);
}
public timeout(ms: number) {
const builder = new TestBuilder<T>(this);
return builder.timeout(ms);
}
public parallel() {
return new TestBuilder<T>(this, true);
}
/**
* skips a test
@@ -236,6 +247,7 @@ export class Tap<T> {
};
private _tapPreTasks: PreTask[] = [];
private _tapPostTasks: PostTask[] = [];
private _tapTests: TapTest<any>[] = [];
private _tapTestsOnly: TapTest<any>[] = [];
private _currentSuite: ITestSuite | null = null;
@@ -304,18 +316,22 @@ export class Tap<T> {
this._tapPreTasks.push(new PreTask(descriptionArg, functionArg));
}
public postTask(descriptionArg: string, functionArg: IPostTaskFunction) {
this._tapPostTasks.push(new PostTask(descriptionArg, functionArg));
}
/**
* A parallel test that will not be waited for before the next starts.
* @param testDescription - A description of what the test does
* @param testFunction - A Function that returns a Promise and resolves or rejects
*/
public testParallel(testDescription: string, testFunction: ITestFunction<T>) {
public testParallel(testDescription: string, testFunction: ITestFunction<T>): TapTest<T> {
const localTest = new TapTest({
description: testDescription,
testFunction,
parallel: true,
});
// Apply default settings from settings manager
const settings = this.settingsManager.getSettings();
if (settings.timeout !== undefined) {
@@ -324,12 +340,14 @@ export class Tap<T> {
if (settings.retries !== undefined) {
localTest.tapTools.retry(settings.retries);
}
if (this._currentSuite) {
this._currentSuite.tests.push(localTest);
} else {
this._tapTests.push(localTest);
}
return localTest;
}
/**
@@ -360,6 +378,28 @@ export class Tap<T> {
}
}
/**
* Set up a function to run once before all tests in the current suite
*/
public beforeAll(setupFunction: ITestFunction<any>) {
if (this._currentSuite) {
this._currentSuite.beforeAll = setupFunction;
} else {
throw new Error('beforeAll can only be used inside a describe block');
}
}
/**
* Set up a function to run once after all tests in the current suite
*/
public afterAll(teardownFunction: ITestFunction<any>) {
if (this._currentSuite) {
this._currentSuite.afterAll = teardownFunction;
} else {
throw new Error('afterAll can only be used inside a describe block');
}
}
/**
* Set up a function to run before each test in the current suite
*/
@@ -370,7 +410,7 @@ export class Tap<T> {
throw new Error('beforeEach can only be used inside a describe block');
}
}
/**
* Set up a function to run after each test in the current suite
*/
@@ -554,6 +594,11 @@ export class Tap<T> {
console.log(failReason);
}
// Run post tasks
for (const postTask of this._tapPostTasks) {
await postTask.run();
}
// Run global afterAll hook if configured
if (settings.afterAll) {
try {
@@ -597,6 +642,12 @@ export class Tap<T> {
suiteName: suite.description
}
});
// Run beforeAll hook for this suite
if (suite.beforeAll) {
await suite.beforeAll(new plugins.smartpromise.Deferred().promise as any);
}
// Run beforeEach from parent suites
const beforeEachFunctions: ITestFunction<any>[] = [];
let currentSuite: ITestSuite | null = suite;
@@ -666,7 +717,12 @@ export class Tap<T> {
// Recursively run child suites
await this._runSuite(suite, suite.children, promiseArray, context);
// Run afterAll hook for this suite
if (suite.afterAll) {
await suite.afterAll(new plugins.smartpromise.Deferred().promise as any);
}
// Emit suite:completed event
this.emitEvent({
eventType: 'suite:completed',