core/test_suite.js

import { isFunction, isStringWithContent, isUndefined, shuffle } from '../utils.js';

/**
 * I represent a grouping of [tests]{@link Test} under a name, that will be executed by a
 * [runner]{@link TestRunner}. I can run some code [before]{@link TestSuite#before} and
 * [after]{@link TestSuite#after} each test.
 */
export class TestSuite {
  #name;
  #body;
  #tests;
  #currentTest;
  #callbacks;
  #before;
  #after;
  #isSkipped;

  static BEFORE_HOOK_NAME = 'before';
  static AFTER_HOOK_NAME = 'after';

  // Error messages

  static invalidSuiteNameErrorMessage() {
    return 'Suite does not have a valid name. Please enter a non-empty string to name this suite.';
  }

  static invalidSuiteDefinitionBodyErrorMessage() {
    return 'Suite does not have a valid body. Please provide a function to declare the suite body.';
  }

  static alreadyDefinedHookErrorMessage(hookName) {
    return `There is already a ${hookName}() block. Please leave just one ${hookName}() block and run again the tests.`;
  }

  static hookWasNotInitializedWithAFunctionErrorMessage(hookName) {
    return `The ${hookName}() hook must include a function. Please provide a function or remove the ${hookName}() and run again the tests.`;
  }

  // Instance creation

  constructor(name, body, callbacks) {
    this.#initializeName(name);
    this.#initializeBody(body);
    this.#tests = [];
    this.#callbacks = callbacks;
    this.#before = undefined;
    this.#after = undefined;
    this.#isSkipped = false;
  }

  // Initializing / Configuring

  /**
   * Adds a new test to the suite.
   *
   * @param {!Test} test the test we'd like to add.
   * @returns {void}
   */
  addTest(test) {
    this.#tests.push(test);
  }

  /**
   * Registers a piece of code that should be executed before each test. There should be one `before` per suite, so it
   * will fail if there's already a `before` block in the suite.
   *
   * @param {!Function} initializationBlock the code you'd like to execute before each test.
   * @returns {void}
   */
  before(initializationBlock) {
    this.#validateHook(TestSuite.BEFORE_HOOK_NAME, initializationBlock, this.#before);

    this.#before = initializationBlock;
  }

  /**
   * Registers a piece of code that should be executed after each test. There should be one `after` per suite, so it
   * will fail if there's already an `after` block in the suite.
   *
   * @param {!Function} releasingBlock the code you'd like to execute after each test.
   * @returns {void}
   */
  after(releasingBlock) {
    this.#validateHook(TestSuite.AFTER_HOOK_NAME, releasingBlock, this.#after);

    this.#after = releasingBlock;
  }

  // Skipping
  skip() {
    this.#isSkipped = true;
  }

  isSkipped() {
    return this.#isSkipped;
  }

  #skipAllTests() {
    this.tests().forEach(test => {
      test.skip();
      test.finishWithSkippedStatus();
    });
  }

  // Executing

  async run(context) {
    this.#callbacks.onStart(this);
    this.#evaluateSuiteDefinition();
    if (this.isSkipped()) {
      this.#skipAllTests();
    } else {
      await this.#runTests(context);
    }
    this.#callbacks.onFinish(this);
  }

  // Counting

  totalCount() {
    return this.tests().length;
  }

  successCount() {
    return this.tests().filter(test => test.isSuccess()).length;
  }

  pendingCount() {
    return this.tests().filter(test => test.isPending()).length;
  }

  errorsCount() {
    return this.tests().filter(test => test.isError()).length;
  }

  skippedCount() {
    return this.tests().filter(test => test.isSkipped() || test.isExplicitlySkipped()).length;
  }

  failuresCount() {
    return this.totalCount() - this.successCount() - this.pendingCount() - this.errorsCount() - this.skippedCount();
  }

  // Accessing

  name() {
    return this.#name;
  }

  currentTest() {
    return this.#currentTest;
  }

  tests() {
    return this.#tests;
  }

  allFailuresAndErrors() {
    return this.tests().filter(test => test.isFailure() || test.isError());
  }

  // Private - Validations

  #initializeBody(body) {
    this.#ensureBodyIsValid(body);
    this.#body = body;
  }

  #initializeName(name) {
    this.#ensureNameIsValid(name);
    this.#name = name;
  }

  #ensureNameIsValid(name) {
    if (!isStringWithContent(name)) {
      throw new Error(TestSuite.invalidSuiteNameErrorMessage());
    }
  }

  #ensureBodyIsValid(body) {
    if (!isFunction(body)) {
      throw new Error(TestSuite.invalidSuiteDefinitionBodyErrorMessage());
    }
  }

  #validateHook(hookName, hookToSet, existingHook) {
    this.#validateTheHookIsNotAlreadyDeclared(existingHook, hookName);
    this.#validateTheHookBlockIsAFunction(hookToSet, hookName);
  }

  #validateTheHookIsNotAlreadyDeclared(hookBlock, hookName) {
    if (!isUndefined(hookBlock)) {
      throw new Error(TestSuite.alreadyDefinedHookErrorMessage(hookName));
    }
  }

  #validateTheHookBlockIsAFunction(hookBlock, hookName) {
    if (!isFunction(hookBlock)) {
      throw new Error(TestSuite.hookWasNotInitializedWithAFunctionErrorMessage(hookName));
    }
  }

  // Private - Running

  #randomizeTests() {
    shuffle(this.tests());
  }

  #evaluateSuiteDefinition() {
    this.#body.call();
    this.#evaluateExclusiveRunsMarks();
  }

  #evaluateExclusiveRunsMarks() {
    if (this.#thereAreTestsMarkedAsExclusive()) {
      this.#skipTestsNotMarkedAsExclusive();
    }
  }

  #thereAreTestsMarkedAsExclusive() {
    return this.tests().some(test => test.isMarkedAsExclusiveForRun());
  }

  #skipTestsNotMarkedAsExclusive() {
    this.tests().forEach(test => {
      if (!test.isMarkedAsExclusiveForRun()) {
        test.skip();
      }
    });
  }

  async #runTests(context) {
    if (context.randomOrderMode) {
      this.#randomizeTests();
    }

    context.hooks = {
      [TestSuite.BEFORE_HOOK_NAME]: this.#before,
      [TestSuite.AFTER_HOOK_NAME]: this.#after,
    };

    // eslint-disable-next-line no-restricted-syntax
    for (const test of this.tests()) {
      // NOTE: test will run in sequence, by design. The tool is not (yet) supporting parallel execution.
      this.#currentTest = test;

      // eslint-disable-next-line no-await-in-loop
      await test.run(context);
    }
  }
}