Risei Home Page

Overview

Risei is a new way to write unit tests that's easier and faster, more dependable, and keeps your tests from standing in the way of redesigns.

Risei does all this by replacing coded tests with simple declarative syntax.

Examples

Here are two example tests, in which SortModel.countSort() is being tested using the inputs from .in and the expected output from .out:

https://deusware.com/risei/images/Syntax-example.png

Here are the example test results for those two tests:

https://deusware.com/risei/images/Output-example.png

Test runs have a title bar so the starting point is easy to find:

https://deusware.com/risei/images/Title-example.png

And they have a summary bar at the bottom:

https://deusware.com/risei/images/Summary-example.png

Status

Risei was under active development until recently, but its major features are now complete.  New enhancements or fixes may appear from time to time.

The latest releases, 3.0.0 and 2.0.0 / 2.0.1, brought major improvements, including the ability to test async code, to test properties, and much more.

Check out the list of these changes.

Installation

Install Risei for development time only:

npm install --save-dev risei

Ensure that package.json specifies ECMAScript modules:

"type": "module"

And add Risei's metadata to package.json:

"risei": {
  "tests": "**.rt.js"
}

Running Tests

Once you have some tests written, you can run them manually:

node ./node_modules/risei/index.js

Or write a script in package.json that does the same:

"scripts": {
  "test": "node ./node_modules/risei/index.js"
}

And then run that script:

npm test

Writing Tests

You write tests in .rt.js files like this:

import ATestSource from "risei/ATestSource";
import ClassToTest from "ClassToTest.js";

export class SomeTests extends ATestSource {
    tests = [ ... ];
}

Write individual tests as plain JavaScript objects with Risei's simple syntax:

tests = [ ...
    { on: ContainerClass, with: [ "a", "b", "c" ],    // Target class and constructor args.
      of: "doesContain",                              // Target method name.
      for: "When the arg is present, returns true.",  // Description of test.
      in: [ "c" ],                                    // Inputs to method.
      out: true },                                    // Expected output.
... ];
Asynchronous code with async and await keywords can tested with no changes at all to this syntax.

Collapsing forward for lighter tests

You can write more tests with less syntax by stating test properties once and letting them collapse forward across subsequent tests.

Risei collapses values together until it has a full test to run, as seen in this typical example:

{ on: ContainerClass, with: [ "a", "b", "c" ] },                     // Following tests are of this class.

{ of: "doesContain" },                                               // Following tests are of this method.
{ for: "Returns true when arg present.", in: [ "c" ], out: true },   // First test: now Risei has enough to run on.
{ for: "Returns false when arg absent.", in: [ "d" ], out: false },  // Next test: same method, different test case.

{ of: "countOf" },                                                   // Change of tested method.  Method-related props are wiped out.
...

{ on: SortingClass, with: [ ] },                                     // Change of tested class.  All existing props are wiped out.

Every property collapses forward until it is set to something else or wiped out with a target shift:

Preventing extra tests using .just:

You may want to pre-set a property for an upcoming group of tests, even when fully defined tests already exist.  However, this creates an accidental extra test (which often fails), because Risei always collapses old and new values together to make a new fully defined test.

It's easy to avoid this problem with the .just property:

{ on: ContainerClass, with: [ "a", "b", "c", ] },  // A section of tests using these values.
{ ... },
{ ... },

{ just: true, with: [ "t", "u", "v" ] },           // Changing .with without creating an extra test.
{ ... },                                           // Tests using the new .with start here.
{ ... },

Avoiding mutable-arg problems by restating them:

Collapsing forward reuses test property contents directly.  Tests are isolated unless your code mutates its arguments.  If so, the mutated values are used, throwing off test results.

To prevent this problem, simply restate mutable args in each test to keep them independent.

Testing properties and static methods

To test properties of instances, or to test static methods and properties, you use exactly the same syntax you would for instance methods, and Risei figures out the rest:

