| name | manage-entity-tests |
| description | Create or update test files for TypeScript classes that implement interfaces. Use when user asks to "create tests", "update tests", "generate test file", "fix tests", "test MyClass", or mentions needing tests for a class. Generates vitest test files with describe blocks, createClass helpers, and proper fake dependency injection. |
Manage Entity Tests Skill
This skill helps you create or update test files for classes that implement interfaces. Tests follow a specific pattern with vitest, fake builders for dependencies, and structured describe blocks.
When to Use This Skill
Use this skill when you need to:
- Create a new test file for a class
- Update an existing test file when the class or interface changes
- Generate test coverage for interface methods
The skill will generate/update:
- A test file with vitest imports and describe blocks
- A
createMyClasshelper function for dependency injection - Individual describe blocks for each interface method
- Test cases for different method behaviors and argument permutations
Usage
Invoke this skill when the user asks to:
- "Create tests for [ClassName]"
- "Generate a test file for [ClassName]"
- "I need tests for [ClassName]"
- "Update tests for [ClassName]"
- "The [ClassName] changed, update its tests"
- "Fix the tests for [ClassName]"
- "Test [ClassName]"
Core Principles
Testing Philosophy
- Interface-Driven: Only test methods defined in the class's interface, not implementation-specific private methods
- Dependency Injection: Use a
createMyClasshelper to handle dependency creation and overrides - Fake Builders: Use Fake builders from dependencies, avoid
mockReturnValuewhen possible - Single Responsibility: Each
itblock should test one specific scenario with ideally one assertion - Avoid vi.mock: Only use
vi.mockfor 3rd party dependencies, use Fakes for internal dependencies
Class Types Supported
This skill supports testing two types of classes:
Entity Classes
Entity classes that implement interfaces with event brokers (IEntity pattern):
- Have event broker properties (e.g.,
name: IPropEventBroker<string>) - Follow the ZDR entities pattern
- Focus tests on event broker updates and entity state
Service/Client Classes
Service or client classes that wrap dependencies (e.g., HTTP clients, APIs):
- Take dependencies through constructor injection
- Focus tests on method calls and return values
- Test data transformations (e.g., DTO conversions)
- Verify correct parameters are passed to dependencies
Key Difference for Service/Client Classes:
- Test that methods call dependencies with correct parameters
- Test return value transformations
- Test different response scenarios (success, error, empty data)
- Use backend DTOs in test data to verify transformations
Prerequisites
Before creating/updating tests:
- Verify the class and interface exist - The class you're testing must already be defined
- Check for existing test file - Use Glob to search for existing
.spec.tsfile - Identify the interface - Determine which interface the class implements
- Locate dependencies - Identify all dependencies and their available Fake builders
Create vs Update Decision
If test file exists: Update mode
- Read the existing test file
- Read the class and interface definitions
- Compare and identify what's missing or outdated
- Update the test to match current implementation
If test file does NOT exist: Create mode
- Read the class and interface definitions
- Identify all dependencies
- Generate complete test file from scratch
Test File Location
CRITICAL: Test files MUST be in the __tests__ folder, which is a SIBLING of the /src folder, NOT inside it.
Directory Structure
packages/
my-package/
src/
MyClass.ts
IMyClass.ts
__tests__/ # Sibling to src/, NOT inside src/
myClass.spec.ts # camelCase filename
otherClass.spec.ts
Naming Convention
For a class named MyClass:
- Test file:
myClass.spec.ts(camelCase, matches class name but lowercase first letter) - Located in:
packages/my-package/__tests__/myClass.spec.ts
Test File Structure
1. Imports
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { IMyClass } from '../src/IMyClass';
import { MyClass } from '../src/MyClass';
import { FakeOtherClassBuilder } from '@someScope/some-library/fakes';
import type { IOtherClass } from '@someScope/some-library';
Import Rules:
- Import vitest utilities from
'vitest' - Import the interface using
typeimport - Import the class being tested
- Import Fake builders from dependency packages using
/fakessubpath - Import dependency interfaces when needed for typing
2. Main Describe Block
describe('MyClass', () => {
// Helper function
function createMyClass(/* ... */): { /* ... */ } {
// ...
}
// Method describe blocks
describe('someMethod', () => {
// Test cases
});
describe('anotherMethod', () => {
// Test cases
});
});
Structure Rules:
- Main describe uses the class name
- Contains one
createMyClasshelper function at the top - One describe block per interface method
- No tests directly in main describe (only in method describes)
3. createMyClass Helper Function
Purpose: Factory function that creates an instance of the class with all its dependencies, allowing for easy overrides in tests.
Standard Pattern:
function createMyClass(overrides?: {
otherClass?: IOtherClass;
anotherService?: IAnotherService;
}) {
const otherClass = overrides?.otherClass ?? new FakeOtherClassBuilder().build();
const anotherService = overrides?.anotherService ?? new FakeAnotherServiceBuilder().build();
const myClass = new MyClass({
otherClass,
anotherService
});
return {
otherClass,
anotherService,
myClass
};
}
Rules:
- Accept an
overridesparameter (optional object) - One override parameter per dependency (typed with interface, not Fake)
- Use nullish coalescing (
??) to provide default Fake instances - Instantiate the class using all dependencies
- Return an object containing all dependencies AND the class instance
- Never instantiate the class directly in tests (always use this helper)
Advanced Pattern (with primitive parameters):
function createMyClass(
params?: {
initialValue?: string;
config?: { timeout: number };
},
overrides?: {
otherClass?: IOtherClass;
}
) {
const otherClass = overrides?.otherClass ?? new FakeOtherClassBuilder().build();
const myClass = new MyClass({
initialValue: params?.initialValue ?? 'default',
config: params?.config ?? { timeout: 1000 },
otherClass
});
return {
otherClass,
myClass
};
}
4. Method Describe Blocks
One describe per interface method:
describe('login', () => {
it('should return true when credentials are valid', () => {
const { myClass, authService } = createMyClass({
authService: new FakeAuthServiceBuilder()
.withValidateReturnValue(Promise.resolve(true))
.build()
});
const result = await myClass.login('user', 'pass');
expect(result).toBe(true);
});
it('should return false when credentials are invalid', () => {
const { myClass, authService } = createMyClass({
authService: new FakeAuthServiceBuilder()
.withValidateReturnValue(Promise.resolve(false))
.build()
});
const result = await myClass.login('user', 'wrong');
expect(result).toBe(false);
});
it('should call authService.validate with correct parameters', () => {
const { myClass, authService } = createMyClass();
await myClass.login('testuser', 'testpass');
expect(authService.validate).toHaveBeenCalledWith('testuser', 'testpass');
});
});
Rules for Test Cases:
- Create one
itblock per test scenario - Test different argument permutations
- Test different branches based on dependency behavior
- Test edge cases (null, undefined, empty strings, etc.)
- Use descriptive test names that explain the expected behavior
- Prefer single assertion per test when possible
5. Mocking Dependencies
Preferred Method (Fake Builders):
it('should handle success case', () => {
const { myClass } = createMyClass({
otherService: new FakeOtherServiceBuilder()
.withProcessReturnValue(Promise.resolve({ success: true }))
.withIsActiveValue(true)
.build()
});
// Test using the configured fake
});
Avoid (mockReturnValue) unless absolutely necessary:
// DON'T DO THIS unless impossible to avoid
it('should handle changing return values', () => {
const { myClass, otherService } = createMyClass();
// ONLY use this as a LAST RESORT
otherService.process.mockReturnValue(Promise.resolve({ success: false }));
// Test...
});
When mockReturnValue is Acceptable:
- Testing a sequence of calls with different return values
- Dynamic behavior that can't be pre-configured
- Testing error recovery where you need to simulate failures mid-test
6. Testing Method Calls
Good Pattern:
it('should call dependency method with correct arguments', () => {
const { myClass, otherService } = createMyClass();
myClass.processData({ id: '123', name: 'test' });
expect(otherService.process).toHaveBeenCalledWith({ id: '123', name: 'test' });
expect(otherService.process).toHaveBeenCalledTimes(1);
});
Testing with Multiple Calls:
it('should call dependency multiple times', () => {
const { myClass, otherService } = createMyClass();
myClass.processBatch([item1, item2, item3]);
expect(otherService.process).toHaveBeenCalledTimes(3);
expect(otherService.process).toHaveBeenNthCalledWith(1, item1);
expect(otherService.process).toHaveBeenNthCalledWith(2, item2);
expect(otherService.process).toHaveBeenNthCalledWith(3, item3);
});
7. Using vi.mock (Only for 3rd Party)
When to use:
- Mocking external libraries (axios, fs, etc.)
- Mocking Node.js built-in modules
- Mocking modules that don't have Fake implementations
Example:
import { vi, describe, it, expect } from 'vitest';
import axios from 'axios';
vi.mock('axios');
describe('ApiClient', () => {
it('should fetch data from API', async () => {
vi.mocked(axios.get).mockResolvedValue({ data: { id: 1 } });
const client = new ApiClient();
const result = await client.fetchUser(1);
expect(result).toEqual({ id: 1 });
expect(axios.get).toHaveBeenCalledWith('/users/1');
});
});
Workflow
Create Workflow (No existing test)
- Identify the class and interface - Ask user which class to test if not clear
- Locate the class file - Use Glob to find the class definition
- Read the interface - Get all methods that need testing (ONLY interface methods)
- Identify dependencies - Look at class constructor to find all dependencies
- Locate Fake builders - For each dependency, find its Fake builder (usually in the same package under
/fakes) - Ensure
__tests__folder exists - Check if__tests__/directory exists at package root (sibling to/src), create if needed - Create the test file in
__tests__/myClass.spec.tswith:- Vitest imports
- Interface and class imports
- Fake builder imports for dependencies
- Main describe block with class name
createMyClasshelper function- One describe block per interface method
- Multiple
itblocks per method covering different scenarios
Update Workflow (Test exists)
- Identify the class and test - Determine which class/test to update
- Read all relevant files:
- Read the existing test file
- Read the current class definition
- Read the current interface definition
- Compare and identify changes:
- New methods in interface - Add new describe blocks with test cases
- Removed methods - Remove corresponding describe blocks
- Changed method signatures - Update test cases to match new signatures
- New dependencies - Add to
createMyClassfunction and imports - Removed dependencies - Remove from
createMyClassand imports
- Apply updates using Edit tool:
- Add/remove imports as needed
- Update
createMyClassfunction - Add/remove/modify describe blocks
- Ensure coverage for all interface methods
- Verify completeness - Ensure all interface methods have test coverage
Update Guidelines
When updating an existing test:
- Preserve existing structure - Don't rewrite the entire file, use Edit tool for targeted changes
- Maintain consistency - Follow the same patterns used in the existing test
- Keep test names descriptive - Use clear, behavior-focused test names
- Don't delete passing tests - Only update tests that are failing or testing removed functionality
- Add missing coverage - If new methods were added, create new describe blocks
Common Test Scenarios
Scenario 1: Testing Async Methods
describe('fetchData', () => {
it('should return data when fetch succeeds', async () => {
const expectedData = { id: 1, name: 'Test' };
const { myClass } = createMyClass({
apiClient: new FakeApiClientBuilder()
.withGetReturnValue(Promise.resolve(expectedData))
.build()
});
const result = await myClass.fetchData('123');
expect(result).toEqual(expectedData);
});
it('should throw error when fetch fails', async () => {
const { myClass } = createMyClass({
apiClient: new FakeApiClientBuilder()
.withGetReturnValue(Promise.reject(new Error('Network error')))
.build()
});
await expect(myClass.fetchData('123')).rejects.toThrow('Network error');
});
});
Scenario 2: Testing Methods with Multiple Arguments
describe('updateUser', () => {
it('should call repository with user ID and updates', async () => {
const { myClass, userRepository } = createMyClass();
await myClass.updateUser('user-123', { name: 'New Name', email: 'new@email.com' });
expect(userRepository.update).toHaveBeenCalledWith('user-123', {
name: 'New Name',
email: 'new@email.com'
});
});
it('should handle empty updates object', async () => {
const { myClass, userRepository } = createMyClass();
await myClass.updateUser('user-123', {});
expect(userRepository.update).toHaveBeenCalledWith('user-123', {});
});
it('should handle undefined user ID', async () => {
const { myClass } = createMyClass();
await expect(myClass.updateUser(undefined, { name: 'Test' }))
.rejects.toThrow('User ID is required');
});
});
Scenario 3: Testing Methods with Conditional Logic
describe('processOrder', () => {
it('should use express shipping when order is marked as urgent', async () => {
const { myClass, shippingService } = createMyClass();
await myClass.processOrder({ id: '123', urgent: true });
expect(shippingService.ship).toHaveBeenCalledWith(
expect.objectContaining({ shippingMethod: 'express' })
);
});
it('should use standard shipping when order is not urgent', async () => {
const { myClass, shippingService } = createMyClass();
await myClass.processOrder({ id: '123', urgent: false });
expect(shippingService.ship).toHaveBeenCalledWith(
expect.objectContaining({ shippingMethod: 'standard' })
);
});
it('should apply discount when customer is premium', async () => {
const { myClass } = createMyClass({
customerService: new FakeCustomerServiceBuilder()
.withIsPremiumReturnValue(true)
.build()
});
const result = await myClass.processOrder({ id: '123', total: 100 });
expect(result.total).toBe(90); // 10% discount
});
it('should not apply discount for regular customers', async () => {
const { myClass } = createMyClass({
customerService: new FakeCustomerServiceBuilder()
.withIsPremiumReturnValue(false)
.build()
});
const result = await myClass.processOrder({ id: '123', total: 100 });
expect(result.total).toBe(100);
});
});
Scenario 4: Testing with beforeEach
describe('UserManager', () => {
function createUserManager(/* ... */) {
// ...
}
describe('addUser', () => {
let userManager: IUserManager;
let userRepository: IUserRepository;
beforeEach(() => {
const created = createUserManager();
userManager = created.userManager;
userRepository = created.userRepository;
});
it('should add user to repository', () => {
userManager.addUser({ id: '1', name: 'John' });
expect(userRepository.save).toHaveBeenCalledWith({ id: '1', name: 'John' });
});
it('should validate user before adding', () => {
userManager.addUser({ id: '1', name: 'John' });
expect(userRepository.validate).toHaveBeenCalled();
});
});
});
Note: Use beforeEach sparingly. It's useful when many tests need the same setup, but can make tests harder to understand. Prefer explicit setup in each test when possible.
Scenario 5: Testing Event Brokers
describe('updateName', () => {
it('should update the name event broker', () => {
const { myClass } = createMyClass();
myClass.updateName('New Name');
expect(myClass.name.get()).toBe('New Name');
});
it('should notify listeners when name changes', () => {
const { myClass } = createMyClass();
const listener = vi.fn();
myClass.name.subscribe(listener);
myClass.updateName('New Name');
expect(listener).toHaveBeenCalledWith('New Name');
});
});
Scenario 6: Testing Service/Client Classes with HTTP Calls
For service/client classes that wrap HTTP clients or APIs:
describe('ReportsClient', () => {
function createReportsClient(overrides?: {
httpClient?: IHttpClient;
}) {
const httpClient = overrides?.httpClient ?? new FakeHttpClientBuilder()
.withGetCallback(() => Promise.resolve({ data: [], status: 200, headers: {} }))
.build();
const reportsClient = new ReportsClient(httpClient);
return {
httpClient,
reportsClient
};
}
describe('listReportTemplates', () => {
it('should call httpClient.get with correct endpoint', async () => {
const { reportsClient, httpClient } = createReportsClient();
await reportsClient.listReportTemplates();
expect(httpClient.get).toHaveBeenCalledWith('/velocity/reports');
});
it('should transform backend DTOs to frontend DTOs', async () => {
/* eslint-disable camelcase */
const backendDTO: ReportBackendDTO = {
id: 'report-1',
name: 'Test ReportTemplate',
report_type: 'analytics',
description: 'Test Description',
notebook_path: '/path/to/notebook',
parameters: { key: 'value' },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z'
};
/* eslint-enable camelcase */
const { reportsClient } = createReportsClient({
httpClient: new FakeHttpClientBuilder()
.withGetCallback(() => Promise.resolve({ data: [backendDTO], status: 200, headers: {} }))
.build()
});
const result = await reportsClient.listReportTemplates();
expect(result.reports).toHaveLength(1);
expect(result.reports[0]).toEqual({
id: 'report-1',
name: 'Test ReportTemplate',
reportType: 'analytics',
description: 'Test Description',
notebookPath: '/path/to/notebook',
parameters: { key: 'value' },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z'
});
});
it('should return empty array when no reports are available', async () => {
const { reportsClient } = createReportsClient({
httpClient: new FakeHttpClientBuilder()
.withGetCallback(() => Promise.resolve({ data: [], status: 200, headers: {} }))
.build()
});
const result = await reportsClient.listReportTemplates();
expect(result.reports).toEqual([]);
});
});
describe('createReportTemplate', () => {
it('should call httpClient.post with transformed data', async () => {
/* eslint-disable camelcase */
const backendDTO: ReportBackendDTO = {
id: 'report-1',
name: 'New ReportTemplate',
report_type: 'test-type',
description: 'Test Description',
notebook_path: '/notebook',
parameters: { param: 'value' },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z'
};
/* eslint-enable camelcase */
const { reportsClient, httpClient } = createReportsClient({
httpClient: new FakeHttpClientBuilder()
.withPostCallback(() => Promise.resolve({ data: backendDTO, status: 201, headers: {} }))
.build()
});
await reportsClient.createReportTemplate({
name: 'New ReportTemplate',
reportType: 'test-type',
description: 'Test Description',
notebookPath: '/notebook',
parameters: { param: 'value' }
});
expect(httpClient.post).toHaveBeenCalledWith('/velocity/reports', {
name: 'New ReportTemplate',
report_type: 'test-type',
description: 'Test Description',
notebook_path: '/notebook',
parameters: { param: 'value' }
});
});
it('should return created report in response object', async () => {
/* eslint-disable camelcase */
const backendDTO: ReportBackendDTO = {
id: 'report-1',
name: 'New ReportTemplate',
report_type: 'test-type',
description: null,
notebook_path: null,
parameters: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z'
};
/* eslint-enable camelcase */
const { reportsClient } = createReportsClient({
httpClient: new FakeHttpClientBuilder()
.withPostCallback(() => Promise.resolve({ data: backendDTO, status: 201, headers: {} }))
.build()
});
const result = await reportsClient.createReportTemplate({
name: 'New ReportTemplate',
reportType: 'test-type'
});
expect(result.report.id).toBe('report-1');
expect(result.report.name).toBe('New ReportTemplate');
expect(result.report.reportType).toBe('test-type');
});
});
});
Key Points for Service/Client Tests:
- Use backend DTOs (snake_case) in test data to verify transformation
- Test that correct HTTP methods and endpoints are called
- Test that request data is properly transformed (camelCase → snake_case)
- Test that response data is properly transformed (snake_case → camelCase)
- Test different response scenarios (empty, single item, multiple items)
- Verify the structure of returned Response objects
Best Practices
1. Test Behavior, Not Implementation
- Focus on what the method does (outputs, side effects)
- Don't test internal implementation details
- Test the public interface only
2. Descriptive Test Names
// Good
it('should return null when user is not found', () => { /* ... */ });
it('should throw error when email is invalid', () => { /* ... */ });
// Bad
it('should work', () => { /* ... */ });
it('test login', () => { /* ... */ });
3. Arrange-Act-Assert Pattern
it('should calculate total with tax', () => {
// Arrange
const { calculator } = createCalculator();
// Act
const result = calculator.calculateTotal(100, 0.1);
// Assert
expect(result).toBe(110);
});
4. One Assertion Per Test (When Possible)
// Preferred
it('should return user ID', () => {
const { service } = createService();
const result = service.getUser();
expect(result.id).toBe('123');
});
it('should return user name', () => {
const { service } = createService();
const result = service.getUser();
expect(result.name).toBe('John');
});
// Acceptable when testing object shape
it('should return complete user object', () => {
const { service } = createService();
const result = service.getUser();
expect(result).toEqual({
id: '123',
name: 'John',
email: 'john@example.com'
});
});
5. Avoid Test Interdependence
- Each test should be independent
- Tests should pass in any order
- Use
createMyClassin each test, not shared instances
6. Test Edge Cases
- Null/undefined inputs
- Empty strings/arrays
- Boundary values
- Error conditions
7. Use Appropriate Matchers
// Equality
expect(value).toBe(5); // Primitive values
expect(obj).toEqual({ id: 1 }); // Objects/arrays
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThanOrEqual(10);
// Strings
expect(str).toContain('substring');
expect(str).toMatch(/regex/);
// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
// Exceptions
expect(() => fn()).toThrow('error message');
await expect(asyncFn()).rejects.toThrow();
// Function calls
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg1, arg2);
expect(fn).toHaveBeenCalledTimes(2);
Common Pitfalls to Avoid
1. ❌ Don't Instantiate Class Directly
// BAD
it('should work', () => {
const myClass = new MyClass({ dep: new FakeDepBuilder().build() });
// ...
});
// GOOD
it('should work', () => {
const { myClass } = createMyClass();
// ...
});
2. ❌ Don't Overuse mockReturnValue
// BAD (if avoidable)
it('should handle success', () => {
const { myClass, service } = createMyClass();
service.fetch.mockReturnValue(Promise.resolve(data));
// ...
});
// GOOD
it('should handle success', () => {
const { myClass } = createMyClass({
service: new FakeServiceBuilder()
.withFetchReturnValue(Promise.resolve(data))
.build()
});
// ...
});
3. ❌ Don't Test Private Methods
// BAD - testing private implementation
it('should call private helper', () => {
const { myClass } = createMyClass();
// @ts-ignore
const result = myClass.privateHelper();
expect(result).toBe(something);
});
// GOOD - test public interface only
it('should process data correctly', () => {
const { myClass } = createMyClass();
const result = myClass.publicMethod();
expect(result).toBe(expectedOutput);
});
4. ❌ Don't Use vi.mock for Internal Dependencies
// BAD
vi.mock('../src/MyService');
describe('MyClass', () => {
// ...
});
// GOOD
import { FakeMyServiceBuilder } from '../fakes';
describe('MyClass', () => {
function createMyClass(overrides?: { myService?: IMyService }) {
const myService = overrides?.myService ?? new FakeMyServiceBuilder().build();
// ...
}
});
5. ❌ Don't Forget Async/Await
// BAD
it('should fetch data', () => {
const { myClass } = createMyClass();
const result = myClass.fetchData(); // Returns Promise!
expect(result).toEqual(data); // This will fail!
});
// GOOD
it('should fetch data', async () => {
const { myClass } = createMyClass();
const result = await myClass.fetchData();
expect(result).toEqual(data);
});
Example Reference
See examples.md in the same directory as this skill for complete working examples.
Important Notes
File Organization (CRITICAL)
- All tests MUST be in
__tests__/directory at package root (sibling to/src, NOT inside/src) - Test files use camelCase naming:
myClass.spec.ts - Import paths from tests to src use relative paths:
'../src/MyClass'
Interface-Driven Testing
- ONLY test methods defined in the interface the class implements
- Do not test private methods or implementation details
- If a method is not in the interface, it should not have tests (unless it's a public utility method also in the interface)
Dependency Management
- Use Fake builders from
/fakessubpath exports - Import Fakes from the same package where the real class is defined
- If a Fake doesn't exist, you may need to create it first (use manage-fake skill)
- For 3rd party libraries without Fakes, use vi.mock sparingly
When Updating
- Always use the Edit tool for updates, not Write (which overwrites the entire file)
- Update the
createMyClassfunction when dependencies change - Add new describe blocks for new methods
- Remove describe blocks for removed methods
- Update test cases when method signatures change
- Verify all interface methods have corresponding describe blocks
Test Quality
- Prefer many small tests over few large tests
- Test happy path and error cases
- Test edge cases and boundary conditions
- Use descriptive test names that explain the behavior being tested
- Keep tests simple and focused
- Avoid complex logic in tests themselves