Skip to content

Commit e57f21c

Browse files
authored
Merge pull request #48 from AsenaJs/feature-asena-test
Feature: Asena test utilities
2 parents 04c6219 + 765b76f commit e57f21c

File tree

10 files changed

+761
-6
lines changed

10 files changed

+761
-6
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @asenajs/asena
22

3+
## 0.6.2
4+
5+
### Patch Changes
6+
7+
- Added testing utilities with `mockComponent` and `mockComponentAsync` functions for automated dependency mocking in component tests.
8+
39
## 0.6.1
410

511
### Patch Changes

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# Asena
66

7-
[![Version](https://img.shields.io/badge/version-0.6.1-blue.svg)](https://asena.sh)
7+
[![Version](https://img.shields.io/badge/version-0.6.2-blue.svg)](https://asena.sh)
88
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
99
[![Bun Version](https://img.shields.io/badge/Bun-1.3.2%2B-blueviolet)](https://bun.sh)
1010

bun.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/test/factory/mockFactory.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { mock } from 'bun:test';
2+
3+
/**
4+
* Detects all methods in a class (excluding constructor)
5+
*
6+
* @param classType - Class to inspect
7+
* @returns Array of method names
8+
*
9+
* @internal
10+
*/
11+
function detectClassMethods(classType: any): string[] {
12+
if (!classType?.prototype) {
13+
return [];
14+
}
15+
16+
const methods: string[] = [];
17+
const prototype = classType.prototype;
18+
19+
// Get all property names from prototype
20+
const propertyNames = Object.getOwnPropertyNames(prototype);
21+
22+
for (const name of propertyNames) {
23+
// Skip constructor
24+
if (name === 'constructor') {
25+
continue;
26+
}
27+
28+
// Check if it's a function
29+
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
30+
if (descriptor && typeof descriptor.value === 'function') {
31+
methods.push(name);
32+
}
33+
}
34+
35+
return methods;
36+
}
37+
38+
/**
39+
* Checks if a value is mockable (is a class with prototype)
40+
*
41+
* @param classType - Value to check
42+
* @returns True if value is a class
43+
*
44+
* @internal
45+
*/
46+
function isMockable(classType: any): boolean {
47+
return (
48+
classType !== null &&
49+
classType !== undefined &&
50+
typeof classType === 'function' &&
51+
classType.prototype !== undefined
52+
);
53+
}
54+
55+
/**
56+
* Creates a mock object from a class type
57+
* Automatically generates Bun mock functions for all class methods
58+
*
59+
* @param classType - Class to create mock from
60+
* @returns Mock object with all methods mocked
61+
*
62+
* @example
63+
* ```typescript
64+
* const UserServiceMock = createMockFromClass(UserService);
65+
* // Returns:
66+
* // {
67+
* // createUser: mock(async () => null),
68+
* // findByEmail: mock(async () => null),
69+
* // findById: mock(async () => null),
70+
* // }
71+
*
72+
* // Can configure mocks:
73+
* UserServiceMock.createUser.mockResolvedValue({ id: '123', name: 'John' });
74+
* ```
75+
*/
76+
export function createMockFromClass(classType: any): any {
77+
// Handle non-mockable types - return empty object as placeholder
78+
if (!isMockable(classType)) {
79+
return {};
80+
}
81+
82+
// Detect all methods in the class
83+
const methods = detectClassMethods(classType);
84+
85+
// Create mock object
86+
const mockObject: Record<string, any> = {};
87+
88+
// Create Bun mock for each method
89+
for (const methodName of methods) {
90+
// Check if method is async by inspecting the original
91+
const originalMethod = classType.prototype[methodName];
92+
const isAsync = originalMethod?.constructor?.name === 'AsyncFunction';
93+
94+
// Create mock with appropriate default return value
95+
if (isAsync) {
96+
// Async methods return Promise.resolve(null) by default
97+
mockObject[methodName] = mock(async () => null);
98+
} else {
99+
// Sync methods return undefined by default
100+
mockObject[methodName] = mock(() => undefined);
101+
}
102+
}
103+
104+
return mockObject;
105+
}
106+
107+
/**
108+
* Creates a simple spy that tracks calls without mocking behavior
109+
* Useful for partial mocking scenarios
110+
*
111+
* @returns Mock function that returns undefined
112+
*
113+
* @internal
114+
*/
115+
export function createSimpleSpy(): any {
116+
return mock(() => undefined);
117+
}

lib/test/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { mockComponent, mockComponentAsync } from './mockComponent';
2+
export type { MockComponentOptions, MockedComponent } from './types';
3+
export { createMockFromClass } from './factory/mockFactory';

lib/test/metadata/discovery.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { Dependencies, Expressions } from '../../ioc';
2+
import type { FieldMetadata } from '../types';
3+
import { ComponentConstants } from '../../ioc';
4+
import { getTypedMetadata, getOwnTypedMetadata } from '../../utils';
5+
6+
/**
7+
* Discovers all fields with @Inject decorator in a component
8+
* Traverses prototype chain to support inheritance
9+
*
10+
* @param instance - Component instance to inspect
11+
* @returns Array of field metadata containing field names, service names, and expressions
12+
*
13+
* @example
14+
* ```typescript
15+
* const fields = discoverInjectedFields(new AuthService());
16+
* // Returns: [
17+
* // { fieldName: 'userService', serviceName: 'UserService', expression: undefined },
18+
* // { fieldName: 'loginService', serviceName: 'LoginService', expression: (s) => s.login() }
19+
* // ]
20+
* ```
21+
*/
22+
export function discoverInjectedFields(instance: any): FieldMetadata[] {
23+
const fields: FieldMetadata[] = [];
24+
const processedFields = new Set<string>();
25+
26+
// Traverse prototype chain (similar to how Container does it)
27+
let currentPrototype = Object.getPrototypeOf(instance);
28+
29+
while (currentPrototype && currentPrototype !== Object.prototype) {
30+
const constructor = currentPrototype.constructor;
31+
32+
// Get dependencies metadata for this level
33+
const dependencies = getOwnTypedMetadata<Dependencies>(ComponentConstants.DependencyKey, constructor);
34+
35+
// Get expressions metadata for this level
36+
const expressions = getOwnTypedMetadata<Expressions>(ComponentConstants.ExpressionKey, constructor);
37+
38+
if (dependencies) {
39+
// Process each dependency field
40+
for (const [fieldName, serviceName] of Object.entries(dependencies)) {
41+
// Skip if already processed (child class overrides)
42+
if (processedFields.has(fieldName)) {
43+
continue;
44+
}
45+
46+
processedFields.add(fieldName);
47+
48+
fields.push({
49+
fieldName,
50+
serviceName,
51+
expression: expressions?.[fieldName],
52+
});
53+
}
54+
}
55+
56+
// Move up the prototype chain
57+
currentPrototype = Object.getPrototypeOf(currentPrototype);
58+
}
59+
60+
return fields;
61+
}
62+
63+
/**
64+
* Checks if a component has any injected dependencies
65+
*
66+
* @param ComponentClass - Component class to check
67+
* @returns True if component has @Inject decorated fields
68+
*
69+
* @example
70+
* ```typescript
71+
* if (hasInjectedFields(AuthService)) {
72+
* console.log('AuthService has dependencies');
73+
* }
74+
* ```
75+
*/
76+
export function hasInjectedFields(ComponentClass: new (...args: any[]) => any): boolean {
77+
const dependencies = getTypedMetadata<Dependencies>(ComponentConstants.DependencyKey, ComponentClass);
78+
return dependencies !== undefined && Object.keys(dependencies).length > 0;
79+
}
80+
81+
/**
82+
* Gets the service name for a specific field
83+
*
84+
* @param ComponentClass - Component class
85+
* @param fieldName - Name of the field
86+
* @returns Service name if field is injected, undefined otherwise
87+
*
88+
* @internal
89+
*/
90+
export function getFieldServiceName(
91+
ComponentClass: new (...args: any[]) => any,
92+
fieldName: string,
93+
): string | undefined {
94+
const dependencies = getTypedMetadata<Dependencies>(ComponentConstants.DependencyKey, ComponentClass);
95+
return dependencies?.[fieldName];
96+
}
97+
98+
/**
99+
* Gets the expression function for a specific field
100+
*
101+
* @param ComponentClass - Component class
102+
* @param fieldName - Name of the field
103+
* @returns Expression function if defined, undefined otherwise
104+
*
105+
* @internal
106+
*/
107+
export function getFieldExpression(
108+
ComponentClass: new (...args: any[]) => any,
109+
fieldName: string,
110+
): ((service: any) => any) | undefined {
111+
const expressions = getTypedMetadata<Expressions>(ComponentConstants.ExpressionKey, ComponentClass);
112+
return expressions?.[fieldName];
113+
}

0 commit comments

Comments
 (0)