ts-guts

A simple, yet all-inclusive unit test framework with built-in mocking and assertions with Typescript support.

Usage no npm install needed!

<script type="module">
  import tsGuts from 'https://cdn.skypack.dev/ts-guts';
</script>

README

ts-test

Build Status Release Status semantic-release

An all-inclusive unit test framework with built-in mocking and assertions. Under the hood, it currently uses tape for tap-compatible tests and assertions, and sinon for creating mock functions and objects.

The goals for this package are:

  • To make it easier to set up unit testing for new node projects.

    • Provide a single, inclusive, easy-to-use package for unit testing.
    • Includes mocking, assertion, and test coverage.
    • Built-in support for Typescript and ES6 Promises.
  • To make it easier to write and read Good Unit Tests (GUTs).

    • A reliable, TS-compatible mocking utility isolate, built around immutability, which obviates the need for hacking around ES6 modules and for most teardown functions. All of this means that tests are more reliable and more difficult to get wrong.
    • An opinionated and much simplified API with straightforward and comprehensive documentation.
  • To help future proof.

    • To be a wrapper so that it's easy to transition to other underlying test frameworks in the future. Future versions may even an an option for which framework to use as an engine.

Installation

  1. Install from npm:

    npm i ts-test -DE
    
  2. Create an .nycrc file in the root of your project with the following:

{
    "extends": "@istanbuljs/nyc-config-typescript",
    "all": true,
    "exclude": [
        "**/*.spec.ts",
        "**/*.d.ts",
        "**/*.spec.js",
        "coverage",
        ".eslintrc.js",
        ".eslintrc",
        "tests",
        "test",
        "test_utils"
    ]
}
  1. Add "ts-test" command to your npm test script:
"scripts": {
    "test": "ts-test"
    ...
}

Basic Usage

Write your tests with a .spec.ts extension. To run your tests and get a coverage report, simply run npm test.

foo.ts

export function foo() { return 'flooble'; }

foo.spec.ts

import {test} from 'ts-test';
import {foo} from './foo';

test('foo()', async (assert) => {
    assert.plan(1);
    assert.deepEqual(foo(), 'flooble', 'should return "flooble"');
});

Examples

See examples directory for all examples. Below is an example utilizing mocking.

multiply.ts

export function multiply(num1: number, num2: number) {
    return num1 * num2;
}

exponent.ts

import {multiply} from './multiply';

export function exponent(base: number, exp: number): number {
    if (exp < 0) throw new Error('exponent must be >= 0');
    switch (exp) {
        case 0:
            return 1;
        case 1:
            return base;
        default:
            return multiply(base, exponent(base, exp - 1));
    }
}

exponent.spec.ts

import {isolate, test} from 'ts-test';
import * as exponentModule from './exponent'; //used only for typing

test('exponent(): alternate reality where multiplication is actually addition', (assert) => {
    //mock out ./mulitpy import
    //`typeof exponentModule` is used for typing
    const mockExp: typeof exponentModule = isolate('./exponent', {imports: [
        ['./multiply', {multiply: (a: number, b: number) => a + b}]
    ]});

    assert.plan(4);
    assert.deepEqual(mockExp.exponent(1, 0), 1, 'exponent(1, 0) should be 1');
    assert.deepEqual(mockExp.exponent(2, 4), 8, 'exponent(2, 4) should be 8');
    assert.deepEqual(mockExp.exponent(3, 3), 9, 'exponent(3, 3) should be 9');
});

API

test(title: string, cb: (assert: Assert) => void, options?: TestOptions): void

Add a test to be picked up by the test runner. cb can be an async function or ES6 Promise.

skip(title: string, cb: (assert: Assert) => void, options?: TestOptions): void

An alias for test with markAs = 'skip'.

only(title: string, cb: (assert: Assert) => void, options?: TestOptions): void

An alias for test with markAs = 'only'.

suite(suiteTitle: string): {test, skip, only}

Creates a grouping for tests. It simply appends whatever is passed as suiteTitle to each test in the suite. Returns an object with test(), skip(), and only() functions.

isolate(modulePath: string, mocks: Mocks): module

Mocks interface:

{
    imports?: [string, any][] //must be relative to module being isolated
    props?: [string, any][] //only works if module uses "self-import" technique
}

Returns a module with Dependency Injection for modulePath, as specified by the mocks argument. As a side effect, the module cache is deleted (before and after) for module specified by modulePath and all modules specified in mocks.imports. This should not matter during unit testing, but it is something to be aware of. This method (along with the rest of this package) should not be used in production code.

You should pass as a string the same thing you would pass to an import statement or require. The only caveats are that 1) any relative paths be relative to the module being returned, and 2) it must only be a direct dependency of that module (will not work recursively, including re-exported modules).

This function throws if any of the modules or properties are not resolvable, or if there are any unused (not imported by the subject):

Error: The following imports were not found in module ./exponent: 
        path

Example usage:

import * as fooModule from './foo'; //not emitted since only used for type

const m: typeof fooModule = isolate('./foo', {
    imports: [
        ['./bar', {bar: () => 'fake bar'}]
    ]
});

