@selfage/test_basedeprecated

Testing framework.

Usage no npm install needed!

<script type="module">
  import selfageTestBase from 'https://cdn.skypack.dev/@selfage/test_base';
</script>

README

@selfage/test_base

Install

npm install @selfage/test_base

Overview

Written in TypeScript. Provides a minimalist framework for local testing. Compiled to ES6.

For every test file, import @selfage/test_base/runner to define a test set and multiple cases to run. Multiple test files can be imported into a single "test_suite.ts" file to be run together.

Import @selfage/test_base/matcher to assert basic statements or match simple data structures, or define customized matchers for complicated data structures.

Simple test

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

// math_test.ts
import { add } from './math';
import { TEST_RUNNER, Environment } from "@selfage/test_base/runner";
import { assertThat, eq } from "@selfage/test_base/matcher";

class ComplicatedEnv implements Environment {
  public setUp(): Promise<void> {
    // ...
  }
  public tearDown(): Promise<void> {
    // ...
  }
}

TEST_RUNNER.run({
  // The name of this test set.
  name: "MathTest",
  // If you need to set up an environment for the entire test set, you can add
  // it like this.
  // environment: new ComplicatedEnv(),
  // Or define it inline.
  // environment: { setUp: () => {...}, tearDown: () => {...} },
  cases: [
    {
      // The name of each test case.
      name: "UnderTen",
      // It can also be an async function.
      execute: () => {
        // Execute
        let res = add(1, 2);

        // Verify
        assertThat(res, eq(3));
      }
    }
  ]
});

After compiled with tsc, you can execute the test file via node math_test.js, which executes all test cases in this file and outputs success or failure of each case to console. Note that logs to console in each test case is ignored.

math_test.js is a runnable file taking two command line arguments: --set-name or -s, and --case-name or -c. (node math_test.js -h brings up help menu.)

node math_test.js -c UnderTen would only execute the test case UnderTen and all logs to console are output as usual to help debug.

node math_test.js -s MathTest would only execute the test set MathTest which is helpful in a test suite.

Test suite

Suppose we have 3 test files: math_test.ts, handler_test.ts, element_test.ts. The test_suite.ts contains the following.

import './math_test';
import './handler_test';
import './element_test';
// That's it!

After compiled with tsc, you can execute it via node test_suite.js, which executes the test set in each test file and outputs success or failure of each case to console. It's helpful to include all tests in a project that needs to pass before, e.g., commiting or releasing. Also logs to console in each test case is ignored.

test_suite.js is a runnable file that takes -s and -c, just like math_test.js.

node test_suite.js -s MathTest makes more sense in that it only executes the test set MathTest.

node test_suite.js -s MathTest -c UnderTen would only execute the test case UnderTen from the test set MathTest.

Simple matcher

A matcher means a function that returns MatchFn<T> such as eq() and containStr(). It's best to be used together with assertThat() to populate error messages, and reads more naturally.

// import other stuff...
import {
  assertThat, eq, containStr, eqArray, eqSet, eqMap
} from "@selfage/test_base/matcher";

// Usually called within a test case, if assertThat() fails, it throws an error
// which fails the rest of the test case.

// Equality is checked via `===`. Any type can be compared. The last argument
// `targetName` is filled into the error message `When matching ${targetName}:`,
// when assertion failed. In a test case, you can simply use the variable name,
// or anything you want to help you understand which assertion failed. You will
// see more why this is helpful in debugging when customizing matchers.
assertThat(actual, eq(expected), `actual`);

// `actual` must be a string that contains the expected text.
assertThat(actual, containStr('expected text'), `actual`);

// `actual` must be an array of only one type.
// eqArray() takes an array of matchers to match each element from `actual`.
assertThat(actual, eqArray([eq(1), eq(2)]), `actual`);
// Therefore it can also be used like this.
assertThat(
  actual,
  eqArray([containStr('expected1'), containStr('expected2')]),
  `actual`);
// If `actual` is undefined, it can be matched as the following.
assertThat(actual, eqArray(), `actual`);

// `actual` must be a Set of only one type.
// eqSet() also takes an array of matchers just like eqArray() to match Set in
// insertion order.
assertThat(actual, eqSet([eq(1), eq(2)]), `actual`);
// If `actual` is undefined, it can be matched as the following.
assertThat(actual, eqSet(), `actual`);

// `actual` must be a Map of one key type and one value type.
// eqMap() takes an array of pairs of matchers, to match key and value in
// insertion order.
assertThat(
  actual,
  eqMap([[eq('key'), eq('value')], [eq('key2'), eq('value2')]]),
  `actual`);
// If `actual` is undefined, it can be matched as the following.
assertThat(actual, eqMap(), `actual`);

Assert upon error

Often we need to assert when a function throws an error, which can be helped by assertRejection(), assertThrow(), and eqError().

