Testing
Write comprehensive tests for your entities through their public interface
Overview
Test your entities by calling their public methods and verifying behavior, not by testing implementation details like reducers. This guide focuses on testing entities as black boxes through their interface.
Testing Entities
Test entities by creating them, calling mutations, and verifying the resulting state.
Basic Entity Tests
import { describe, it, expect } from 'vitest';
describe('User Entity', () => {
describe('create', () => {
it('creates user with initial state', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
expect(user.state.email).toBe('john@example.com');
expect(user.state.nickname).toBe('john');
expect(user.state.bio).toBeUndefined();
expect(user.state.isDeleted).toBe(false);
});
it('generates unique entity IDs', () => {
const user1 = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
const user2 = User.create({
body: { email: 'jane@example.com', nickname: 'jane' }
});
expect(user1.entityId).not.toBe(user2.entityId);
});
});
describe('updateProfile', () => {
it('updates profile fields', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.updateProfile({ bio: 'Software Engineer' });
expect(user.state.bio).toBe('Software Engineer');
expect(user.state.nickname).toBe('john'); // Unchanged
});
it('updates multiple fields', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.updateProfile({
nickname: 'john_doe',
bio: 'Software Engineer'
});
expect(user.state.nickname).toBe('john_doe');
expect(user.state.bio).toBe('Software Engineer');
});
it('prevents updating deleted users', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.delete();
expect(() => {
user.updateProfile({ bio: 'Engineer' });
}).toThrow('Cannot update profile of deleted user');
});
});
describe('delete', () => {
it('marks user as deleted', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.delete('User requested deletion');
expect(user.state.isDeleted).toBe(true);
});
it('prevents deleting already deleted user', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.delete();
expect(() => {
user.delete();
}).toThrow('User is already deleted');
});
});
describe('restore', () => {
it('restores deleted user', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.delete();
user.restore();
expect(user.state.isDeleted).toBe(false);
});
it('prevents restoring non-deleted user', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
expect(() => {
user.restore();
}).toThrow('User is not deleted');
});
});
});Testing Entity Lifecycle
Test complete entity lifecycles with multiple operations:
describe('User Lifecycle', () => {
it('handles full user journey', () => {
// Create user
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
expect(user.state.isDeleted).toBe(false);
// Update profile multiple times
user.updateProfile({ bio: 'Developer' });
user.updateProfile({ bio: 'Senior Developer' });
user.updateProfile({ nickname: 'john_doe' });
expect(user.state.nickname).toBe('john_doe');
expect(user.state.bio).toBe('Senior Developer');
// Delete user
user.delete();
expect(user.state.isDeleted).toBe(true);
// Restore user
user.restore();
expect(user.state.isDeleted).toBe(false);
// Verify all data preserved
expect(user.state.email).toBe('john@example.com');
expect(user.state.nickname).toBe('john_doe');
expect(user.state.bio).toBe('Senior Developer');
});
});Testing Business Rules
Test validation and business logic:
describe('User Business Rules', () => {
describe('email validation', () => {
it('accepts valid email', () => {
expect(() => {
User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
}).not.toThrow();
});
it('rejects invalid email', () => {
expect(() => {
User.create({
body: { email: 'invalid-email', nickname: 'john' }
});
}).toThrow('Invalid email format');
});
});
describe('nickname rules', () => {
it('rejects empty nickname', () => {
expect(() => {
User.create({
body: { email: 'john@example.com', nickname: '' }
});
}).toThrow('Nickname cannot be empty');
});
it('rejects nickname with special characters', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
expect(() => {
user.updateProfile({ nickname: 'john@#$' });
}).toThrow('Nickname can only contain letters, numbers, and underscores');
});
});
});Testing Computed Properties
Test getters and computed properties:
describe('User Computed Properties', () => {
it('exposes computed properties', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
// Test getters
expect(user.email).toBe('john@example.com');
expect(user.nickname).toBe('john');
expect(user.isDeleted).toBe(false);
user.delete();
expect(user.isDeleted).toBe(true);
});
it('preserves entity ID through operations', () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
const originalId = user.entityId;
user.updateProfile({ bio: 'Engineer' });
user.delete();
user.restore();
expect(user.entityId).toBe(originalId);
});
});Integration Testing
Test the full flow from entity creation to persistence.
Repository Integration Tests
import { describe, it, expect, beforeEach } from 'vitest';
import { createRepository } from 'ventyd';
describe('User Repository Integration', () => {
let repository: any;
beforeEach(() => {
repository = createRepository(User, {
adapter: createInMemoryAdapter()
});
});
it('persists created entity', async () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
const userId = user.entityId;
await repository.commit(user);
// Retrieve and verify
const retrieved = await repository.findOne({ entityId: userId });
expect(retrieved).not.toBeNull();
expect(retrieved?.email).toBe('john@example.com');
expect(retrieved?.nickname).toBe('john');
});
it('persists entity mutations', async () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.updateProfile({ bio: 'Software Engineer' });
user.setEmail('john.doe@example.com');
await repository.commit(user);
const retrieved = await repository.findOne({
entityId: user.entityId
});
expect(retrieved?.email).toBe('john.doe@example.com');
expect(retrieved?.bio).toBe('Software Engineer');
});
it('reconstructs entity state from events', async () => {
// Create and modify
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.updateProfile({ bio: 'Engineer' });
user.delete();
await repository.commit(user);
// Retrieve fresh instance
const retrieved = await repository.findOne({
entityId: user.entityId
});
// Verify state matches
expect(retrieved?.email).toBe('john@example.com');
expect(retrieved?.bio).toBe('Engineer');
expect(retrieved?.isDeleted).toBe(true);
});
it('handles multiple entities independently', async () => {
const user1 = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
const user2 = User.create({
body: { email: 'jane@example.com', nickname: 'jane' }
});
user1.updateProfile({ bio: 'Engineer' });
user2.updateProfile({ bio: 'Designer' });
await repository.commit(user1);
await repository.commit(user2);
const retrieved1 = await repository.findOne({
entityId: user1.entityId
});
const retrieved2 = await repository.findOne({
entityId: user2.entityId
});
expect(retrieved1?.bio).toBe('Engineer');
expect(retrieved2?.bio).toBe('Designer');
});
it('maintains event history', async () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
user.updateProfile({ bio: 'Engineer' });
user.setEmail('john.doe@example.com');
await repository.commit(user);
// Get raw events
const events = await repository.adapter.getEventsByEntityId({
entityName: 'user',
entityId: user.entityId
});
expect(events).toHaveLength(3); // created, profile_updated, email_changed
expect(events[0].eventName).toBe('user:created');
expect(events[1].eventName).toBe('user:profile_updated');
expect(events[2].eventName).toBe('user:email_changed');
});
});Testing With Plugins
Test entities with plugins attached.
Plugin Tests
describe('User Repository With Plugins', () => {
let repository: any;
let analyticsTrackMock: any;
beforeEach(() => {
analyticsTrackMock = vi.fn();
const analyticsPlugin: Plugin = {
async onCommitted({ events }) {
for (const event of events) {
analyticsTrackMock(event.eventName);
}
}
};
repository = createRepository(User, {
adapter: createInMemoryAdapter(),
plugins: [analyticsPlugin]
});
});
it('triggers plugins on commit', async () => {
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
await repository.commit(user);
expect(analyticsTrackMock).toHaveBeenCalledWith('user:created');
});
it('passes correct data to plugins', async () => {
const pluginDataMock = vi.fn();
const testPlugin: Plugin = {
async onCommitted({ entityName, entityId, events, state }) {
pluginDataMock({
entityName,
entityId,
eventCount: events.length,
state
});
}
};
const repo = createRepository(User, {
adapter: createInMemoryAdapter(),
plugins: [testPlugin]
});
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
await repo.commit(user);
const call = pluginDataMock.mock.calls[0][0];
expect(call.entityName).toBe('user');
expect(call.entityId).toBe(user.entityId);
expect(call.eventCount).toBe(1);
expect(call.state.email).toBe('john@example.com');
});
it('handles plugin errors gracefully', async () => {
const errorHandler = vi.fn();
const failingPlugin: Plugin = {
async onCommitted() {
throw new Error('Plugin failed');
}
};
const repo = createRepository(User, {
adapter: createInMemoryAdapter(),
plugins: [failingPlugin],
onPluginError: errorHandler
});
const user = User.create({
body: { email: 'john@example.com', nickname: 'john' }
});
// Should not throw
await repo.commit(user);
expect(errorHandler).toHaveBeenCalled();
});
});Advanced: Property-Based Testing
Use property-based testing to find edge cases with generated inputs:
import fc from 'fast-check';
describe('User Property-Based Tests', () => {
it('always maintains valid state with any email', () => {
fc.assert(
fc.property(
fc.emailAddress(),
fc.string({ minLength: 1, maxLength: 20 }),
(email, nickname) => {
const user = User.create({
body: { email, nickname }
});
// Entity should always be in valid state
expect(user.state).toBeDefined();
expect(user.email).toBe(email);
expect(user.nickname).toBe(nickname);
expect(user.isDeleted).toBe(false);
}
)
);
});
it('handles multiple updates correctly', () => {
fc.assert(
fc.property(
fc.emailAddress(),
fc.array(fc.string({ minLength: 0, maxLength: 100 })),
(email, bios) => {
const user = User.create({
body: { email, nickname: 'user' }
});
// Apply multiple bio updates
for (const bio of bios) {
user.updateProfile({ bio });
}
// Final state should match last bio
const lastBio = bios[bios.length - 1] || undefined;
expect(user.state.bio).toBe(lastBio);
}
)
);
});
});Test Utilities
Create helper functions for common testing patterns:
// Test helpers
export function createTestUser(
overrides?: Partial<UserCreationData>
): User {
return User.create({
body: {
email: 'test@example.com',
nickname: 'testuser',
...overrides
}
});
}
export function createTestRepository() {
return createRepository(User, {
adapter: createInMemoryAdapter()
});
}
export async function persistAndRetrieve(
repository: any,
entity: any
) {
await repository.commit(entity);
return repository.findOne({ entityId: entity.entityId });
}
// Usage
describe('User', () => {
it('works with test utilities', async () => {
const user = createTestUser({ email: 'john@example.com' });
const repository = createTestRepository();
user.updateProfile({ bio: 'Engineer' });
const retrieved = await persistAndRetrieve(repository, user);
expect(retrieved?.bio).toBe('Engineer');
});
});Testing Checklist
Focus on testing behavior through the entity interface:
- Test entity creation with valid/invalid data
- Test each mutation method
- Test validation and business rules
- Test entity lifecycle (create → update → delete → restore)
- Test computed properties and getters
- Test persistence with repository
- Test event history is maintained
- Test with plugins
- Test error cases and edge cases
Don't test:
- ❌ Reducers directly (implementation detail)
- ❌ Internal event dispatch (implementation detail)
- ❌ Event body structure (implementation detail)
Do test:
- ✅ Public methods and their effects on state
- ✅ Business rules and validation
- ✅ Entity behavior as a black box
