1.3.4 • Published 2 months ago

risei v1.3.4

Weekly downloads
-
License
MIT
Repository
-
Last release
2 months ago

Risei

What Risei is

Risei is a new way to write unit tests that allows you to:

  • Whip up full test coverage of object-oriented or object-hosted JavaScript and TypeScript in no time.
  • Refactor or replace existing designs without worrying about the cost in past or future test time.
  • Create tests with immediate confidence, because you can't introduce mistakes in test code you write.

The Risei approach

Risei replaces coded tests with simple declarative syntax that's far easier to draft than any other unit-testing approach.

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

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

Here is the terminal output of these two example tests.  Tests are grouped by class and method.  Inputs, expected, and actual values are displayed for all tests, to make intended usage obvious at a glance:

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

  • An individual test may appear on one line or multiple lines, depending on how wide the terminal window is.
  • Any failing tests appear in light red.
  • Your latest-edited tests always sort to the bottom, so you can see your current test results at a glance.

Test runs also feature a title bar at the top, as well as a summary bar at the bottom:

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

Using Risei

Risei is under active development.  If you've been here before, check out what's new in Risei for the latest.  Also see known issues and workarounds and limitations in Risei.

There are a few additional or different steps for using Typescript with Risei.

Installation

Install Risei for development time only:

npm install --save-dev risei

Make sure your package.json specifies that ESM is in use:

"type": "module"

And add Risei's metadata to package.json:

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

Just a few extra steps are required for Risei to work with TypeScript.

Siting tests

Add tests in files ending in .rt.js like this:

import { ATestSource } from "risei/ATestSource";
import { ClassToBeTested } from "somewhere";

export class SomeTests extends ATestSource {
    tests = [ ... ];  // This array is where test definitions are written.
}

Writing tests

Write tests with Risei's easy syntax:

tests = [ ...
    { on: ContainerModelClass, with: [ "a", "b", "c" ],      // Target class and constructor args.
      of: "doesContain",                                     // Target method.
      for: "When the input arg is present, returns true.",   // Description of test.
      in: [ "c" ],                                           // Inputs.
      out: true },                                           // Expected output.
...];

Write more tests with less syntax:

You can save a lot of time by putting repeated test properties into an object once, just before the related tests:

    /* All of the following tests are of ContainerModelClass. */
    { on: ContainerModelClass, with: [ "a", "b", "c", "a", "b" ] },

    /* Two tests for ContainerModelClass .doesContain(). */
    { of: "doesContain" },
    { for: "When the input arg is present, returns true.",
      in: [ "c" ], out: true },
    { for: "When the input arg is not present, returns false.",
      in: [ "d" ], out: false },
        
    /* First test for ContainerModelClass .countOf(). */
    { of: "countOf", for: "Returns the number present.", in: [ "b" ], out: 2 }
  • This collapsing forward is reset when you change the class in .on, when you give the property a new value, or you erase it with an empty array [].

  • Tests are isolated in general, but if the code you're testing mutates its arguments, you have to state them in each test so the mutations aren't carried forward.

Spoof away code dependencies:

