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.
Here are two example tests, in which SortModel.countSort()
is being tested using the inputs from .in
and the expected output from .out
:
Here are the example test results for those two tests:
Test runs have a title bar so the starting point is easy to find:
And they have a summary bar at the bottom:
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.
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" }
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
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. ... ];
.on
, .with
, .of
, .for
, .in
, and .out
defined..in
or .with
.Asynchronous code withasync
andawait
keywords can tested with no changes at all to this syntax.
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:
.on
(the target class) wipes out all other test properties..of
(the target method) wipes out all except .on
, .with
, and .plus
(described later).[]
..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. { ... },
.just
doesn't have to be true
, or any value in particular. The property just has to be defined.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.
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. ];
.with
and .in
. You can set them to empty arrays []
if 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.
.of
, .plus
(covered here), and .from
(covered here)..name
..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 } ] }, ], ... }
this
of the tested instance..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.") }
.and
property is an extension point for special test conditions..and
can contain any mix of valid .and
values.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 }
undefined
itself directly for .out
, because Risei treats it as a missing property..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.
.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" }
.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; } }
actual
parameter (found second) is the original actual value from running your targeted code.
test
parameter (found first) is the assemblage of test properties you provided, plus others that Risei sets. Here are the properties you'll most likely want to use in .from
:
test.target
— The instance created for the test, or the class for a static test.test.in
— An array, most useful for methods that mutate their arguments.test.out
— To replace the original expected value using test-time values.test.target
, as this might cause problems..from
to set test.actual
, so even though you can set the latter in .from
, it has no effect..from
, in general it's better (and more flexible) to use .do
and .undo
for that..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. }
do.early
and do.late
if you only need one, which is normally the case.test
parameter to alter the test itself if needed, though this is rare..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. }
actual
parameter / test.actual
parameter in .from
is an array of all the outputs..in
and arrayed outputs in .out
should be the same when testing all outputs.
.out
, and use .from
to return the value you want..and
property can contain any mix of valid .and
values.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.
tsconfig.json
:You set up the transpiler to output files to a separate directory with these two settings:
{ "outDir": "dist/out-tsc", "noEmit": false }
outDir
, but it's best to avoid dist
, which can interfere with frameworks' build steps.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" }
tsc
or an equivalent produces individually testable JavaScript files.outDir
in tsconfig.json
.You can run Risei manually with the same sequence of operations.
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";
outDir
folder itself (React).When problems cause test files not to load, you see a gold bar listing the syntax errors at fault:
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 |
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 |
Test properties have longer names that you can use interchangeably with their short 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 |
.early
and .late
properties of .do
/ .enact
.Short Name | Long Name |
---|---|
on |
target |
of |
method |
as |
output |
async
syntax is now fully supported, with no special test syntax required..from
functions now take test
and actual
as parameters, rather than target
and test
.test.target
..and
of "poly"
(AKA poly-calling)..name
..do
property and one-part .undo
property..just
.undefined
in .out
using this.undef
/ ATestSource.undefSymbol
.Error
objects are now compared accurately..
, :
, and ()
in .of
, .plus
, and .from
, though it isn't necessary to do so.Error
), rather than the Error
's message text.ATestSource
is now a default export, changing its imports from import { ATestSource } from
to import ATestSource from
.Date
comparisons.
The oldest releases are no longer listed here, and old releases are dropped progressively over time. Using the latest release is recommended.
The only issue at present is the use of mutated args when tested code mutates them, when collapsing forward.
The following are not supported at present:
export
modules, AKA loose coderequire()
Proxy
or ArrayBuffer
in test assertionsSome of these may be supported in the future.
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.
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.