chromium/chrome/test/data/webui/mock_controller.ts

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assertDeepEquals, assertEquals} from './chai_assert.js';

/**
 * Create a mock function that records function calls and validates against
 * expectations.
 */
export class MockMethod {
  /** List of signatures for function calls. */
  private calls_: any[][] = [];

  /** List of expected call signatures. */
  private expectations_: any[][] = [];

  /** Value returned from call to function. */
  returnValue: any|undefined = undefined;

  /** List of arguments for callback function. */
  callbackData: any[][] = [];

  /** Name of the function being replaced. */
  functionName: string|null = null;

  constructor() {
    const fn: MockMethod|Function = function() {
      const args = Array.prototype.slice.call(arguments);
      const callbacks = args.filter(function(arg) {
        return (typeof arg === 'function');
      });

      if (callbacks.length > 1) {
        console.error(
            'Only support mocking function with at most one callback.');
        return;
      }

      const fnAsMethod = fn as unknown as MockMethod;
      fnAsMethod.recordCall(args);
      if (callbacks.length === 1) {
        callbacks[0].apply(undefined, fnAsMethod.callbackData);
        return;
      }
      return fnAsMethod.returnValue;
    };

    const fnAsMethod = fn as unknown as MockMethod;
    Object.assign(fnAsMethod, this);
    Object.setPrototypeOf(fnAsMethod, MockMethod.prototype);
    return fnAsMethod;
  }

  /**
   * Adds an expected call signature.
   * @param args Expected arguments for the function call.
   */
  addExpectation(...args: any[]) {
    this.expectations_.push(args.filter(this.notFunction_));
  }

  /**
   * Adds a call signature.
   */
  recordCall(args: any[]) {
    this.calls_.push(args.filter(this.notFunction_));
  }

  /**
   * Verifies that the function is called the expected number of times and with
   * the correct signature for each call.
   */
  verifyMock() {
    let errorMessage = 'Number of method calls did not match expectation.';
    if (this.functionName) {
      errorMessage = 'Error in ' + this.functionName + ':\n' + errorMessage;
    }
    assertEquals(this.expectations_.length, this.calls_.length, errorMessage);
    for (let i = 0; i < this.expectations_.length; i++) {
      this.validateCall(i, this.expectations_[i]!, this.calls_[i]!);
    }
  }

  /**
   * Verifies that the observed function arguments match expectations.
   * Override if strict equality is not required.
   * @param index Canonical index of the function call. Unused in the
   *     base implementation, but provides context that may be useful for
   *     overrides.
   * @param expected The expected arguments.
   * @param observed The observed arguments.
   */
  validateCall(_index: number, expected: any[], observed: any[]) {
    assertDeepEquals(expected, observed);
  }

  /**
   * Test if arg is a function.
   * @param arg The argument to test.
   * @return True if arg is not function type.
   */
  private notFunction_(arg: any): boolean {
    return typeof arg !== 'function';
  }
}

interface Override {
  parent: {[key: string]: any};
  functionName: string;
  originalFunction: Function;
}

/**
 * Controller for mocking methods. Tracks calls to mocked methods and verifies
 * that call signatures match expectations.
 */
export class MockController {
  /**
   * Original functions implementations, which are restored when |reset| is
   * called.
   */
  private overrides_: Override[] = [];

  /** List of registered mocks. */
  private mocks_: MockMethod[] = [];

  /**
   * Creates a mock function.
   * @param parent Optional parent object for the function.
   * @param functionName Optional name of the function being
   *     mocked. If the parent and function name are both provided, the
   *     mock is automatically substituted for the original and replaced on
   *     reset.
   */
  createFunctionMock(parent?: {[key: string]: any}, functionName?: string) {
    const fn = new MockMethod();

    // Register mock.
    if (parent && functionName) {
      this.overrides_.push({
        parent: parent,
        functionName: functionName,
        originalFunction: parent[functionName],
      });
      parent[functionName] = fn;
      fn.functionName = functionName;
    }
    this.mocks_.push(fn);

    return fn;
  }

  /**
   * Validates all mocked methods. An exception is thrown if the
   * expected and actual calls to a mocked function to not align.
   */
  verifyMocks() {
    for (let i = 0; i < this.mocks_.length; i++) {
      this.mocks_[i]!.verifyMock();
    }
  }

  /**
   * Discard mocks reestoring default behavior.
   */
  reset() {
    for (let i = 0; i < this.overrides_.length; i++) {
      const override = this.overrides_[i]!;
      override.parent[override.functionName] = override.originalFunction;
    }
  }
}