How to test dcupl applications
Write reliable tests for applications that use the dcupl SDK, covering unit tests for queries, component tests with mocks, and integration tests.
Prerequisites
- Working dcupl application
- Vitest installed. The examples import from
vitestand usevi; with Jest, usejest.fn()forvi.fn()and Jest's globals instead of the imports - Basic understanding of lists and queries
Unit testing queries
Test query logic in isolation using real dcupl instances with inline test data.
Basic query test
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
import type { ModelDefinition } from '@dcupl/common';
describe('Product Queries', () => {
let dcupl: Dcupl;
const productModel: ModelDefinition = {
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'category', type: 'string', filter: true },
{ key: 'inStock', type: 'boolean' },
],
data: [
{ key: 'p1', name: 'Laptop', price: 999, category: 'Electronics', inStock: true },
{ key: 'p2', name: 'Mouse', price: 29, category: 'Electronics', inStock: true },
{ key: 'p3', name: 'Desk', price: 299, category: 'Furniture', inStock: false },
{ key: 'p4', name: 'Chair', price: 199, category: 'Furniture', inStock: true },
],
};
beforeEach(async () => {
dcupl = new Dcupl();
dcupl.models.set(productModel);
await dcupl.init();
});
afterEach(() => {
dcupl.destroy();
});
it('should return all products', () => {
const list = dcupl.lists.create({ modelKey: 'Product' });
const items = list.catalog.query.items();
expect(items).toHaveLength(4);
});
it('should filter by category', () => {
const list = dcupl.lists.create({ modelKey: 'Product' });
list.catalog.query.apply({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const items = list.catalog.query.items();
expect(items).toHaveLength(2);
expect(items.every((item) => item.category === 'Electronics')).toBe(true);
});
it('should filter by price range', () => {
const list = dcupl.lists.create({ modelKey: 'Product' });
list.catalog.query.apply({
groupKey: 'priceRange',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 100 },
{ attribute: 'price', operator: 'lte', value: 500 },
],
});
const items = list.catalog.query.items();
expect(items).toHaveLength(2);
expect(items.map((i) => i.name)).toContain('Desk');
expect(items.map((i) => i.name)).toContain('Chair');
});
});Testing filter combinations
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
describe('Filter Combinations', () => {
let dcupl: Dcupl;
let list: ReturnType<typeof dcupl.lists.create>;
beforeEach(async () => {
dcupl = new Dcupl();
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'category', type: 'string', filter: true },
{ key: 'brand', type: 'string', filter: true },
{ key: 'price', type: 'float' },
],
data: [
{ key: 'p1', name: 'iPhone', category: 'Phones', brand: 'Apple', price: 999 },
{ key: 'p2', name: 'Galaxy', category: 'Phones', brand: 'Samsung', price: 899 },
{ key: 'p3', name: 'MacBook', category: 'Laptops', brand: 'Apple', price: 1299 },
{ key: 'p4', name: 'ThinkPad', category: 'Laptops', brand: 'Lenovo', price: 999 },
],
});
await dcupl.init();
list = dcupl.lists.create({ modelKey: 'Product' });
});
afterEach(() => {
dcupl.destroy();
});
it('should combine category and brand filters (AND)', () => {
// Filter: Apple products in Phones category
// Each condition goes into the group keyed by its attribute; different attributes combine with AND
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Phones',
});
list.catalog.query.addCondition({
attribute: 'brand',
operator: 'eq',
value: 'Apple',
});
const items = list.catalog.query.items();
expect(items).toHaveLength(1);
expect(items[0].name).toBe('iPhone');
});
it('should support OR within a filter group', () => {
// Filter: Apple OR Samsung products
list.catalog.query.apply({
groupKey: 'brandFilter',
groupType: 'or',
queries: [
{ attribute: 'brand', operator: 'eq', value: 'Apple' },
{ attribute: 'brand', operator: 'eq', value: 'Samsung' },
],
});
const items = list.catalog.query.items();
expect(items).toHaveLength(3);
});
it('should clear filters correctly', () => {
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Phones',
});
expect(list.catalog.query.items()).toHaveLength(2);
list.catalog.query.clear();
expect(list.catalog.query.items()).toHaveLength(4);
});
it('should remove specific filter group', () => {
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Phones',
});
list.catalog.query.addCondition({
attribute: 'brand',
operator: 'eq',
value: 'Apple',
});
expect(list.catalog.query.items()).toHaveLength(1);
// Remove only the category filter (its group is keyed by the attribute name)
list.catalog.query.removeGroup('category');
// Should now show all Apple products
const items = list.catalog.query.items();
expect(items).toHaveLength(2);
expect(items.every((i) => i.brand === 'Apple')).toBe(true);
});
});Testing sorting and pagination
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
describe('Sorting and Pagination', () => {
let dcupl: Dcupl;
let list: ReturnType<typeof dcupl.lists.create>;
beforeEach(async () => {
dcupl = new Dcupl();
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'rating', type: 'float' },
],
data: [
{ key: 'p1', name: 'Product A', price: 100, rating: 4.5 },
{ key: 'p2', name: 'Product B', price: 50, rating: 4.8 },
{ key: 'p3', name: 'Product C', price: 150, rating: 4.2 },
{ key: 'p4', name: 'Product D', price: 75, rating: 4.6 },
],
});
await dcupl.init();
list = dcupl.lists.create({ modelKey: 'Product' });
});
afterEach(() => {
dcupl.destroy();
});
it('should sort by price ascending', () => {
const items = list.catalog.query.items({
sort: { attributes: ['price'], order: ['ASC'] },
});
expect(items[0].price).toBe(50);
expect(items[3].price).toBe(150);
});
it('should sort by price descending', () => {
const items = list.catalog.query.items({
sort: { attributes: ['price'], order: ['DESC'] },
});
expect(items[0].price).toBe(150);
expect(items[3].price).toBe(50);
});
it('should paginate results', () => {
const page1 = list.catalog.query.items({ start: 0, count: 2 });
const page2 = list.catalog.query.items({ start: 2, count: 2 });
expect(page1).toHaveLength(2);
expect(page2).toHaveLength(2);
expect(page1[0].key).not.toBe(page2[0].key);
});
it('should combine sorting and pagination', () => {
const items = list.catalog.query.items({
sort: { attributes: ['price'], order: ['ASC'] },
start: 1,
count: 2,
});
expect(items).toHaveLength(2);
expect(items[0].price).toBe(75); // Second cheapest
expect(items[1].price).toBe(100); // Third cheapest
});
});Mocking dcupl for component tests
When testing UI components, mock dcupl to avoid initializing the full SDK.
Creating a mock dcupl instance
The mock needs every method your component calls. This one covers the common query, function and data methods; add the ones your components use.
import { vi, type Mock } from 'vitest';
export interface MockDcuplList {
catalog: {
query: {
items: Mock;
first: Mock;
count: Mock;
addCondition: Mock;
setCondition: Mock;
removeCondition: Mock;
addGroup: Mock;
removeGroup: Mock;
apply: Mock;
clear: Mock;
remove: Mock;
};
fn: {
facets: Mock;
aggregate: Mock;
metadata: Mock;
};
filters: {
get: Mock;
getAll: Mock;
};
};
on: Mock;
onResultChange: Mock;
destroy: Mock;
}
export interface MockDcupl {
models: {
set: Mock;
get: Mock;
};
data: {
set: Mock;
update: Mock;
upsert: Mock;
remove: Mock;
};
lists: {
create: Mock;
get: Mock;
getAll: Mock;
};
init: Mock;
update: Mock;
destroy: Mock;
}
export function createMockList<T>(items: T[] = []): MockDcuplList {
return {
catalog: {
query: {
items: vi.fn().mockReturnValue(items),
first: vi.fn().mockReturnValue(items[0]),
count: vi.fn().mockReturnValue(items.length),
addCondition: vi.fn(),
setCondition: vi.fn(),
removeCondition: vi.fn(),
addGroup: vi.fn(),
removeGroup: vi.fn(),
apply: vi.fn(),
clear: vi.fn(),
remove: vi.fn(),
},
fn: {
facets: vi.fn().mockReturnValue([]),
aggregate: vi.fn().mockReturnValue({ attribute: 'price', types: ['sum'], sum: 0 }),
metadata: vi.fn().mockReturnValue({
key: 'TestModel',
currentSize: items.length,
initialSize: items.length,
appliedQuery: { queries: [] },
}),
},
filters: {
get: vi.fn().mockReturnValue(undefined),
getAll: vi.fn().mockReturnValue([]),
},
},
on: vi.fn().mockReturnValue(() => {}), // returns an unsubscribe function, like the real one
onResultChange: vi.fn().mockReturnValue(() => {}),
destroy: vi.fn(),
};
}
export function createMockDcupl(lists: Map<string, MockDcuplList> = new Map()): MockDcupl {
return {
models: {
set: vi.fn(),
get: vi.fn(),
},
data: {
set: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
remove: vi.fn(),
},
lists: {
create: vi.fn().mockImplementation(({ modelKey }) => {
return lists.get(modelKey) || createMockList();
}),
get: vi.fn().mockImplementation((key) => lists.get(key)),
getAll: vi.fn().mockReturnValue(Array.from(lists.values())),
},
init: vi.fn().mockResolvedValue(true), // the real init() resolves true, or false on failure
update: vi.fn().mockResolvedValue(true),
destroy: vi.fn(),
};
}Testing a React component with mock
The component under test gets the instance as a prop, creates its list, and filters by category:
import { useEffect, useState } from 'react';
import type { Dcupl, DcuplList } from '@dcupl/core';
type Product = { key: string; name: string; category: string };
export function ProductList({ dcupl }: { dcupl: Dcupl }) {
const [list, setList] = useState<DcuplList | null>(null);
const [items, setItems] = useState<Product[]>([]);
useEffect(() => {
const productList = dcupl.lists.create({ modelKey: 'Product' });
setList(productList);
setItems(productList.catalog.query.items<Product>());
return () => productList.destroy();
}, [dcupl]);
const filterByCategory = (category: string) => {
list?.catalog.query.setCondition({ attribute: 'category', operator: 'eq', value: category });
setItems(list?.catalog.query.items<Product>() ?? []);
};
const clearFilters = () => {
list?.catalog.query.clear();
setItems(list?.catalog.query.items<Product>() ?? []);
};
return (
<div>
<button onClick={() => filterByCategory('Electronics')}>Electronics</button>
<button onClick={clearFilters}>Clear filters</button>
<ul>
{items.map((product) => (
<li key={product.key}>{product.name}</li>
))}
</ul>
</div>
);
}The test passes the mock in place of a Dcupl instance. The mock has only part of the Dcupl type, so cast it with as unknown as Dcupl. toBeInTheDocument() comes from @testing-library/jest-dom: import @testing-library/jest-dom/vitest in your Vitest setup file and run the tests with environment: 'jsdom'; for tsc to know the matcher, list the setup file in your tsconfig.json include.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import type { Dcupl } from '@dcupl/core';
import { ProductList } from './ProductList';
import { createMockDcupl, createMockList } from './test-helpers/mock-dcupl';
describe('ProductList Component', () => {
const mockProducts = [
{ key: 'p1', name: 'Laptop', price: 999, category: 'Electronics' },
{ key: 'p2', name: 'Mouse', price: 29, category: 'Electronics' },
{ key: 'p3', name: 'Desk', price: 299, category: 'Furniture' },
];
let mockList: ReturnType<typeof createMockList>;
let mockDcupl: ReturnType<typeof createMockDcupl>;
beforeEach(() => {
mockList = createMockList(mockProducts);
mockDcupl = createMockDcupl(new Map([['Product', mockList]]));
});
afterEach(() => {
cleanup(); // unmount; Testing Library does this on its own only with Vitest's globals enabled
});
it('should render product list', () => {
render(<ProductList dcupl={mockDcupl as unknown as Dcupl} />);
expect(screen.getByText('Laptop')).toBeInTheDocument();
expect(screen.getByText('Mouse')).toBeInTheDocument();
expect(screen.getByText('Desk')).toBeInTheDocument();
});
it('should set the category condition when filtering', () => {
render(<ProductList dcupl={mockDcupl as unknown as Dcupl} />);
const filterButton = screen.getByRole('button', { name: /electronics/i });
fireEvent.click(filterButton);
expect(mockList.catalog.query.setCondition).toHaveBeenCalledWith(
expect.objectContaining({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
}),
);
});
it('should call query.clear when clearing filters', () => {
render(<ProductList dcupl={mockDcupl as unknown as Dcupl} />);
const clearButton = screen.getByRole('button', { name: /clear/i });
fireEvent.click(clearButton);
expect(mockList.catalog.query.clear).toHaveBeenCalled();
});
});Testing an Angular component with mock
Here the instance comes from a service of your own, which the test replaces:
import { Injectable } from '@angular/core';
import { Dcupl } from '@dcupl/core';
@Injectable({ providedIn: 'root' })
export class DcuplService {
private readonly dcupl = new Dcupl();
getDcupl(): Dcupl {
return this.dcupl;
}
}import { Component, inject } from '@angular/core';
import { DcuplService } from './services/dcupl.service';
type Product = { key: string; name: string };
@Component({
selector: 'app-product-list',
template: `
@for (product of products; track product.key) {
<div class="product-item">{{ product.name }}</div>
}
`,
})
export class ProductListComponent {
private readonly list = inject(DcuplService).getDcupl().lists.create({ modelKey: 'Product' });
products = this.list.catalog.query.items<Product>();
filterByCategory(category: string) {
this.list.catalog.query.setCondition({ attribute: 'category', operator: 'eq', value: category });
this.products = this.list.catalog.query.items<Product>();
}
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductListComponent } from './product-list.component';
import { DcuplService } from './services/dcupl.service';
import { createMockDcupl, createMockList, type MockDcuplList } from './test-helpers/mock-dcupl';
describe('ProductListComponent', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let mockList: MockDcuplList;
const mockProducts = [
{ key: 'p1', name: 'Laptop', price: 999 },
{ key: 'p2', name: 'Mouse', price: 29 },
];
beforeEach(async () => {
mockList = createMockList(mockProducts);
const mockDcupl = createMockDcupl(new Map([['Product', mockList]]));
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [{ provide: DcuplService, useValue: { getDcupl: vi.fn().mockReturnValue(mockDcupl) } }],
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display products', () => {
const productElements = fixture.nativeElement.querySelectorAll('.product-item');
expect(productElements.length).toBe(2);
});
it('should filter products when category selected', () => {
component.filterByCategory('Electronics');
expect(mockList.catalog.query.setCondition).toHaveBeenCalledWith(
expect.objectContaining({ attribute: 'category', value: 'Electronics' }),
);
});
});Integration testing
Test complete workflows with real dcupl instances.
Full workflow test
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
import type { ModelDefinition } from '@dcupl/common';
describe('Product Catalog Integration', () => {
let dcupl: Dcupl;
const categoryModel: ModelDefinition = {
key: 'Category',
properties: [{ key: 'name', type: 'string' }],
data: [
{ key: 'cat1', name: 'Electronics' },
{ key: 'cat2', name: 'Furniture' },
{ key: 'cat3', name: 'Clothing' },
],
};
const productModel: ModelDefinition = {
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'inStock', type: 'boolean' },
],
references: [{ key: 'category', type: 'singleValued', model: 'Category', filter: true }],
data: [
{ key: 'p1', name: 'Laptop', price: 999, category: 'cat1', inStock: true },
{ key: 'p2', name: 'Mouse', price: 29, category: 'cat1', inStock: true },
{ key: 'p3', name: 'Desk', price: 299, category: 'cat2', inStock: false },
{ key: 'p4', name: 'Chair', price: 199, category: 'cat2', inStock: true },
{ key: 'p5', name: 'T-Shirt', price: 25, category: 'cat3', inStock: true },
],
};
beforeEach(async () => {
dcupl = new Dcupl();
dcupl.models.set(categoryModel);
dcupl.models.set(productModel);
await dcupl.init();
});
afterEach(() => {
dcupl.destroy();
});
it('should complete a full filter workflow', () => {
const list = dcupl.lists.create({ modelKey: 'Product' });
// Step 1: Get initial facets
const categoryFacets = list.catalog.fn.facets({ attribute: 'category' });
expect(categoryFacets).toHaveLength(3);
// Step 2: Apply category filter (a reference matches by key)
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'cat1',
});
const filteredItems = list.catalog.query.items();
expect(filteredItems).toHaveLength(2);
// Step 3: Verify facets update
// The facet leaves out the group of its own attribute, so all 3 categories keep their counts
const updatedFacets = list.catalog.fn.facets({ attribute: 'category' });
const electronicsFacet = updatedFacets.find((f) => f.key === 'cat1');
expect(updatedFacets).toHaveLength(3);
expect(electronicsFacet?.size).toBe(2);
expect(electronicsFacet?.selected).toBe(true);
// Step 4: Add price filter
list.catalog.query.addCondition({
attribute: 'price',
operator: 'lte',
value: 100,
});
const priceFilteredItems = list.catalog.query.items();
expect(priceFilteredItems).toHaveLength(1);
expect(priceFilteredItems[0].name).toBe('Mouse');
// Step 5: Clear all filters
list.catalog.query.clear();
expect(list.catalog.query.count()).toBe(5);
});
it('should handle data updates correctly', async () => {
const list = dcupl.lists.create({ modelKey: 'Product' });
// Initial count
expect(list.catalog.query.count()).toBe(5);
// Add new product (upsert adds unknown keys; set() would replace all Product items)
dcupl.data.upsert([{ key: 'p6', name: 'Monitor', price: 399, category: 'cat1', inStock: true }], {
model: 'Product',
});
// Data calls are staged until update(); the list sees the new item on its next read
await dcupl.update();
expect(list.catalog.query.count()).toBe(6);
// Verify the new product is queryable
list.catalog.query.apply({
attribute: 'name',
operator: 'find',
value: 'Monitor',
});
const items = list.catalog.query.items();
expect(items).toHaveLength(1);
expect(items[0].price).toBe(399);
});
it('should create filtered lists', () => {
// A list scoped to electronics: it keeps the keys the query selects now, with their current items
const query = dcupl.query.generate('Product', {
attribute: 'category.key',
operator: 'eq',
value: 'cat1',
});
const electronicsList = dcupl.lists.create({ modelKey: 'Product', query });
const meta = electronicsList.catalog.fn.metadata();
expect(meta.initialSize).toBe(2);
expect(meta.currentSize).toBe(2);
// Additional filters work on the pre-filtered set
electronicsList.catalog.query.apply({
attribute: 'price',
operator: 'lt',
value: 100,
});
expect(electronicsList.catalog.query.count()).toBe(1);
});
});Testing with aggregations
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
describe('Aggregations', () => {
let dcupl: Dcupl;
beforeEach(async () => {
dcupl = new Dcupl();
dcupl.models.set({
key: 'Order',
properties: [
{ key: 'product', type: 'string' },
{ key: 'quantity', type: 'int' },
{ key: 'price', type: 'float' },
{ key: 'status', type: 'string', filter: true },
],
data: [
{ key: 'o1', product: 'Laptop', quantity: 2, price: 999, status: 'completed' },
{ key: 'o2', product: 'Mouse', quantity: 5, price: 29, status: 'completed' },
{ key: 'o3', product: 'Keyboard', quantity: 3, price: 79, status: 'pending' },
{ key: 'o4', product: 'Monitor', quantity: 1, price: 399, status: 'completed' },
],
});
await dcupl.init();
});
afterEach(() => {
dcupl.destroy();
});
it('should calculate sum', () => {
const list = dcupl.lists.create({ modelKey: 'Order' });
// aggregate() always needs `types` and returns one object with a field per type
const result = list.catalog.fn.aggregate({
attribute: 'quantity',
types: ['sum'],
});
expect(result.sum).toBe(11);
});
it('should calculate average', () => {
const list = dcupl.lists.create({ modelKey: 'Order' });
const result = list.catalog.fn.aggregate({
attribute: 'price',
types: ['avg'],
});
expect(result.avg).toBe(376.5);
});
it('should calculate min and max', () => {
const list = dcupl.lists.create({ modelKey: 'Order' });
const result = list.catalog.fn.aggregate({
attribute: 'price',
types: ['min', 'max'],
});
expect(result.min).toBe(29);
expect(result.max).toBe(999);
});
it('should aggregate filtered results', () => {
const list = dcupl.lists.create({ modelKey: 'Order' });
list.catalog.query.apply({
attribute: 'status',
operator: 'eq',
value: 'completed',
});
const completed = list.catalog.fn.aggregate({
attribute: 'quantity',
types: ['sum'],
});
expect(completed.sum).toBe(8); // 2 + 5 + 1
});
});Test data strategies
Using fixtures
import type { ModelDefinition } from '@dcupl/common';
export const PRODUCT_MODEL: ModelDefinition = {
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'category', type: 'string', filter: true },
{ key: 'brand', type: 'string', filter: true },
{ key: 'inStock', type: 'boolean' },
{ key: 'rating', type: 'float' },
],
};
export const SAMPLE_PRODUCTS = [
{
key: 'p1',
name: 'Laptop Pro',
price: 1299,
category: 'Electronics',
brand: 'TechCo',
inStock: true,
rating: 4.5,
},
{
key: 'p2',
name: 'Wireless Mouse',
price: 49,
category: 'Electronics',
brand: 'TechCo',
inStock: true,
rating: 4.2,
},
{
key: 'p3',
name: 'Office Desk',
price: 399,
category: 'Furniture',
brand: 'HomeStyle',
inStock: false,
rating: 4.0,
},
{
key: 'p4',
name: 'Ergonomic Chair',
price: 299,
category: 'Furniture',
brand: 'HomeStyle',
inStock: true,
rating: 4.8,
},
{
key: 'p5',
name: 'USB-C Hub',
price: 79,
category: 'Electronics',
brand: 'ConnectPro',
inStock: true,
rating: 4.3,
},
];
export function createProductModel(data = SAMPLE_PRODUCTS): ModelDefinition {
return {
...PRODUCT_MODEL,
data,
};
}Factory functions for test data
export interface Product {
key: string;
name: string;
price: number;
category: string;
brand: string;
inStock: boolean;
rating: number;
}
let idCounter = 0;
export function createProduct(overrides: Partial<Product> = {}): Product {
idCounter++;
return {
key: `product-${idCounter}`,
name: `Test Product ${idCounter}`,
price: 100 + idCounter * 10,
category: 'General',
brand: 'TestBrand',
inStock: true,
rating: 4.0,
...overrides,
};
}
export function createProducts(count: number, overrides: Partial<Product> = {}): Product[] {
return Array.from({ length: count }, () => createProduct(overrides));
}
export function createProductsInCategory(category: string, count: number): Product[] {
return createProducts(count, { category });
}Keep describe blocks in spec files: a helper module that contains one registers it again in every test file that imports the helper.
import { describe, it, expect } from 'vitest';
import { Dcupl } from '@dcupl/core';
import { createProducts } from './test-helpers/factories';
describe('Product Tests', () => {
it('should handle many products', async () => {
const dcupl = new Dcupl();
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'category', type: 'string', filter: true },
],
data: createProducts(1000),
});
await dcupl.init();
const list = dcupl.lists.create({ modelKey: 'Product' });
expect(list.catalog.query.count()).toBe(1000);
});
});Resetting state between tests
import { Dcupl } from '@dcupl/core';
import type { ModelDefinition } from '@dcupl/common';
let dcuplInstance: Dcupl | null = null;
export function getTestDcupl(): Dcupl {
if (!dcuplInstance) {
throw new Error('Dcupl not initialized. Call setupTestDcupl first.');
}
return dcuplInstance;
}
export async function setupTestDcupl(models: ModelDefinition[]): Promise<Dcupl> {
dcuplInstance = new Dcupl();
models.forEach((model) => dcuplInstance!.models.set(model));
// init() resolves false instead of throwing, so fail the test setup explicitly
if (!(await dcuplInstance.init())) {
throw new Error('dcupl init failed');
}
return dcuplInstance;
}
export function cleanupTestDcupl(): void {
if (dcuplInstance) {
dcuplInstance.destroy();
dcuplInstance = null;
}
}Use the helpers in a spec file (here with the model from the fixtures above):
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDcupl, cleanupTestDcupl, getTestDcupl } from './test-helpers/test-dcupl';
import { createProductModel } from './test-fixtures/products';
describe('My Tests', () => {
beforeEach(async () => {
await setupTestDcupl([createProductModel()]);
});
afterEach(() => {
cleanupTestDcupl();
});
it('should work', () => {
const dcupl = getTestDcupl();
const list = dcupl.lists.create({ modelKey: 'Product' });
expect(list.catalog.query.count()).toBe(5);
});
});Testing data quality
init() resolves true even when the data has quality errors: a value that doesn't fit its type (WrongDataType) or a reference to a key that doesn't exist (RemoteReferenceKeyNotFound) doesn't fail it. A test that only checks init() misses broken data, so assert on dcupl.quality.errors.summary() too:
import { describe, it, expect, afterEach } from 'vitest';
import { Dcupl } from '@dcupl/core';
import { createProductModel } from './test-fixtures/products';
describe('Product data quality', () => {
let dcupl: Dcupl;
afterEach(() => {
dcupl.destroy();
});
it('loads the fixtures without quality errors', async () => {
dcupl = new Dcupl(); // quality checks are on by default
dcupl.models.set(createProductModel());
expect(await dcupl.init()).toBe(true);
expect(dcupl.quality.errors.summary()).toEqual([]);
});
it('reports a wrong type and a broken reference', async () => {
dcupl = new Dcupl();
dcupl.models.set({
key: 'Category',
properties: [{ key: 'name', type: 'string' }],
data: [{ key: 'cat1', name: 'Electronics' }],
});
dcupl.models.set({
key: 'Product',
properties: [{ key: 'price', type: 'float' }],
references: [{ key: 'category', type: 'singleValued', model: 'Category' }],
data: [
{ key: 'p1', price: 'abc', category: 'cat1' },
{ key: 'p2', price: 10, category: 'cat9' },
],
});
// init() still resolves true
expect(await dcupl.init()).toBe(true);
const errorTypes = dcupl.quality.errors.summary().map((group) => group.errorType);
expect(errorTypes).toEqual(expect.arrayContaining(['WrongDataType', 'RemoteReferenceKeyNotFound']));
});
});Each summary entry carries model, attribute, errorType and totalCount, so a test can also pin the exact errors it expects. See Quality & validation for the error types.
Best practices
Arrange-Act-Assert pattern:
it('should filter by category', () => {
// Arrange
const list = dcupl.lists.create({ modelKey: 'Product' });
// Act
list.catalog.query.apply({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const items = list.catalog.query.items();
// Assert
expect(items).toHaveLength(2);
expect(items.every((i) => i.category === 'Electronics')).toBe(true);
});Each test should be independent:
import { describe, beforeEach, afterEach } from 'vitest';
import { Dcupl, type DcuplList } from '@dcupl/core';
import { createProductModel } from './test-fixtures/products';
describe('Filter Tests', () => {
let dcupl: Dcupl;
let list: DcuplList;
beforeEach(async () => {
// Fresh instance for each test
dcupl = new Dcupl();
dcupl.models.set(createProductModel());
await dcupl.init();
list = dcupl.lists.create({ modelKey: 'Product' });
});
afterEach(() => {
// Clean up
list.destroy();
dcupl.destroy();
});
// Tests are now isolated
});Be specific in assertions:
// Bad - too vague
expect(items.length).toBeGreaterThan(0);
// Good - specific expectation
expect(items).toHaveLength(3);
// Good - verify actual content
expect(items.map((i) => i.key)).toEqual(['p1', 'p2', 'p3']);
// Good - verify properties
expect(items[0]).toMatchObject({
name: 'Laptop',
category: 'Electronics',
});