tests = [
    { on: VariegatedClass, with: [ ] },
    { of: "instanceMethod", for: "Returns true.", in: [ ], out: true },  // Typical use: instance method.
    { of: "staticMethod", for: "Returns false.", in: [ ], out: false },  // Static method: same syntax.  Empty .with needed.
    { of: "instanceProperty", for: "Returns 47.", in: [ ], out: 47 },    // Instance prop: same.  Empty .in needed.
    { of: "staticProperty", for: "Returns 98.", in: [ ], out: 98 }       // Static prop: same.  Empty .in needed.
];

Although Risei figures out what it needs to, you can explicitly indicate member types if you want, and Risei then treats the code as you specify:

{ of: "instanceMethod()", ... },                            // Trailing parens specify method.
{ of: ".someProperty", ... }, { of: "someProperty:", ... }  // Leading dot or trailing colon specify property.
{ of: "something", and: "static", ... },                    // You add an .and of "static" for statics.

Spoofing for test isolation using .plus

To make code dependencies return what your tests need, just spoof what you want using a .plus test property:

{ 
  on: TestedClass,
  ...
  plus: [ 
      { on: Dependency, of: "someMethod", as: 10 },  // Dependency.someMethod() returns 10 in this test.
      { of: "testedClassMethod", as: 11 }            // TestedClass.testedClassMethod() returns 11 in this test.
    ],
  ... 
}

There are many options available for spoofing, all laid out together in this example:

{ on: CombinerClass, 
  ...
  plus: [ 
    /* Mono-spoofing. */
    { on: ClassA, of: "someMethod", as: 10 },    // Spoof this ClassA method to return 10.
    { on: ClassB, of: "someMethod" },            // Spoof this ClassB method not to return (or do) anything.

    { of: "firstMethod", as: [ 7, 8, 9, 10 ] },  // Spoof a method on the tested class (CombinerClass).
    { of: "secondMethod" },                      // Spoof a method on the tested class not to do anything.

    { on: ClassC,                                // Spoof this ClassC method to be this nonce code using a runtime arg.
      of: "someMethod",
      as: (arg) => { return { color: arg }; } }, 

    /* Poly-spoofing. */
    { on: ClassD, as: [                          // Spoof two methods on ClassD at the same time.
        { of: "firstMethod", as: 11 }, 
        { of: "secondMethod", as: 12 } 
      ] },
    { as: [                                      // Spoof two methods on the tested class at the same time.
        { of: "firstMethod", as: 11 }, 
        { of: "secondMethod", as: 12 } 
      ] },
  ],
  ...
}

Special test conditions

Testing throws with .and of "throws":

You can test for a value thrown in code by adding an .and property of "throw" or "throws":

{ 
  on: TestedClass, of: "throwsWhenNoArgsGiven", and: "throws", 
  for: "When not given any args, throws an Error stating \"Argless.\".",
  in: [ ], out: new Error("Argless.") 
}

Testing for undefined actuals with this.undef:

You can test for an undefined value by setting .out to this.undef (or ATestSource.undefSymbol):

{
  on: TestedClass, of: "returnsArgGiven", 
  for: "When the arg is undefined, returns undefined.",
  in: [ undefined ], out: this.undef 
}

Changing actual with .from:

You can refine or replace the test's actual using the .from property, for instance for method side-effects or derivatives of the original actual.

Property-name .from:

The .from can be the name of a property on the target class or its instance created for the test:

{
  on: TestedClass, of: "makeInstanceReady", 
  for: "When method is run, the .ready flag is set.", 
  in: [ ], out: true, 
  from: "ready"
}

Nonce-function .from:

The .from can be a nonce function, which must always return a new actual value:

{
  on: TestedClass, of: "initDerivedObject", 
  for: "When method is run, output's .flag is cleared.",
  in: [ ], out: false,
  from: (test, actual) => { return actual.flag; }
}

Changing external state with .do.early, .do.late, and .undo:

If test state you need can't be created declaratively, you can set it up and then tear it down using .do and .undo.  The .do property has two stages, .early and .late.

You set these properties to nonce functions:

