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
Scan to join WeChat group