Scripting GuideCustom Assertions

Custom Assertion Functions

Custom Assertion functions allow you to create reusable, complex validation logic that can be applied throughout your tests. They’re perfect for encapsulating business rules, data structure validations, and domain-specific checks that go beyond simple equality assertions.

What Are Custom Assertions?

Custom Assertions are functions that:

  • Validate complex business rules (e.g., “order total matches itemized calculation”)
  • Check data structure integrity (e.g., “user object has required fields with correct types”)
  • Perform domain-specific validations (e.g., “payment meets PCI compliance rules”)
  • Provide clear, descriptive error messages when validation fails

Key advantages:

  • Reusable - Write once, use in hundreds of tests
  • Self-documenting - Clear assertion names describe what’s being checked
  • Consistent validation - Same rules applied everywhere
  • Better error messages - Custom messages explain exactly what failed

Script Pattern

A custom assertion function takes one, two, or three parameters and reports an assertion result using $addAssertionResult().

// Single parameter assertion
async function isUser(value) {
  try {
    const isValid =
      value &&
      typeof value === "object" &&
      typeof value.id === "number" &&
      typeof value.name === "string";
 
    $addAssertionResult({
      passed: isValid,
      message: isValid ? "Value is a valid user" : "Value is not a valid user",
      operator: "isUser",
      leftValue: value,
      rightValue: "user object",
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error validating user: ${error.message}`,
      operator: "isUser",
      leftValue: value,
      rightValue: "user object",
    });
  }
}
 
// Two parameter assertion
async function isFartherThan(distance1, distance2) {
  try {
    const result = distance1 > distance2;
 
    $addAssertionResult({
      passed: result,
      message: result
        ? `${distance1} is farther than ${distance2}`
        : `${distance1} is not farther than ${distance2}`,
      operator: "isFartherThan",
      leftValue: distance1,
      rightValue: distance2,
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error comparing distances: ${error.message}`,
      operator: "isFartherThan",
      leftValue: distance1,
      rightValue: distance2,
    });
  }
}

Key Points

  • Function Name: Can be any valid JavaScript function name (not fixed like hooks)
    • Important: The function name must match the title you give it in the UI
    • Example: UI title “Is Valid User” → function name isValidUser
  • Parameters: Accepts one, two, or three parameters
    • One parameter: isValidUser(value)
    • Two parameters: isGreaterThan(actual, expected)
    • Three parameters: isCloseTo(actual, expected, delta) - the third parameter is optional and can be used for configuration
  • Assertion Result: Must call $addAssertionResult() to report the outcome
  • Error Handling: Use try...catch to handle errors and report them as failed assertions
  • Async/Sync: Can be async or synchronous
  • Zod Integration: Use Zod’s safeParse() (not parse()) to avoid throwing errors

Assertion Result Structure

The $addAssertionResult() function expects an object with this structure:

{
  passed: boolean,        // Whether the assertion passed
  message: string,        // Human-readable description
  operator: string,       // The assertion operation name (usually the function name)
  leftValue: any,         // The actual value being tested
  rightValue: any         // The expected value or comparison target
}

Pattern: Use Zod with safeParse()

Best practice: Use Zod’s safeParse() method in assertions to avoid throwing errors. Custom assertions should never throw—always report results via $addAssertionResult().

Why safeParse() instead of parse()?

// ❌ Bad: parse() throws on invalid data
const result = Schema.parse(value); // Throws ZodError, UI doesn't catch it properly
 
// ✅ Good: safeParse() returns success/error object
const result = Schema.safeParse(value);
if (result.success) {
  // Validation passed
} else {
  // Validation failed, result.error contains details
}

Example: Zod-Powered Assertion

import { z } from "zod";
 
// Define schema for a valid user
const UserSchema = z.object({
  id: z.number().positive(),
  email: z.string().email(),
  name: z.string().min(1),
  role: z.enum(["admin", "user", "guest"]),
  createdAt: z.number().positive(),
});
 
async function isValidUser(value) {
  try {
    const result = UserSchema.safeParse(value);
 
    $addAssertionResult({
      passed: result.success,
      message: result.success
        ? "Value is a valid user object"
        : `Invalid user: ${result.error.errors
            .map((e) => e.message)
            .join(", ")}`,
      operator: "isValidUser",
      leftValue: value,
      rightValue: "valid user schema",
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error validating user: ${error.message}`,
      operator: "isValidUser",
      leftValue: value,
      rightValue: "valid user schema",
    });
  }
}

Benefits:

  • ✅ Clear error messages from Zod (e.g., “Expected number, received string”)
  • ✅ No exception throwing - always reports via $addAssertionResult()
  • ✅ Comprehensive validation - type + format + business rules

Common Patterns

Pattern 1: Simple Schema Validation

import { z } from "zod";
 
const OrderSchema = z.object({
  id: z.string().uuid(),
  status: z.enum(["pending", "approved", "rejected"]),
  amount: z.number().positive(),
  currency: z.string().length(3),
});
 
async function isValidOrder(order) {
  try {
    const result = OrderSchema.safeParse(order);
 
    $addAssertionResult({
      passed: result.success,
      message: result.success
        ? "Order is valid"
        : `Invalid order: ${result.error.errors[0].message}`,
      operator: "isValidOrder",
      leftValue: order,
      rightValue: "valid order schema",
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error: ${error.message}`,
      operator: "isValidOrder",
      leftValue: order,
      rightValue: "valid order schema",
    });
  }
}

