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 →