Vitest Custom Matchers
QuickModel ships a set of custom matchers for Vitest (and Bun's test runner) that make assertions on QModel instances expressive and readable.
Installation
npm install quickmodelThe matchers are available as a sub-entry point:
import { quickmodelMatchers } from 'quickmodel/matchers';Setup
Extend Vitest's expect in your setup file:
// vitest.setup.ts
import { expect } from 'vitest';
import { quickmodelMatchers } from 'quickmodel/matchers';
expect.extend(quickmodelMatchers);// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
},
});Bun Test
// tests/setup.ts
import { expect } from 'bun:test';
import { quickmodelMatchers } from 'quickmodel/matchers';
expect.extend(quickmodelMatchers);TypeScript Augmentation
The matchers entry point automatically augments Vitest's Assertion interface. No extra type imports are needed in test files.
// Fully typed — no manual imports required
expect(dto).toBeValidQModel();
expect(dto).toHaveQRuleError('email');Available Matchers
toBeValidQModel()
Asserts that the value is a QModel instance.
const dto = new UserDto({ id: '1', name: 'Alice' });
expect(dto).toBeValidQModel();toHaveQRuleError(field, message?)
Asserts that qCheckRules() produces an error on the specified field. Optionally checks the error message.
const dto = new UserSignupDto({
username: 'x',
email: 'not-an-email',
age: 15,
role: 'user',
});
expect(dto).toHaveQRuleError('username'); // any error on 'username'
expect(dto).toHaveQRuleError('username', 'too short'); // partial message match
expect(dto).toHaveQRuleError('email', 'Invalid email'); // exact or partial
expect(dto).toHaveQRuleError('age', 'Must be 18 or older'); // full messagetoHaveQField(fieldName)
Asserts that the class has a @QField decorator on the specified property.
expect(dto).toHaveQField('email');
expect(dto).toHaveQField('username');
expect(dto).not.toHaveQField('internalSecret'); // not decoratedtoMatchQModel(expected)
Deep-asserts that the QModel instance matches all properties of expected. Uses $qSerialize() for the comparison, so @QComputed values are included.
const dto = new UserDto({ id: '1', name: 'Alice', role: 'admin' });
expect(dto).toMatchQModel({ id: '1', name: 'Alice' }); // partial matchtoBeIntact()
Asserts that the model has no dirty fields — $qIsDirty() returns false. Useful to verify freshly created instances haven't been accidentally mutated.
const dto = new UserDto({ id: '1', name: 'Alice' });
expect(dto).toBeIntact();
dto.name = 'Bob';
expect(dto).not.toBeIntact();toHaveDirtyField(fieldName)
Asserts that a specific field is dirty (has been mutated since the last snapshot).
const dto = new UserDto({ id: '1', name: 'Alice' });
dto.name = 'Bob'; // direct mutation tracks the field
expect(dto).toHaveDirtyField('name');
expect(dto).not.toHaveDirtyField('id');Note:
toHaveDirtyFielduses$qIsDirty(fieldName)which only tracks fields mutated directly (dto.field = value). UsetoBeIntact()/not.toBeIntact()to check if the model has any pending changes (including changes via$qCopy()).
Full Example
import { describe, test, expect, beforeEach } from 'vitest';
import { QModel, Quick, QRule, QField } from 'quickmodel';
@Quick(
{ id: 'string', username: 'string', email: 'string', age: 'number' },
{ unknownPropertyPolicy: 'strip' }
)
class UserDto extends QModel<{
id: string;
username: string;
email: string;
age: number;
}> {
@QField({ label: 'Username', required: true })
@QRule((v: string) => v.length >= 3, 'Username too short')
declare username: string;
@QField({ label: 'Email', widget: 'email', required: true })
@QRule((v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), 'Invalid email')
declare email: string;
@QField({ label: 'Age' })
@QRule((v: number) => v >= 18, 'Must be 18 or older')
declare age: number;
declare id: string;
}
describe('UserDto matchers', () => {
test('is a valid QModel', () => {
const dto = new UserDto({
id: '1',
username: 'alice',
email: 'alice@example.com',
age: 25,
});
expect(dto).toBeValidQModel();
});
test('fresh instance is intact', () => {
const dto = new UserDto({
id: '1',
username: 'alice',
email: 'alice@example.com',
age: 25,
});
expect(dto).toBeIntact();
});
test('detects dirty field after mutation', () => {
const dto = new UserDto({
id: '1',
username: 'alice',
email: 'alice@example.com',
age: 25,
});
dto.username = 'bob';
expect(dto).toHaveDirtyField('username');
expect(dto).not.toHaveDirtyField('email');
});
test('validates rule errors', () => {
const dto = new UserDto({
id: '1',
username: 'x',
email: 'bad',
age: 15,
});
expect(dto).toHaveQRuleError('username', 'too short');
expect(dto).toHaveQRuleError('email', 'Invalid email');
expect(dto).toHaveQRuleError('age', 'Must be 18 or older');
});
test('field annotations', () => {
const dto = new UserDto({
id: '1',
username: 'alice',
email: 'alice@example.com',
age: 25,
});
expect(dto).toHaveQField('username');
expect(dto).toHaveQField('email');
expect(dto).not.toHaveQField('id'); // not decorated with @QField
});
test('partial model match', () => {
const dto = new UserDto({
id: '1',
username: 'alice',
email: 'alice@example.com',
age: 25,
});
expect(dto).toMatchQModel({
username: 'alice',
email: 'alice@example.com',
});
});
});