Pattern 2: Complex Business Rules

import { z } from "zod";
 
async function isValidMetricWithSLA(metric) {
  try {
    // First validate structure with Zod
    const parseResult = $$AnalyticsUtils.schemas.metric.safeParse(metric);
 
    if (!parseResult.success) {
      $addAssertionResult({
        passed: false,
        message: `Invalid metric schema: ${parseResult.error.errors[0].message}`,
        operator: "isValidMetricWithSLA",
        leftValue: metric,
        rightValue: "valid metric with SLA",
      });
      return;
    }
 
    // Then check business rules
    const withinSLA = $$AnalyticsUtils.isWithinSLA(metric.value, metric.unit);
    const hasValidRange = $$AnalyticsUtils.isInValidRange(metric.value);
 
    const passed = withinSLA && hasValidRange;
 
    $addAssertionResult({
      passed,
      message: passed
        ? "Metric is valid and meets SLA requirements"
        : `Metric fails: ${!withinSLA ? "SLA violation" : "Out of range"}`,
      operator: "isValidMetricWithSLA",
      leftValue: metric,
      rightValue: "SLA threshold",
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error validating metric: ${error.message}`,
      operator: "isValidMetricWithSLA",
      leftValue: metric,
      rightValue: "valid metric with SLA",
    });
  }
}

Pattern 3: Leverage Global Schemas

// Define schemas in Global Scripts for reuse
class $$Schemas {
  static user = z.object({
    id: z.number().positive(),
    email: z.string().email(),
    role: z.enum(["admin", "user", "guest"]),
  });
 
  static payment = z.object({
    amount: z.number().positive(),
    currency: z.string().length(3),
    status: z.enum(["pending", "approved", "rejected"]),
    transactionId: z.string().uuid(),
  });
 
  static apiResponse = z.object({
    status: z.string(),
    data: z.any(),
    timestamp: z.number().positive(),
  });
}
 
// Use in assertions
async function isValidPayment(payment) {
  try {
    const result = $$Schemas.payment.safeParse(payment);
 
    $addAssertionResult({
      passed: result.success,
      message: result.success
        ? "Payment is valid"
        : `Invalid payment: ${result.error.errors
            .map((e) => `${e.path.join(".")}: ${e.message}`)
            .join(", ")}`,
      operator: "isValidPayment",
      leftValue: payment,
      rightValue: "valid payment schema",
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error: ${error.message}`,
      operator: "isValidPayment",
      leftValue: payment,
      rightValue: "valid payment schema",
    });
  }
}

Pattern 4: Two-Parameter Comparisons

async function isGreaterThanThreshold(actual, threshold) {
  try {
    const passed =
      typeof actual === "number" &&
      typeof threshold === "number" &&
      actual > threshold;
 
    $addAssertionResult({
      passed,
      message: passed
        ? `${actual} is greater than threshold ${threshold}`
        : `${actual} is not greater than threshold ${threshold}`,
      operator: "isGreaterThanThreshold",
      leftValue: actual,
      rightValue: threshold,
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error comparing values: ${error.message}`,
      operator: "isGreaterThanThreshold",
      leftValue: actual,
      rightValue: threshold,
    });
  }
}

Pattern 5: Three-Parameter Assertions with Optional Configuration

The third parameter is optional and can be used for configuration values like tolerances, flags, or precision settings. Your function should handle the case where the third parameter is undefined.

// Example: Custom assertion with optional delta parameter
async function isApproximately(actual, expected, delta = 0.01) {
  try {
    // Validate types
    if (typeof actual !== "number" || typeof expected !== "number") {
      $addAssertionResult({
        passed: false,
        message: "Both actual and expected values must be numbers",
        operator: "isApproximately",
        leftValue: actual,
        rightValue: expected,
      });
      return;
    }
 
    // Validate delta if provided
    if (delta !== undefined && (typeof delta !== "number" || delta < 0)) {
      $addAssertionResult({
        passed: false,
        message: `Delta must be a positive number, got ${delta}`,
        operator: "isApproximately",
        leftValue: actual,
        rightValue: expected,
      });
      return;
    }
 
    // Perform comparison
    const diff = Math.abs(actual - expected);
    const passed = diff <= delta;
 
    $addAssertionResult({
      passed,
      message: passed
        ? `${actual} is approximately ${expected} (±${delta})`
        : `${actual} is not approximately ${expected} (±${delta}), difference is ${diff}`,
      operator: "isApproximately",
      leftValue: actual,
      rightValue: expected,
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error comparing values: ${error.message}`,
      operator: "isApproximately",
      leftValue: actual,
      rightValue: expected,
    });
  }
}

