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`);