import {
  assertThat, assertRejection, assertThrow, eqError
} from '@selfage/test_base/matcher';

// Suppose we are in an async function.
let e = await assertRejection(() => {
  return Promise.reject(new Error('It has to fail.'));
});
// `eqError()` expects the `actual` to be of `Error` type, to have the expected
// error name, and to contain (not equal to) the error message.
assertThat(e, eqError(new Error('has to')), `e`);

// If the function doesn't return a Promise.
let e = assertThrow(() => {
  // Suppose we are calling some function that would throw an error.
  throw an Error('It has to fail.');
});
assertThat(e, eqError(new Error('has to')), `e`);

Customized matcher

Once you have your own data class, it becomes a pain to match each field in each test. A customized matcher can help to ease the matching process.

import {
  assert, assertThat, eq, eqArray, MatchFn
} from '@selfage/test_base/matcher';

// Suppose we define the following interfaces as data classes.
interface User {
  id?: number;
  name?: string;
  channelIds?: number[];
  creditCard?: CreditCard[];
}

interface CreditCard {
  cardNumber?: string;
  cvv?: string;
}

// The eventual goal is to define a matcher that works like the following, where
// both `actual` and `expected` follow the interface definition, but are of
// different instances, i.e., they are not equal by `===`.
assertThat(actual, eqUser(expected), `actual`);

// The `T` in `MatchFn<T>` is the usually the type of the actual value you want
// to match. In this case, `T` should be `User`. eqUser() is just an example
// name which is really up to you. But following this naming convention makes
// the assertion statement reads more naturally. 
function eqUser(expected?: User): MatchFn<User> {
  // `MatchFn<T>` is just an alias of a function type.
  // type MatchFn<T> = (actual: T) => void;
  // You will need to compare the actual value with the expected value in the
  // function body, and throw an error if anything is not matched, instead of
  // returning a boolean if you were wondering.
  return (actual) => {
    // When using assertThat(), the last argument `targetName` is used to
    // construct `When matching ${targetName}:` and it needs to be descriptive
    // enough for you to locate the failure within this matcher, because
    // `eqUser()` can be used inside other matchers, such as eqArray().
    if (expected === undefined) {
      // Supports matching when we expect `actual` to be undefined.
      assertThat(actual, eq(undefined), `nullity`);
    }
    assertThat(actual.id, eq(expected.id), `id field`);
    assertThat(actual.name, eq(expected.name), `name field`);
    // Because we can expect `actual.channelIds` to be undefined, the expected
    // array needs to be undefined in that case as well.
    let channelIds: MatchFn<number>[];
    if (expected.channelIds) {
      // Because eqArray() takes an array of matchers, we need to convert
      // `expected.channelIds` into `MatchFn<number>[]`.
      channelIds = expected.channelIds.map((channelId) => eq(channelId));
    }
    assertThat(actual.channelIds, eqArray(channelIds), `channelIds field`);
    // Similarly, let's convert `expected.creditCards` into
    // `MatchFn<CreditCard>[]`.
    let creditCards: MatchFn<CreditCard>[];
    if (expected.creditCards) {
      // Well eqCreditCard() doesn't exist. Let's define it below.
      creditCards = expected.creditCards.map(
        (channelId) => eqCreditCard(channelId)
      );
    }
    assertThat(actual.creditCards, eqArray(creditCards), `creditCards field`);
  };
}

function eqCreditCard(expected?: CreditCard): MatchFn<CreditCard> {
  return (actual) => {
    if (expected === undefined) {
      assertThat(actual, eq(undefined), `nullity`);
    }
    assertThat(actual.cardNumber, eq(expected.cardNumber), `cardNumber field`);
    assertThat(actual.cvv, eq(expected.cvv), `cvv field`);
    // If there are no exisitng matchers to help, you can fallback to use
    // `assert()`.
    assert(
      /^[0-9]$/.test(actual.cardNumber),
      `cardNumber to be of numbers only`,
      actual.cardNumber
    );
    // Or fallback to simply throw an error, if we re-write the above assertion
    // as the following.
    if (!(/^[0-9]$/.test(actual.cardNumber))) {
      throw Error(
        `Expect cardNumber to be of numbers only but it actually is ` +
        `${actual.cardNumber}.`
      );
    }
  };
}

// The input to a matcher is also up to you, as long as it returns `MatchFn<T>`.
// Hard-coded expected user.
function eqACertainUser(): MatchFn<User> {
  return (actual) => {
    // Match `actual` with a const User instance.
  };
}
assertThat(actual, eqACertainUser(), `actual`);
// Options to ignore certain fields.
function eqUserWithOptions(expected?: User, ignoreId: boolean): MatchFn<User> {
  return (actual) => {
    // Same as `eqUser()` except don't assert on id field, if `ignoreId` is
    // true.
  };
}
assertThat(actual, eqUserWithOptions(expected, true), `actual`);