Ventyd Logo

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

On this page