When your tests need certain results from code dependencies, just spoof what you want using a .plus test property:

        { on: CombinerClass, with: [], 
          of: "combineResults",
          plus: [ 
            { 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 CombinerClass not to do anything.
            { on: ClassC,                                // Spoof this ClassC method to be this nonce code.
              of: "someMethod", 
              as: (arg) => { return { color: arg }; } },  
            { on: ClassD, as: [                          // Spoof two methods on ClassD at the same time.
                { of: "firstMethod", as: 11 }, 
                { of: "secondMethod", as: 12 } 
              ] },
          ],
          for: "When called, returns an array of values from sources.",
          in: [ "green" ], out: [ 7, 8, 9, 10, 10, 11, 12, { color: "green" } ] }
  • It's just like fakes, mocks, or test doubles in other test systems, but much easier to write and read.

  • Spoofed code is fully isolated within tests, even though spoof definitions collapse forward across tests (and within .plus).

Running tests

Run your tests by invoking Risei's index.js file:

node ./node_modules/risei/index.js

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

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

And then run that script:

npm test

Risei can be used alongside other test frameworks, and even run with them from the same test script if you want.

Learning more about Risei

Read through the rest of this doc to learn more about Risei's many capabilities, and about where Risei is headed.

  • For instance, learn about using .and to test static methods or throws.
  • Or learn about using .from to test property results or other state.
  • Don't miss out:  Once you've tried the basics, read the rest...!

Risei in depth

Installation

  • You can use any file extension you want in Risei's metadata in package.json, and Risei will scan those files for tests.

  • You can install Risei outside of development time the usual way, but as with any test system, this is definitely not recommended.

  • Risei uses ESM syntax, and may not work in older environments where that's not available or is patched in.

Siting tests

  • Match your Risei test file's extensions to whatever you choose in the metadata in package.json.

  • Test classes must inherit from ATestSource in "risei/ATestSource": this type name is looked for specifically, so duck-typing doesn't work.

Writing tests

  • The order and placement of test properties doesn't matter, although the order seen in the examples is probably most readable.

Basic test properties and options for each:

NameContents
onThe class under test, as a symbol / name (already imported into the test file)
withAn array of any arguments to pass to the constructor of the tested class, or an empty array [] if none
ofThe name of the method under test, as a string, no () needed
forA description of what the test proves, for test output
inAn array of the input arguments to pass to the tested method, or an empty array [] if none
outThe expected return value, not in an array
  • There are additional properties for extended functionality, covered later.
  • The property names were chosen to be easy to remember and type, but longer alternatives are available, covered later.

Test-property reuse AKA Collapsing forward

  • To save time and effort, any property you write is collapsed forward to subsequent tests unless you replace it or erase it with an empty array [].

    • Property values are gathered across partial test objects until they add up to a runnable test, which is then run.
    • Changes to properties are combined with previous ones to produce intended new tests.
  • Test contents, reused or not, are automatically reset when it makes the most sense:

    • Changing the tested class in .on wipes out all prior test properties, so the new class has a fresh start.
    • Changing the tested method in .of wipes out only test properties related to methods, to preserve class values you usually want.
  • Individual tests remain isolated, except for when args are mutated by the code under test.

    • Restate args for each test when the code mutates them.
    • This limitation is the result of limits on what can be copied reliably in JavaScript.

Choosing test-only dependency inputs AKA Spoofing

  • Spoofing is Risei's way of providing test-only inputs from dependencies the tested code uses, written in simple declarative syntax.
    • It's therefore the equivalent of test doubles, mocks, fakes, and so on.
  • As the earlier examples and this table show, you can spoof in many ways, both on the dependencies, and on the class being tested.
  • All classes whose members are being spoofed have to be imported.

Spoof-definition properties:

NameNecessary or OptionalContents
onOptionalSymbol for a type (class); if omitted, the targeted model class is assumed
ofNecessaryName of the method to spoof, as a string, trailing () not needed
asOptionalValue to return or nonce implementation; if omitted, an empty method with no return value is used or A list of partial spoof definitions for the same class
  • You can spoof multiple methods of a class individually, or list them together with partial definitions as seen in the example, with the same effect either way.

Partial spoof-definition properties:

NameNecessary or OptionalContents
ofNecessaryName of the method to spoof, as a string, trailing () not needed
asOptionalValue to return or nonce implementation; if omitted, an empty method with no return value is used
  • Defining new spoofing wipes out all old definitions.
  • Spoofing is done at the start of each test and undone at the end of each test, keeping all tests isolated.

Advanced Risei usage

Testing special test conditions with .and

You can use the special test property .and, always a string, to indicate special conditions that apply to your test.

  • At present, the values available are "static" and "throw" / "throws" (either one works).
  • You can list static and throw / throws together if needed, separated by a space.

The .and property is an expansion point for supporting more special conditions in the future.  Values will always be listable together (as long as any particular grouping makes sense).

Testing static methods

To test a static method, use an .and of "static":

{ on: StaticTextProfileModel, with: [] },

{ of: "getTextProfile",
  for: "Returns accurate profile of arg text.",
  and: "static",
  in: [ "This is a short sentence." ],
  out: { isFullSentence: true, characters: 25, words: 5 }
}

Testing throws

To test a throw, use an .and of "throw" or "throws":

{ on: InstanceTextProfileModel, with: [] },

{ of: "getTextProfile",
  for: "Throws with a helpful message when there is no arg text.",
  and: "throws",
  in: [],
  out: "Could not build a profile.  Did you forget to provide a text?"
}

Testing object properties and other non-return results

To test a value that isn't the exercised code's return value, you use .out normally, and you add a .from property to your test, which can be one of two things:

Contents of .fromUsage
Property name as stringRetrieves the actual from the named property on the test's target class
Specialized functionRetrieves the actual from either its target or test parameter
  • Getting properties, mutated args, and other non-return values is known as retrieving.
  • Retrieving definitions collapse forward across tests unless replaced with new definitions, or erased by setting .from to an empty array [].
  • Use of .from with a property name looks like this:
{ on: StatefulModel, with: [] },

{ of: "storePolyProduct",
  for: "The result of the calculation is stored as .result.",
  in: [ 10, 8, 9, 6 ], out: 4320, from: "result" },
  • Only instance properties can be retrieved by name.  For static properties, use .and (covered earlier), plus a function as .from (covered next).

When the contents of .from are a function, these are the two parameters:

NameContents
targetThe instance of the class being tested
testThe test definition itself, whose properties may have been mutated by the tested method
  • The test parameter to a .from function contains all of the properties of your test definition, including those that collapsed forward.
    • These properties are available by both short or long names (covered later).
  • The test.on property always references the class under test, rather than an instance of it.
  • You can write a function for .from that uses test.on to get the values of static properties, in conjunction with and: "static" in the encompassing test.
  • Another usage of a .from function is to look at an input to see if it was changed as expected:
{ on: SecurityModel, with: [] },

{ of: "addHash",
  for: "The content's hash is added to the input arg a new property.",
  in: [ { content: "SecretValue" }, iterations: 8 ],
  out: { content: "SecretValue", hash: "FasBdQ-fljk%asPcdf" },
  from: (target, test) => { return test.in[0]; } 
},

Property long names

Property names are short so they're easy to use.  Some of them overlap with JavaScript keywords, but this causes no harm.

All test properties have long names that you can use instead of the short ones if you prefer, mixed together as much as you want.  None of the long names are JavaScript keywords.

Short NameLong Name
ontype
withinitors
ofmethod
fornature
ininputs
outoutput
plusspoofed
fromsource
andfactors
Short NameLong Name
ontarget
ofmethod
asoutput

Further capabilities of Risei

Constructors can be tested with no special test properties or keywords.

  • The method name in .of is simply "constructor".
  • Any .with args are completely ignored for a constructor test.  Only those in the test's .in property are used.
    • However, a .with must still be provided.  It can simply be an empty array [].

Using TypeScript with Risei

Testing TypeScript code with Risei basically just means transpiling before running the tests, and being sure to point to its output JS files, not the TS ones.

  • Anything beyond those basics is intended to keep JS files separate from TS files and the output of web frameworks' own, complex build processes.

If you follow the general Risei set-up, you can just make the following few modifications for TypeScript applications.

  • These steps have worked with both Angular and React.
  • Varying other approaches are also sure to work, depending on the other technical choices in play.

In package.json:

Add transpilation and deletion operations to the test script, with each operation conditional on completion of the preceding one:

"scripts": {
  "test": "tsc && node ./node_modules/risei/index.js && rm -r ./dist/out-tsc"
}
  • Generally, you should use tsc or another plain transpiler, not web frameworks' complex processes with single output files.
  • Deleting the files at the end prevents interference with web frameworks' bundling.
  • The deletion path must match the outDir in tsconfig.json.

You can run Risei manually with the same sequence of operations.

In tsconfig.json:

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

{
  "outDir": "dist/out-tsc",
  "noEmit": false
}
  • The outDir can be any location.  It's best not to just use dist, where adding or deleting files may interfere with web frameworks' build steps.
  • These settings are normally not near each other in the file (as seen here).
  • You can also set the target to es6 or higher, although Risei works fine with ES5.

In test files:

All import statements have to point to the Javascript (.js) files in the outDir path or subfolders there, using relative-path syntax:

import { TestedClass } from "../../dist/out-tsc/SubPath/TestedClass.js";
  • Output files may be in a folder tree parallel to the originals (Angular), or may all be in the outDir folder itself (React).

Limitations in Risei

The following are not supported at present:

  • Use of async syntax
  • Code written in ESM export modules, but not within classes
  • Standalone functions and other functionality not built into classes, AKA loose code
  • CJS syntax using require()
  • Spoofing mixes of properties and methods, such as something.method.property.method
  • Debugging model code during tests
  • Comparing rare JS object types like Proxy or ArrayBuffer in test assertions

Some of these are on the tentative future-development list.  In the meantime, Risei can be used to save development time for most of your JavaScript code, while these can be tested using another framework invoked alongside Risei.

Troubleshooting

Most problems with using Risei are minor mistakes in syntax, or omissions in test definitions:

Error condition or textProbable cause
Gold bar appears below the summary, and the number of tests run dropsA syntax error caused a test file not to load; error output in the gold bar explains why
"Test loading failed for... SyntaxError: Unexpected token ':'"Missing comma in your tests in the named file
"Test loading failed for... SyntaxError: Unexpected token '.'"Using this.tests = [] instead of tests = [] in the file named
Other ... Unexpected token ... errorsSome other syntax error in the file named, most likely a missing or extra delimiter
Unexpected extra, failing test/sPartial test properties in a mid-list object have produced extra tests
Unexpected actual values, or unexpected passes / fails in test runsTest properties from previous tests not replaced or reset with [] or Args mutated by tested code were not restated in following tests to isolate them

Known issues and workarounds

Two issues are known to exist:

  • When code mutates its args in test definitions, the mutated forms are used in subsequent tests unless those args are restated.
    • To avoid this problem, just restate the args in each test of this code.
    • The workaround for this copying and initing objects to bypass mutation requires too many assumptions to be reliable.
  • When a few properties are changed in separate objects, they are treated as whole new tests, which usually fail because of their unintended mix of old and new properties.
    • To avoid this problem, just change properties as part of whole new tests, or else restate .of along with the changed properties.
    • Risei is actually working as intended when this happens, but ways to optionally prevent it are being considered.

What's new in Risei

  • Release 1.3.4 (March, 2024) fixes a bug that caused class definitions still to appear instead of class names when classes were used as object properties.
    • As a side effect of this change, objects' own toString() is never used in onscreen test output any longer, but all of Risei's output display is now handled the same.
  • Release 1.3.3 (March, 2024) fixes a major bug related to file paths that prevented any tests from loading or running in Windows environments.
    • Risei is now fully usable in Windows environments again.
  • Release 1.3.2 (February, 2024) does not change the code at all, but improves this read-me.
    • It now explains the easiest way to deal with mutable args (which is just restating the args).
    • A few mistakes in the text were also fixed.
  • Release 1.3.1 (February, 2024) reverses some changes in 1.3.0 that were intended to fully isolate tests against mutable args, but which caused incomplete or inaccurate object contents in some cases.
    • Supporting built-in total test isolation is not feasible with collapsing forward, nor is it usually necessary given easy usage alternatives.
    • In contrast, collapsing forward is both highly beneficial and frequently used.
  • Release 1.3.0 (February, 2024) makes two changes:
    • If there are any test-loading errors, their messages now appear in a new gold bar that appears below the summary of the test run.
    • A test-sorting error has also been fixed that occurred when no test files existed yet.
  • Release 1.2.0 (January, 2024) changes sorting of test files to make tests you're working on now appear at the bottom.
    • Previously tests were displayed in the order found, which is always alphabetical (though is not guaranteed to be so).
    • The order for all files except one remains the same.
    • The last-edited file is moved to the bottom, making it easy to see the latest test results.
  • Release 1.1.2 of Risei (January, 2024) fixes two problems:
    • Classes were sometimes displayed in output as their entire definition.  Now just the class name is displayed.
    • All Date instances were considered equal, regardless of their actual value.  Now they only are considered equal when they actually are.
  • Don't use release 1.1.1, which contains an internal path error.

Who makes Risei

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 license

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

Risei Copyright © 20232024 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.

 

1.3.4

2 months ago

1.3.3

2 months ago

1.3.2

3 months ago

1.3.1

3 months ago

1.3.0

3 months ago

1.2.0

4 months ago

1.1.1

4 months ago

1.1.2

4 months ago

1.1.0

7 months ago

1.0.4

8 months ago

1.0.3

8 months ago

1.0.2

8 months ago

1.0.1

8 months ago

1.0.0

8 months ago