Testing¶
CyberEco is designed for fast, reliable testing without external dependencies. All domain service tests use the MockStorageAdapter -- a complete in-memory implementation of the StorageAdapter interface. No Firebase emulators, no network calls, no Docker containers.
MockStorageAdapter¶
Installation¶
The MockStorageAdapter is exported from the @cyber-eco/services/testing entry point:
Separate entry point
The testing utilities are exported from @cyber-eco/services/testing (not the main @cyber-eco/services entry point) to ensure they are not accidentally bundled into production code.
What It Provides¶
| Feature | Behavior |
|---|---|
| Document CRUD | In-memory Map<string, Map<string, unknown>> |
| Queries | Full filter support: ==, !=, <, <=, >, >=, in, array-contains, array-contains-any |
| Sorting | Supports QueryOptions.sort with field + direction |
| Pagination | Supports limit and offset via QueryOptions |
| Batch writes | Executes all operations sequentially against in-memory store |
| Subscriptions | Callbacks fire immediately with current data and on subsequent writes |
| Timestamps | Returns new Date().toISOString() |
| ID generation | Deterministic: ${collection}_${counter} (e.g., tasks_1, tasks_2) |
Testing Utilities¶
seed(collection, id, data)¶
Insert data directly into the in-memory store, bypassing the data layer (no permission checks, no metadata enrichment):
const adapter = new MockStorageAdapter();
adapter.seed('users', 'user-1', {
name: 'Alice',
role: 'admin',
apps: ['my-app'],
});
adapter.seed('tasks', 'task-1', {
title: 'Review PR',
status: 'pending',
assignee: 'user-1',
});
dump()¶
Return a snapshot of all stored data for assertions:
const snapshot = adapter.dump();
expect(snapshot.users['user-1'].name).toBe('Alice');
expect(Object.keys(snapshot.tasks)).toHaveLength(1);
clear()¶
Reset all state -- data, ID counter, and subscriptions:
Setting Up Tests¶
Creating a Test Data Layer¶
Use createDataLayer() with the mock adapter:
import { MockStorageAdapter } from '@cyber-eco/services/testing';
import { createDataLayer } from '@cyber-eco/services';
import type { CyberEcoDataLayer } from '@cyber-eco/services';
let adapter: MockStorageAdapter;
let dataLayer: CyberEcoDataLayer;
beforeEach(() => {
adapter = new MockStorageAdapter();
dataLayer = createDataLayer({
adapter,
permissions: { enabled: false }, // Disable for unit tests
cache: {}, // Enable caching to test cache behavior
});
});
Fresh adapter per test
Always create a new MockStorageAdapter in beforeEach to ensure test isolation. This resets all data, ID counters, and subscriptions.
With Permissions Enabled¶
When testing permission logic, enable permissions and seed the necessary user data:
import { MockStorageAdapter } from '@cyber-eco/services/testing';
import { createDataLayer } from '@cyber-eco/services';
let adapter: MockStorageAdapter;
beforeEach(() => {
adapter = new MockStorageAdapter();
// Seed user data that PermissionService will read
adapter.seed('users', 'user-1', {
name: 'Alice',
apps: ['my-app'],
permissions: [
{
appId: 'my-app',
roles: ['admin'],
features: ['read', 'write', 'delete'],
grantedAt: '2024-01-01T00:00:00Z',
grantedBy: 'system',
},
],
});
});
it('should allow admin access', async () => {
const { core } = createDataLayer({
adapter,
permissions: { enabled: true },
});
adapter.seed('documents', 'doc-1', {
title: 'Test',
ownerId: 'user-1',
});
const doc = await core.get('user-1', 'documents', 'doc-1');
expect(doc).toBeTruthy();
});
Example Test File¶
Here is a complete test file matching the patterns used in the CyberEco codebase:
import { describe, it, expect, beforeEach } from 'vitest';
import { DataLayerService } from '../../core/DataLayerService';
import { MockStorageAdapter } from '../../testing/MockStorageAdapter';
import type { DataLayerConfig } from '@cyber-eco/types';
describe('DataLayerService', () => {
let adapter: MockStorageAdapter;
let dataLayer: DataLayerService;
const userId = 'user-1';
beforeEach(() => {
adapter = new MockStorageAdapter();
const config: DataLayerConfig = {
adapter,
permissions: { enabled: false },
cache: {},
sync: { enabled: true },
webhooks: { enabled: true },
};
dataLayer = new DataLayerService(config);
});
describe('get', () => {
it('should retrieve a document from adapter', async () => {
adapter.seed('users', 'user-1', { name: 'Test User', id: 'user-1' });
const result = await dataLayer.get(userId, 'users', 'user-1');
expect(result).toEqual({ name: 'Test User', id: 'user-1' });
});
it('should return null for non-existent document', async () => {
const result = await dataLayer.get(userId, 'users', 'non-existent');
expect(result).toBeNull();
});
it('should cache retrieved documents', async () => {
adapter.seed('users', 'user-1', { name: 'Test User' });
// First call: fetches from adapter and caches
await dataLayer.get(userId, 'users', 'user-1');
// Second call: should return cached value
const result = await dataLayer.get(userId, 'users', 'user-1');
expect(result).toEqual({ name: 'Test User' });
});
});
describe('create', () => {
it('should create a document in the adapter', async () => {
const docId = await dataLayer.create(userId, 'users', { name: 'New User' });
expect(docId).toBeTruthy();
const stored = await adapter.getDocument('users', docId);
expect(stored).toMatchObject({ name: 'New User' });
});
it('should add metadata to created documents', async () => {
const docId = await dataLayer.create(userId, 'users', { name: 'New User' });
const stored = await adapter.getDocument<any>('users', docId);
expect(stored?.createdAt).toBeTruthy();
expect(stored?.updatedAt).toBeTruthy();
});
});
describe('query', () => {
it('should query documents with filters', async () => {
adapter.seed('users', '1', { name: 'User 1', age: 25 });
adapter.seed('users', '2', { name: 'User 2', age: 30 });
adapter.seed('users', '3', { name: 'User 3', age: 35 });
const result = await dataLayer.query(userId, 'users', [
{ field: 'age', operator: '>=', value: 30 },
]);
expect(result.data).toHaveLength(2);
});
});
describe('permissions', () => {
it('should reject when permission checker denies', async () => {
const secureDataLayer = new DataLayerService({
adapter,
permissions: { enabled: true },
});
secureDataLayer.setPermissionChecker(async () => false);
await expect(
secureDataLayer.get(userId, 'users', 'user-1'),
).rejects.toThrow('Permission denied');
});
it('should allow when permission checker approves', async () => {
const secureDataLayer = new DataLayerService({
adapter,
permissions: { enabled: true },
});
secureDataLayer.setPermissionChecker(async () => true);
adapter.seed('users', 'user-1', { name: 'Test User' });
const result = await secureDataLayer.get(userId, 'users', 'user-1');
expect(result).toEqual({ name: 'Test User' });
});
});
describe('batchWrite', () => {
it('should execute multiple operations', async () => {
await dataLayer.batchWrite(userId, [
{ type: 'set', collection: 'users', id: '1', data: { name: 'User 1' } },
{ type: 'set', collection: 'users', id: '2', data: { name: 'User 2' } },
]);
const user1 = await adapter.getDocument('users', '1');
const user2 = await adapter.getDocument('users', '2');
expect(user1).toMatchObject({ name: 'User 1' });
expect(user2).toMatchObject({ name: 'User 2' });
});
});
});
Testing Patterns¶
Pattern 1: Seed Then Act¶
Use adapter.seed() for fast test setup, then use the data layer for the operation under test:
it('should update document status', async () => {
// Arrange: seed directly
adapter.seed('tasks', 'task-1', {
title: 'My Task',
status: 'pending',
});
// Act: use data layer
const { core } = createDataLayer({ adapter, permissions: { enabled: false } });
await core.update('user-1', 'tasks', 'task-1', { status: 'completed' });
// Assert: read back through data layer or adapter
const task = await core.get<{ status: string }>('user-1', 'tasks', 'task-1');
expect(task?.status).toBe('completed');
});
Pattern 2: Dump for Assertions¶
Use adapter.dump() to inspect the raw storage state:
it('should delete the correct document', async () => {
adapter.seed('tasks', 'keep', { title: 'Keep this' });
adapter.seed('tasks', 'delete', { title: 'Delete this' });
const { core } = createDataLayer({ adapter, permissions: { enabled: false } });
await core.delete('user-1', 'tasks', 'delete');
const snapshot = adapter.dump();
expect(snapshot.tasks['keep']).toBeDefined();
expect(snapshot.tasks['delete']).toBeUndefined();
});
Pattern 3: Custom Permission Checker¶
For unit-testing specific permission scenarios without the full PermissionService:
it('should deny write to read-only collection', async () => {
const { core } = createDataLayer({
adapter,
permissions: { enabled: true },
});
// Custom checker: allow reads, deny writes
(core as any).setPermissionChecker(
async (_userId: string, _collection: string, action: string) => {
return action === 'read';
},
);
await expect(
core.create('user-1', 'readonly-data', { value: 42 }),
).rejects.toThrow('Permission denied');
});
Pattern 4: Testing Subscriptions¶
MockStorageAdapter fires subscription callbacks on writes:
it('should notify subscriber on document update', async () => {
adapter.seed('tasks', 'task-1', { status: 'pending' });
const { core } = createDataLayer({ adapter, permissions: { enabled: false } });
const updates: unknown[] = [];
const unsubscribe = core.subscribe('user-1', 'tasks', 'task-1', (data) => {
updates.push(data);
});
// Wait for initial callback
await new Promise((r) => setTimeout(r, 10));
// Trigger an update through the adapter
await adapter.updateDocument('tasks', 'task-1', { status: 'completed' });
// Wait for subscription callback
await new Promise((r) => setTimeout(r, 10));
expect(updates).toHaveLength(2); // Initial + update
unsubscribe();
});
Running Tests¶
Run All Tests¶
This uses Turborepo to run tests across all packages.
Run Tests for a Specific Package¶
Or from the package directory:
Watch Mode¶
Test Coverage¶
Current Test Status¶
| Package | Tests | Status |
|---|---|---|
@cyber-eco/types |
0 | No runtime code to test (pure types) |
@cyber-eco/firebase |
16 | All passing |
@cyber-eco/auth |
0 | No test files yet (--passWithNoTests) |
@cyber-eco/services |
27/32 | 5 OOM on heavy type resolution (infra issue) |
| Hub app | 0 | No test files yet |
OOM tests
The 5 failing tests in @cyber-eco/services are caused by vitest v1.6 running out of memory during heavy TypeScript type resolution in the createDataLayer factory test. This is an infrastructure issue, not a code bug. The tests pass individually but OOM when run together with --forceRerunTriggers.
Best Practices¶
- Always use
MockStorageAdapterfor unit tests. Never depend on Firebase emulators for domain logic testing. - Create a fresh adapter in
beforeEach. This guarantees test isolation. - Disable permissions for unit tests unless you are specifically testing permission logic.
- Use
adapter.seed()for setup andadapter.dump()for raw state assertions. - Use deterministic IDs.
MockStorageAdaptergenerates IDs as${collection}_${counter}, making assertions predictable. - Test permission logic separately with a dedicated test suite that seeds the appropriate user and permission data.
Next Steps¶
- StorageAdapter Pattern -- Understand the interface being mocked
- Permissions Guide -- Testing permission scenarios
- Deployment Guide -- From tests to production