{
  on: TestedClass, for: "When the file to read is not text, returns first ten bytes as hex.",
  do: { 
    early: () => { writeRandomTestFile(); },              // Steps before any test target instance is created.
    late: (test) => { test.out = readTestFileHex(10); }   // Steps after any instance is created, here altering a test property.
  },
  undo: { () => { eraseRandomTestFile(); },               // Steps after everything else in the test.
  in: [ ... ], out: 0x00,                                 // .out is replaced with varying data in .undo.
}

Running a method repeatedly in one test with .and of "poly":

You can call a method or property repeatedly in one test (poly-call it) by adding an .and property of "poly", with changes to what's in .in and .out:

{ 
  on: TestedClass, of: "sumSoFar", and: "poly", 
  for: "When called repeatedly, sums all args provided so far.",
  in: [ [ 1 ], [ 5 ], [ 8 ], [ 6 ], [ 4 ] ],  // An inner array for each call of the method (each empty if no args or property test).
  out: [ 1, 6, 14, 20, 24 ]                   // An array for the outputs of all calls, unless .from is used to customize output.
}

Using TypeScript with Risei

To test TypeScript code with Risei, you make sure the code is transpiled before the tests are run, and you point Risei to the transpiled .js files.

In tsconfig.json:

You set up the transpiler to output files to a separate directory with these two settings:

{
  "outDir": "dist/out-tsc",
  "noEmit": false
}

In package.json:

In the test script, add transpilation before the test run, and file deletion after it, each one conditional on the previous:

"scripts": {
  "test": "tsc && node ./node_modules/risei/index.js && rm -r ./dist/out-tsc"
}
You can run Risei manually with the same sequence of operations.

In test files:

All import statements have to point to the JavaScript (.js) files in the outDir path or its subfolders:

import { TestedClass } from "../../dist/out-tsc/SubPath/TestedClass.js";

Troubleshooting

When a gold bar appears and some tests disappear:

When problems cause test files not to load, you see a gold bar listing the syntax errors at fault:

https://deusware.com/risei/images/Gold-bar-example.png

Here are the most common problems:

Error condition Probable cause
Gold bar text "Test loading failed for... SyntaxError: Unexpected token ':'" Missing comma in your tests in the named file, either between tests or between test props
Gold bar text "Test loading failed for... SyntaxError: Unexpected token '.'" Stating the test list as this.tests = [] instead of tests = [] in the file named
Other ... Unexpected token ... errors in gold bar Some other syntax error in the file named, most likely a missing or extra delimiter
Many test files failing all at once Syntax error in the code targeted in those test files, or a dependency of it

When the listed tests or their results are unexpected:

Error condition Probable cause
Unexpected extra, failing test/s An object intended only to change test properties lacks a .just / .only
Unexpected actual values, or unexpected passes / fails in test runs Test properties from previous tests not replaced or reset with []
— or —
Mutated args for tested code not restated in following tests to isolate them
Tests written not appearing in runs Not all properties needed to run tests stated before or in those tests

Property long names

Test properties have longer names that you can use interchangeably with their short names.

Test definition names
Short Name Long Name
on type
with initors
of method
for nature
in inputs
out output
plus spoofed
from source
and factors
do enact
undo counteract
just only

Spoof definition names
Short Name Long Name
on target
of method
as output

Version history

Older releases
The oldest releases are no longer listed here, and old releases are dropped progressively over time.  Using the latest release is recommended.

Known issues and workarounds

The only issue at present is the use of mutated args when tested code mutates them, when collapsing forward.

Exclusions from Risei

The following are not supported at present:

Some of these may be supported in the future.

Maker

Risei is written by myself, Ed Fallin.  I'm a longtime software developer who likes to find better ways to do things.

If you find Risei useful, consider spreading the word to other devs, making a donation, suggesting enhancements, or proposing sponsorships.

You can get in touch about Risei at riseimaker@gmail.com.

License

Risei is published for use under the terms of the MIT license:

Risei Copyright © 2023–2024 Ed Fallin

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.