You can use this function recursively for partial mocking of nested dependencies:

const m = isolate('./foo', {
    imports: [
        ['.', isolate('./bar', {
            imports: [
                ['bob', () => 'fake bob']
            ]       
        })]
    ]
});

Partial mocking (mocks.props)

Unfortunately, partial mocking with mocks.props requires a slight change in the code. In order to be able to properly mock out a module property, it must reference the "live view" module, not just a local function. Let's call this the "self-import" technique:

helloworld.ts

import * as self from './helloworld';

export function hello() {
    return 'hello';
}

export function world() {
    return 'world';
}

export function greet() {
    return `${self.hello()} ${self.world()}`;
}

Then, you can do this:

import {isolate} from 'ts-test';
import * as helloWorldModule from './helloworld';

const mocked: typeof helloWorldModule = isolate('./helloworld', {
    props: [
        ['hello', () => 'greetings'],
        ['world', () => 'earthling'],
    ],
});
    
console.log(mocked.greet()); //greetings earthling
console.log(helloWorldModule.greet());//hello world

"Props" (pun intended) goes to this chap on SO.

For further reading, please see Mocking strategy and a disclaimer.

stub(): SinonStub

Returns a sinon stub.

Interfaces

Assert

plan(n: number): void

Asserts that there must be a certain number of tests. Any less and the test will time out. Any more and the test will fail immediately.

deepEqual(actual: any, expected: any, msg?: string): void

The same as tape's deepEqual function which asserts deep and strict equality on objects or primitives. Unless your code is non-deterministic, this should be the only assertion you need. We include others here for convenience, but the goal is to keep the number of assertions very small.

errorsEquivalent(err1: any, err2: any, msg?: string)

Asserts that both errors are similar. Stack traces are ignored. It checks for both non-enumerable properties (ie, name and message) and enumerable properties (anything added by extending Error).

Both errors must be an instance of Error, or an error will be thrown. See validate.spec.ts example.

TestOptions

timeout?: number

Test will time out after this threshold.

markAs?: 'only' | 'skip'

It is recommended you use aliases only() and skip() instead for readability and consistenecy.

Mocks

imports?: [string, any][]

Must be relative to module being isolated.

props?: [string, any][]

Only works if module uses "self-import" technique (see isolate(): partial mocking).

Mocking strategy and a disclaimer

Mocking consists of two different activities—dependency injection and the creation of "stub/mock objects" which are meant to mimic dependencies or provide instrumentation for the System Under Test (SUT). The latter is fairly straightforward, which is not covered here. The former, however, can by tricky, especially for node applications.

In general, Dependency Injection (DI) means being able to swap out one dependency for another during runtime. This is what allows you to "isolate" your SUT from other code (namely, its dependencies). This is what makes unit testing possible for systems that have dependencies, without requiring some kind of special transpilation or reflection.

There are several methods to achieve this. One approach is to simply pass all of these dependencies as arguments to a function or object constructor. This is what is commonly referred to as DI. There are also frameworks that help manage this for you, but many come with a steep learning curve and a huge buy-in. This project offers a much simpler, yet less robust alternative for node projects, isolate().

As it turns out, we can enhance CommonJS to create our own utility to do DI into node modules. This is possible due to fact that Module.require() can be "overridden". Other libraries like rewire and proxyquire take a similar approach. Unfortunately, node modules are singletons—meaning, once they are created, they are immutable, and they exist globally. This is incompatible with the "atomic tests" principle. We can get around this by deleting the cached module before and after, but only for the modules that we care about. This way, there should be no pollution between tests, obviating the need for mutation or teardown steps.

In the past, libraries like sinon utilized module export mutation. However, since ES6 modules are immutable, this no longer works. In any case, mutating global singletons is not the best choice and always had limitations.

Whenever possible, it is highly recommended avoiding the need for teardown steps in unit tests. For one, they are easy to forget. Most importantly, it's not always obvious when you have forgotten one or got it wrong. You may have faulty tests that still pass, or unexpected behavior that is difficult to debug.

isolate() further helps out by throwing if it detects anything is wrong before it's too late. It will throw if any of the modules or properties are not resolvable, and if there are any unused (not imported by the subject).

All that said, the NodeJS ecosystem is always rapidly changing, and it is possible this strategy may no longer work at some point in the future.

How to build locally

npm i

Running tests

npm test

How to contribute

Issue a PR against master and request review. Make sure all tests pass and coverage is good.

Releases

This package follows Semver and Conventional Commits to determine how to version the codebase. This repo uses Github Actions to publish a release to npm.

Branch Channel
candidate-<name> Publish an rc prerelease the rc-{name} channel.
master Publish a release to the default distribution channel.
Conventional Commit Type Description
BREAKING-CHANGE Bump the API's major version number.
feat Bump the API's minor version number.
fix Bump the API's patch version number.
build Bump the API's patch version number.
ci Bump the API's patch version number.
refactor Bump the API's patch version number.
style Bump the API's patch version number.
perf Bump the API's patch version number.
docs No version number change.
test No version number change.