What you’ll learn
- The basic test pattern: create entity, call action, assert
- How to test guard clauses and error paths
- How to test invariants, events, computed properties
- What NOT to test — the library’s internals
The basic pattern
import { describe, it, expect } from 'vitest';
describe('User', () => {
it('verifies email', () => {
const user = User.create({ id: 'b3f1...', username: 'alice' });
user.actions.verifyEmail();
expect(user.props.emailVerified).toBe(true);
expect(user.isVerified).toBe(true);
});
});
No mocks. No test database. No HTTP server. The entity is pure logic.
❌ Testing through the full stack:
it('verifies email', async () => {
await request(app).post('/users').send({ ... }); // HTTP
await db.users.findUnique(...); // database
const response = await request(app).post('/users/verify'); // HTTP again
expect(response.status).toBe(200);
});
This tests Express, database, and serialization — not the domain rule. Domain logic should be testable in under 10ms without infrastructure.
Testing guard clauses
it('rejects email verification if already verified', () => {
const user = User.create({ id: '...', username: 'alice' });
user.actions.verifyEmail();
expect(() => user.actions.verifyEmail()).toThrow('Already verified');
});
it('cannot deactivate unverified user', () => {
const user = User.create({ id: '...', username: 'alice' });
expect(() => user.actions.deactivate()).toThrow('Must verify email first');
});
❌ Not testing the error path:
it('deactivates user', () => {
// Only tests happy path. What about double-verify? Deactivate without verify?
// Untested → unknown → bug in production
});
Guard clauses are the most important part to test. The happy path works by accident. The error path is where domain rules live. Test every throw.
Testing invariants (Aggregate)
it('rejects order with no items', () => {
expect(() =>
Order.create({ id: '...', status: 'pending', amountPaid: 0, items: [] })
).toThrow('Order must have at least one item');
});
it('invariants re-check after actions', () => {
const order = Order.create({
id: '...', status: 'pending', amountPaid: 100,
items: [{ productId: '...', quantity: 1, price: 100 }],
});
order.actions.setAmountPaid(0); // auto-setter — no invariant check here
// Next ACTION catches the broken invariant:
expect(() => order.actions.confirm()).toThrow('Cannot confirm unpaid order');
});
Auto-setters don’t trigger invariants. Invariants run after actions. The test above exploits this: set amount to 0, then call
confirm(), invariants catch it.
Testing domain events
it('emits event on ship', () => {
const order = Order.create({ /* ... */ });
order.actions.ship();
const events = order.getPendingEvents();
expect(events).toHaveLength(1);
expect(events[0].aggregateId).toBe('b3f1...');
expect(events[0].timestamp).toBeInstanceOf(Date);
});
Test the public API: getPendingEvents(). Don’t inspect private fields.
Testing computed properties
it('initials update after username change', () => {
const user = User.create({ id: '...', username: 'alice' });
user.actions.setUsername('bob');
expect(user.initials).toBe('BO');
});
Testing Value Objects
it('structural equality', () => {
const a = Money.create({ amount: 100, currency: 'USD' });
const b = Money.create({ amount: 100, currency: 'USD' });
expect(a.equals(b)).toBe(true);
});
it('rejects negative amounts', () => {
expect(() => Money.create({ amount: -50, currency: 'USD' })).toThrow();
});
Testing BrandedId
it('rejects invalid UUID', () => {
expect(() => UserId.create('not-a-uuid')).toThrow();
});
it('UserId and OrderId are different types', () => {
const userId = UserId.create('b3f1ae2c-abcd-4e5f-a6b7-c8d9e0f1a2b3');
const orderId = OrderId.create('d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8');
expect(typeof userId).toBe('string');
expect(typeof orderId).toBe('string');
// TypeScript: const x: UserId = orderId; // ← would error
});
What NOT to test
❌ The library’s internals — createEntity returns props, actions, computed is the library’s contract, tested in @sotajs/ddd’s own suite.
❌ Zod — z.string().email() rejecting 'notanemail' is Zod’s job.
✅ Test YOUR domain rules — your guard clauses, your invariants, your computed values, your events.
What you’ve learned
Domain logic is the easiest code to test. Create an entity, call an action, assert the result. No database, no HTTP, no mocks. Tests run in milliseconds.
Test error paths, not just happy paths. Guard clauses are where domain rules live.
Every throw should have a corresponding test.
Auto-setters don’t trigger invariants. Use this in tests: set a field to an invalid value via auto-setter, then call an action — invariants should catch it.
Test the public API. .props, .actions, .equals(), .getPendingEvents().
Private fields can change. Behavior is stable.
What’s next?
You’ve completed the Essentials track. For deeper understanding of how the library works internally, see How It Works. For rules on writing idiomatic code, see the Style Guide.
→ How It Works (coming soon) → Style Guide (coming soon)