Important Notes for Three-Parameter Assertions:

  • Third parameter is optional - The engine does not validate it; your function must handle undefined
  • Use default parameters - JavaScript default parameters (delta = 0.01) work well
  • Validate parameters - Check types and ranges within your function
  • Clear error messages - Explain what went wrong if validation fails

Pattern 6: Complex Data Integrity Checks

import { z } from "zod";
 
async function hasValidDataIntegrity(data) {
  try {
    // Validate structure
    const schema = z.object({
      content: z.any(),
      hash: z.string(),
      timestamp: z.number(),
    });
 
    const parseResult = schema.safeParse(data);
    if (!parseResult.success) {
      $addAssertionResult({
        passed: false,
        message: `Invalid data structure: ${parseResult.error.errors[0].message}`,
        operator: "hasValidDataIntegrity",
        leftValue: data,
        rightValue: "valid data with integrity hash",
      });
      return;
    }
 
    // Check hash integrity
    const expectedHash = $$DataUtils.generateHash(data.content);
    const passed = data.hash === expectedHash;
 
    $addAssertionResult({
      passed,
      message: passed
        ? "Data integrity verified"
        : `Hash mismatch: expected ${expectedHash}, got ${data.hash}`,
      operator: "hasValidDataIntegrity",
      leftValue: data.hash,
      rightValue: expectedHash,
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error checking integrity: ${error.message}`,
      operator: "hasValidDataIntegrity",
      leftValue: data,
      rightValue: "valid data with integrity hash",
    });
  }
}

Best Practices

1. Always Use safeParse(), Never parse()

// ✅ Good: safeParse() doesn't throw
const result = Schema.safeParse(value);
if (result.success) {
  /* ... */
}
 
// ❌ Bad: parse() throws ZodError
const result = Schema.parse(value); // Throws on invalid data

2. Always Wrap in Try-Catch

// ✅ Good: Catches unexpected errors
async function isValidUser(value) {
  try {
    const result = UserSchema.safeParse(value);
    $addAssertionResult({
      /* ... */
    });
  } catch (error) {
    $addAssertionResult({
      passed: false,
      message: `Error: ${error.message}`,
      /* ... */
    });
  }
}
 
// ❌ Bad: Unhandled errors crash the test
async function isValidUser(value) {
  const result = UserSchema.safeParse(value); // What if UserSchema is undefined?
  $addAssertionResult({
    /* ... */
  });
}

3. Provide Descriptive Error Messages

// ✅ Good: Clear, specific error message
$addAssertionResult({
  passed: false,
  message: `User email is invalid: expected format "user@domain.com", got "${value.email}"`,
  /* ... */
});
 
// ❌ Bad: Vague error message
$addAssertionResult({
  passed: false,
  message: "Invalid",
  /* ... */
});

4. Use Global Schemas for Consistency

// ✅ Good: Reuse schema from Global Scripts
const result = $$Schemas.user.safeParse(value);
 
// ❌ Bad: Duplicate schema in every assertion
const UserSchema = z.object({
  /* ... */
}); // Duplicated across multiple functions

5. Combine Zod with Business Logic

// ✅ Good: Validate structure first, then business rules
async function isValidOrder(order) {
  try {
    // Structure validation
    const parseResult = $$Schemas.order.safeParse(order);
    if (!parseResult.success) {
      $addAssertionResult({ passed: false /* ... */ });
      return;
    }
 
    // Business rules
    const meetsMinimum = order.total >= 10;
    const hasValidDiscount = order.discount <= order.subtotal * 0.5;
 
    const passed = meetsMinimum && hasValidDiscount;
 
    $addAssertionResult({
      passed,
      message: passed ? "Order is valid" : "Order violates business rules",
      /* ... */
    });
  } catch (error) {
    $addAssertionResult({ passed: false /* ... */ });
  }
}

Key Takeaways

  1. Function Name Must Match UI Title - Not fixed like hooks
  2. One, Two, or Three Parameters - For validation, comparison, and configuration
  3. Third Parameter is Optional - Engine doesn’t validate it; your function must handle undefined
  4. Use safeParse() Not parse() - Never throw errors in assertions
  5. Always Try-Catch - Catch unexpected errors and report them
  6. Descriptive Messages - Clear error messages help debugging
  7. Leverage Global Schemas - Reuse Zod schemas from $$Schemas
  8. Combine Validation + Business Rules - Structure first, logic second
  9. Never Throw - Always report via $addAssertionResult()

For comprehensive examples, see Build a Testing Framework.