Highly inspired by funkia/io and redux-saga, this library intends to wrap small pieces of impure code, orchestrates and tests them.
testHandler(logTwice('hello world'))
.matchIo(log('hello world'))
.matchIo(log('hello world'))
.run();
This piece of code is an assertion, an error will be thrown if something goes wrong:
- wrong io function
- wrong io arguments
- too much io ran
- not enough io ran
npm install --save handle-io
io is just a wrapper for functions and arguments. In some way, it transforms impure functions into pure functions.
Conceptually, an io function could just be defined in this way:
const log = (...args) => [console.log, args];
but in handle-io
, it isn't.
You can use io
to create one:
const { io } = require('handle-io');
const log = io(console.log);
Running log with arguments:
log('Hello', 'World').run(); // print Hello World
Running log without arguments:
log().run();
// or
log.run();
Keep in mind: pieces of code using .run()
cannot be tested properly.
The idea of this library is to apply an IO function inside a structure called handler.
A handler is a wrapped pure generator which just apply some IO function and/or handler.
e.g.
const { io, handler } = require('handle-io');
const log = io(console.log);
const logTwice = handler(function*(...args) {
yield log(...args);
yield log(...args);
});
Writing tests for handlers is very simple (please see the first example above).
What about testing a handler which applies an IO function and returns values ?
There is a very simple way:
- using the second argument of the .matchIo() method to mock returned values
- using .shouldReturn() to assert on the final value
e.g.
const { io, handler } = require('handle-io');
const getEnv = io((v) => process.env[v]);
const addValues = handler(function*() {
const value1 = yield getEnv('VALUE1');
const value2 = yield getEnv('VALUE2');
return value1 + value2;
});
testHandler(addValues())
.matchIo(getEnv('VALUE1'), 32)
.matchIo(getEnv('VALUE2'), 10)
.shouldReturn(42)
.run();
Same as for IO functions, there is a .run() method:
addValues().run(); // => 42
// or
addValue.run();
Likewise, don't use handlers' .run() everywhere in your codebase.
handlers are combinable together: you can yield a handler.
handle-io
supports promises and allows you to create asynchronous IO.
e.g.
const { io, handler, testHandler } = require('handle-io');
// async io
const sleep = io((ms) => new Promise(resolve => setTimeout(resolve, ms)));
// create an async combination
const sleepSecond = handler(function*(s) {
yield sleep(s * 1000);
return s;
});
// test this combination synchronously
testHander(sleepSecond(42))
.matchIo(sleep(42000))
.shouldReturn(42)
.run();
Please note that sleep(n)
and sleepSecond(n)
will expose .run() methods that return a promise.
e.g.
sleepSecond(1).run().then((n) => {
console.log(`${n} second(s) waited`);
});
The simplest way to handle errors with handle-io
is to use try/catch blocks.
As you can see in the example below, you can try/catch any errors:
- inside a handler:
- thrown error
- inside an io function:
- thrown error
- unhandled promise rejection
e.g.
const { io, handler } = require('handle-io');
const handler1 = handler(function*() {
throw new Error();
});
// Synchronous IO
const io1 = io(() => { throw new Error() });
// Asynchronous IO
const io2 = io(() => Promise.reject(new Error()));
// handler2 is safe, it can't throw because it handles errors
const handler2 = handler(function*() {
try {
yield io1();
yield io2();
yield handler1();
} catch (e) {
console.error(e);
}
});
A functional helper exits to avoid try/catchs block, it allows to easily ignore errors and/or results.
Under the hood, catchError
uses a try/catch block and works similarly.
e.g.
const { io, handler, catchError } = require('handle-io');
const ioError = io((e) => { throw new Error(e) });
const myHandler = handler(function*() {
const [res, err] = yield catchError(ioError('error'));
if (err) {
yield log(err);
}
return res;
});
By default, no mocked IO throws any error.
It's possible to simulate throws with testHandler
using the simulateThrow
test utility.
Writing tests for myHandler means two cases need to be handled:
- when
ioError
throws:
testHandler(myHandler())
.matchIo(ioError('error'), simulateThrow('error'))
.matchIo(log('error'))
.shouldReturn(undefined)
.run();
- when
ioError
doesn't throw:
testHandler(myHandler())
.matchIo(ioError('error'), 42)
.shouldReturn(42)
.run();
A custom testHandler
can be created using createTestHandler
.
e.g.
import { io, createTestHandler } from 'handle-io';
const createCustomTestHandler = (h, mockedIOs = [], expectedRetValue, assertRet = false, constructor = createCustomTestHandler) => {
return {
...createTestHandler(h, mockedIOs, expectedRetValue, assertRet, constructor),
matchLog: (arg, ret) => constructor(
h,
[...mockedIOs, [io(console.log)(arg), ret]],
expectedRetValue,
assertRet,
constructor,
),
};
};
const customTestHandler = h => createCustomTestHandler(h);
const log = io(console.log);
const myHandler = handler(function*(value) {
yield log(value);
yield log(value);
return 42;
});
customTestHandler(myHandler('hello world'))
.shouldReturn(42)
.matchLog('hello world')
.matchLog('hello world')
.run()
Use with Redux
There is a way to use handler
as redux middleware.
Please take a look to redux-fun Handlers.