Custom Matchers
Extend MCP Jest with custom validation logic and domain-specific matchers for more expressive tests.
Why Custom Matchers?
Custom matchers help you:
- • Express domain-specific validation logic clearly
- • Reuse complex validation patterns across tests
- • Create more readable and maintainable tests
- • Provide better error messages for failures
- • Validate business rules and constraints
Creating Custom Matchers
Basic Matcher
// matchers/email.js
function emailMatcher(value, options = {}) {
// Validate input type
if (typeof value !== 'string') {
return {
matches: false,
message: 'Expected a string value for email validation',
received: typeof value,
expected: 'string'
};
}
// Email validation regex
const emailRegex = /^[\w._%+-]+@[\w.-]+\.[A-Z]{2,}$/i;
const matches = emailRegex.test(value);
// Domain validation if specified
if (matches && options.domain) {
const domain = value.split('@')[1];
const domainMatches = domain === options.domain;
if (!domainMatches) {
return {
matches: false,
message: `Expected email from domain '${options.domain}'`,
received: domain,
expected: options.domain
};
}
}
return {
matches,
message: matches
? `Valid email: ${value}`
: `Invalid email format: ${value}`,
received: value,
expected: 'valid email format'
};
}
module.exports = emailMatcher;
Registering Matchers
Register your custom matcher in the configuration:
{
"name": "User API Tests",
"matchers": {
"email": "./matchers/email.js",
"phoneNumber": "./matchers/phone.js",
"uuid": "./matchers/uuid.js"
},
"server": { ... },
"tests": [
{
"name": "Should return valid user email",
"type": "tool",
"tool": "get-user",
"arguments": { "id": "123" },
"expect": {
"email": {
"matcher": "email",
"options": { "domain": "company.com" }
}
}
}
]
}
Advanced Matcher Examples
Date Range Matcher
// matchers/dateRange.js
function dateRangeMatcher(value, options = {}) {
// Parse the date
const date = new Date(value);
if (isNaN(date.getTime())) {
return {
matches: false,
message: `Invalid date: ${value}`,
received: value,
expected: 'valid date string'
};
}
const now = new Date();
const {
minDaysAgo = null,
maxDaysAgo = null,
future = false
} = options;
// Check if date is in the future when not allowed
if (!future && date > now) {
return {
matches: false,
message: `Date is in the future: ${value}`,
received: value,
expected: 'date in the past'
};
}
// Check minimum days ago
if (minDaysAgo !== null) {
const minDate = new Date(now - minDaysAgo * 24 * 60 * 60 * 1000);
if (date > minDate) {
return {
matches: false,
message: `Date is too recent. Expected at least ${minDaysAgo} days ago`,
received: value,
expected: `date before ${minDate.toISOString()}`
};
}
}
// Check maximum days ago
if (maxDaysAgo !== null) {
const maxDate = new Date(now - maxDaysAgo * 24 * 60 * 60 * 1000);
if (date < maxDate) {
return {
matches: false,
message: `Date is too old. Expected at most ${maxDaysAgo} days ago`,
received: value,
expected: `date after ${maxDate.toISOString()}`
};
}
}
return {
matches: true,
message: `Valid date in range: ${value}`,
received: value,
expected: 'date within specified range'
};
}
module.exports = dateRangeMatcher;
JSON Schema Matcher
// matchers/jsonSchema.js
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
function jsonSchemaMatcher(value, options = {}) {
const { schema, strict = true } = options;
if (!schema) {
return {
matches: false,
message: 'No schema provided for validation',
received: value,
expected: 'JSON schema in options'
};
}
// Create AJV instance
const ajv = new Ajv({ strict });
addFormats(ajv);
try {
const validate = ajv.compile(schema);
const valid = validate(value);
if (!valid) {
const errors = validate.errors?.map(error => ({
path: error.instancePath,
message: error.message,
data: error.data
})) || [];
return {
matches: false,
message: `JSON Schema validation failed`,
received: value,
expected: 'data matching JSON schema',
errors
};
}
return {
matches: true,
message: 'JSON Schema validation passed',
received: value,
expected: 'data matching JSON schema'
};
} catch (error) {
return {
matches: false,
message: `Schema compilation error: ${error.message}`,
received: value,
expected: 'valid JSON schema',
error: error.message
};
}
}
module.exports = jsonSchemaMatcher;
API Response Matcher
// matchers/apiResponse.js
function apiResponseMatcher(value, options = {}) {
const {
statusCode = 200,
hasHeaders = [],
contentType = null,
maxResponseTime = null
} = options;
// Validate structure
if (!value || typeof value !== 'object') {
return {
matches: false,
message: 'Expected API response object',
received: typeof value,
expected: 'object'
};
}
const { status, headers = {}, data, responseTime } = value;
// Check status code
if (status !== statusCode) {
return {
matches: false,
message: `Expected status code ${statusCode}, got ${status}`,
received: status,
expected: statusCode
};
}
// Check required headers
for (const header of hasHeaders) {
const headerValue = headers[header.toLowerCase()];
if (!headerValue) {
return {
matches: false,
message: `Missing required header: ${header}`,
received: Object.keys(headers),
expected: `header '${header}' to be present`
};
}
}
// Check content type
if (contentType) {
const actualContentType = headers['content-type'];
if (!actualContentType || !actualContentType.includes(contentType)) {
return {
matches: false,
message: `Expected content-type to include '${contentType}'`,
received: actualContentType,
expected: contentType
};
}
}
// Check response time
if (maxResponseTime && responseTime > maxResponseTime) {
return {
matches: false,
message: `Response too slow: ${responseTime}ms > ${maxResponseTime}ms`,
received: responseTime,
expected: `<= ${maxResponseTime}ms`
};
}
return {
matches: true,
message: 'Valid API response',
received: value,
expected: 'valid API response structure'
};
}
module.exports = apiResponseMatcher;
Using Custom Matchers in Tests
{
"name": "E-commerce API Tests",
"matchers": {
"email": "./matchers/email.js",
"dateRange": "./matchers/dateRange.js",
"jsonSchema": "./matchers/jsonSchema.js",
"apiResponse": "./matchers/apiResponse.js"
},
"tests": [
{
"name": "User registration should return valid response",
"type": "tool",
"tool": "register-user",
"arguments": {
"email": "test@example.com",
"password": "securepass123"
},
"expect": {
"response": {
"matcher": "apiResponse",
"options": {
"statusCode": 201,
"hasHeaders": ["location", "set-cookie"],
"contentType": "application/json",
"maxResponseTime": 2000
}
},
"user": {
"id": { "matcher": "uuid" },
"email": {
"matcher": "email",
"options": { "domain": "example.com" }
},
"createdAt": {
"matcher": "dateRange",
"options": { "maxDaysAgo": 0 }
}
}
}
},
{
"name": "Product data should match schema",
"type": "tool",
"tool": "get-product",
"arguments": { "id": "prod-123" },
"expect": {
"product": {
"matcher": "jsonSchema",
"options": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string", "pattern": "^prod-" },
"name": { "type": "string", "minLength": 1 },
"price": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "enum": ["USD", "EUR"] },
"tags": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["id", "name", "price", "currency"]
}
}
}
}
}
]
}
Async Matchers
For matchers that need to perform asynchronous operations:
// matchers/databaseRecord.js
async function databaseRecordMatcher(value, options = {}) {
const {
table,
idField = 'id',
checkExists = true,
connection
} = options;
if (!connection) {
return {
matches: false,
message: 'Database connection required',
received: value,
expected: 'database connection in options'
};
}
try {
const query = `SELECT * FROM ${table} WHERE ${idField} = $1`;
const result = await connection.query(query, [value]);
const exists = result.rows.length > 0;
if (checkExists && !exists) {
return {
matches: false,
message: `Record not found in ${table} with ${idField}=${value}`,
received: value,
expected: `existing record in ${table}`
};
}
if (!checkExists && exists) {
return {
matches: false,
message: `Record unexpectedly exists in ${table} with ${idField}=${value}`,
received: value,
expected: `no record in ${table}`
};
}
return {
matches: true,
message: checkExists
? `Record found in ${table}`
: `Record does not exist in ${table}`,
received: value,
expected: checkExists ? 'existing record' : 'non-existing record',
data: exists ? result.rows[0] : null
};
} catch (error) {
return {
matches: false,
message: `Database query failed: ${error.message}`,
received: value,
expected: 'successful database query',
error: error.message
};
}
}
module.exports = databaseRecordMatcher;
Matcher Composition
Combine multiple matchers for complex validation:
// matchers/composite.js
function compositeMatcher(value, options = {}) {
const { matchers = [] } = options;
for (const matcherConfig of matchers) {
const { name, matcher, options: matcherOptions } = matcherConfig;
// Load and execute matcher
const matcherFn = require(`./${matcher}`);
const result = matcherFn(value, matcherOptions);
if (!result.matches) {
return {
matches: false,
message: `Composite validation failed at '${name}': ${result.message}`,
received: value,
expected: result.expected,
failedMatcher: name
};
}
}
return {
matches: true,
message: 'All composite validations passed',
received: value,
expected: 'value passing all composite matchers'
};
}
module.exports = compositeMatcher;
Usage:
{
"expect": {
"userEmail": {
"matcher": "composite",
"options": {
"matchers": [
{
"name": "format",
"matcher": "email"
},
{
"name": "domain",
"matcher": "email",
"options": { "domain": "company.com" }
},
{
"name": "database",
"matcher": "databaseRecord",
"options": {
"table": "users",
"idField": "email",
"checkExists": true
}
}
]
}
}
}
}
Best Practices
✅ Matcher Best Practices
- • Provide clear, descriptive error messages
- • Include both received and expected values
- • Make matchers reusable across different tests
- • Add comprehensive input validation
- • Document matcher options and behavior
- • Use TypeScript for better type safety
❌ Common Mistakes
- • Creating overly complex matchers
- • Not handling edge cases and errors
- • Tight coupling to specific data formats
- • Poor error messages that don't help debugging
- • Not testing the matchers themselves
🎯 Custom Matcher Power
Custom matchers make your tests more expressive and maintainable. Build a library of domain-specific validations for better test quality.
Learn About Performance Optimization →