effects-as-data
is a library that allows you to express your business logic using only pure functions, anywhere that Javascript runs. effects-as-data
makes it easy to reason about your code and makes it hard to write hard-to-test code, getting rid of the need for mocks, stubs, spies, globals, and interaction testing.
- It makes development faster.
- Its hard to write hard-to-test code. If you like testing, you'll love effects-as-data.
- Service discovery is no longer a thing. No need for dependency injection, exposing services as singletons, etc. Services and state are available everywhere all the time, without being global.
- All business logic is expressed using only pure functions.
- One testing methodology for everything. Say goodbye to mocks, stubs, spies, globals, and interaction testing. Just pure-function,
deepEqual(input, output)
style testing. - Logicless tests. Tests have no assertions.
- Decouple protocol from implementation.
- Independently monitor all side effects in the system.
A side-by-side example of the same code written in traditional-style javascript and in effects-as-data: https://github.com/orourkedd/effects-as-data-vs-side-effects. This came from a talk given at the Pluralsight Tech Summit.
npm i --save effects-as-data
You can run the code below using this command. You can see the code here.
npm install
npm run demo
Define a pure function that effects-as-data
can use to perform your business logic. This function coordinates your workflow. The function below does a lot and would normally be difficult to test:
- Reads user input (a Github username).
- Does a GET request to Github for the user's repositories.
- Prints an array of the user's repository names.
- Returns the array of repository names.
NOTE: prompt
, httpGet
, logInfo
below are pure functions which only return JSON objects. They are not actually prompt
ing, httpGet
ing, etc. effects-as-data
routes these JSON objects to handlers that do the side-effect-producing, hard-to-test part for you. The code below performs no side-effects, nor does it have any reference to side-effect-producing code.
You can find this in demo-cli/functions/save-repositories.js
const { actions, isFailure } = require('effects-as-data/node')
const { pluck } = require('ramda')
const getListOfNames = pluck(['name'])
const saveRepositories = function * (filename) {
const {payload: username} = yield actions.prompt('\nEnter a github username: ')
const repos = yield actions.httpGet(`https://api.github.com/users/${username}/repos`)
if (isFailure(repos)) return repos
const names = getListOfNames(repos.payload)
yield actions.logInfo(names.join('\n'))
return names
}
module.exports = {
saveRepositories
}
Test your business logic using logic-less tests. Each tuple in the array is an input-output pair. You can find this in demo-cli/functions/save-repositories.spec.js
:
const { testIt } = require('effects-as-data/test')
const { saveRepositories } = require('./save-repositories')
const { actions, failure } = require('effects-as-data/node')
const testSaveRepositories = testIt(saveRepositories)
describe('saveRepositories()', () => {
it('should get repositories and print names', testSaveRepositories(() => {
const repos = [
{ name: 'foo' },
{ name: 'bar' }
]
return [
['repos.json', actions.prompt('\nEnter a github username: ')],
['orourkedd', actions.httpGet('https://api.github.com/users/orourkedd/repos')],
[repos, actions.logInfo('foo\nbar')],
[null, ['foo', 'bar']]
]
}))
it('should return http GET failure', testSaveRepositories(() => {
const httpError = new Error('http error!')
return [
['repos.json', actions.prompt('\nEnter a github username: ')],
['orourkedd', actions.httpGet('https://api.github.com/users/orourkedd/repos')],
[failure(httpError), failure(httpError)]
]
}))
})
If your tests are failing, you get a message like this:
Error on Step 4
Actual:
{ type: 'writeFile',
path: 'wrong-path.json',
data: '...
}
Expected:
{ type: 'writeFile',
path: 'repos.json',
data: '...'
}
Fifth, wire it all up. You can find this in demo-cli/index.js
:
const { run, handlers } = require('effects-as-data/node')
const { saveRepositories } = require('./functions/save-repositories')
run(handlers, saveRepositories, 'repos.json').catch(console.error)
Logging all action failures explicitly can add a lot of noise to your code. Effects-as-data provides an onFailure
hook that will be called for each failed action with a detailed payload about the error. This allows for every effect in the system to be independently monitored and reported on, automatically.
function onFailure (payload) {
// payload:
// {
// fn: 'saveRepositories',
// log: [
// [42, {type: 'firstAction'}],
// [{success: true, payload: 'something from firstAction'}, {type: 'theFailingAction'}]
// ],
// failure: {
// success: false,
// error: {
// message: 'Some happened on this line for that reason'
// }
// },
// action: {type: 'theFailingAction'}
// }
myLoggingFn(payload)
}
function * test () {
yield { type: 'firstAction' }
yield { type: 'theFailingAction' }
}
return run(handlers, test, 42, {
name: 'testFunction',
onFailure
})
- env
- readFile
- writeFile
- prompt
- requireModule
- call
- echo
- guid
- httpGet
- httpPost
- httpPut
- httpDelete
- jsonParse
- logInfo
- logError
- now
- randomNumber
- getState
- setState
Create an env
action. yield
an env action get process.env
.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return the environment', testExample(() => {
return [
[null, actions.env()],
[{ NODE_ENV: 'development' }, success({ NODE_ENV: 'development' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/node')
function * example () {
const result = yield actions.env()
return result
}
// Run It
const { handlers, run } = require('effects-as-data/node')
run(handlers, example).then((env) => {
env.payload.NODE_ENV === 'development' // true, if process.env.NODE_ENV === 'development'
})
Returns Object an action of type env
.
Creates a readFile
action. yield
a readFile
action to read a file using fs.readFile
.
Parameters
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should read a file', testExample(() => {
const path = '/path/to/file.txt'
return [
[{ path }, actions.readFile(path, { encoding: 'utf8' })],
['FOO', success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/node')
function * example ({ path }) {
const result = yield actions.readFile(path, { encoding: 'utf8' })
return result
}
// Run It
const { handlers, run } = require('effects-as-data/node')
run(handlers, example, { path: '/path/to/file.txt' }).then((result) => {
result.payload === 'FOO' // true, if '/path/to/file.txt' has the content 'FOO'.
})
Returns Object an action of type readFile
.
Creates a writeFile
action. yield
a writeFile
action to write a file using fs.writeFile
.
Parameters
file
string file paththe
any contents to writeoptions
Object? options forfs.writeFile
(optional, default{}
)path
data
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const testExample = testIt(example)
describe('example()', () => {
it('should write a file', testExample(() => {
const path = '/path/to/file.txt'
const contents = 'BAR'
return [
[{ path, contents }, writeFile(path, contents, { encoding: 'utf8' })],
// expect a `success` from writeFile and for the function to return the `success`
[success(), success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/node')
function * example ({ path, contents }) {
const result = yield actions.writeFile(path, contents, { encoding: 'utf8' })
return result
}
// Run It
const { handlers, run } = require('effects-as-data/node')
run(handlers, example, { path: '/path/to/file.txt', contents: 'FOO' }).then((result) => {
result.success === true && result.payload === null // true, if write to '/path/to/file.txt' was successful.
})
Returns Object an action of type writeFile
.
src/actions/node/prompt.js:35-40
Creates a prompt
action. yield
a prompt
action read input form a user from the command line.
Parameters
question
string prompt for the user
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should prompt the user', testExample(() => {
return [
[null, actions.prompt("What's your favorite color?")],
['green', success('green')]
]
}))
})
// Write It
const { actions } = require('effects-as-data/node')
function * example () {
const result = yield actions.prompt("What's your favorite color?")
return result
}
// Run It
const { handlers, run } = require('effects-as-data/node')
run(handlers, example).then((result) => {
result.payload === 'green' // true, if the user typed "green" on the command line.
})
Returns Object an action of type prompt
.
src/actions/node/require-module.js:35-40
Creates a requireModule
action. yield
a requireModule
action to require a module.
Parameters
the
string absolute path to the module.path
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should require a module', testExample(() => {
return [
[null, actions.requireModule('/path/to/my-module')],
[{ foo: 'bar' }, success({ foo: 'bar' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/node')
function * example () {
const result = yield actions.requireModule('/path/to/my-module')
return result
}
// Run It
const { handlers, run } = require('effects-as-data/node')
run(handlers, example).then((result) => {
result.payload == { foo: 'bar' } // true
})
Returns Object an action of type requireModule
.
src/actions/universal/call.js:43-50
Creates a call
action. yield
a call
action to call another effects-as-data function. call
is used to compose effects-as-data functions in a testible manner.
Parameters
fn
Function an effects-as-data generator function.payload
any? the payload for the effects-as-data function.options
Object? options forcall
(optional, default{}
)
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should call an effects-as-data function', testExample(() => {
return [
['123', actions.call(getUser, { id: '123' })],
[{ id: '123', username: 'foo' }, success({ id: '123', username: 'foo' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * getUser ({ id }) {
const user = yield actions.httpGet(`https://example.com/api/v1/users/${id}`)
return user
}
function * example ({ id }) {
const result = yield actions.call(getUser, { id })
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { id: '123' }).then((user) => {
user.payload.id === '123' // true
user.payload.username === 'foo' // true, if a user with an id of '123' has the `username` 'foo'.
})
Returns Object an action of type call
.
src/actions/universal/echo.js:35-40
Creates an echo
action. yield
an echo
action for the handler to return payload
. This is used as a placeholder when multiple actions are being yield
ed and some of the actions need to be yield
ed conditionally.
Parameters
payload
any the value to be returns from the handler.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return its argument', testExample(() => {
const value = { foo: 'bar' }
return [
[{ value }, actions.echo(value)],
[value, success(value)]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ value }) {
const result = yield actions.echo(value)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { value: 32 }).then((result) => {
result.payload === 32 // true
})
Returns Object an action of type echo
.
src/actions/universal/guid.js:33-37
Creates a guid
action. yield
a guid
action to get a shiny new guid.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return a guid', testExample(() => {
return [
[null, actions.guid()],
['83feb66e-cf36-40a3-ad23-a150f0b7ed4d', success('83feb66e-cf36-40a3-ad23-a150f0b7ed4d')]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example () {
const result = yield actions.guid()
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example).then((result) => {
result.payload === '15270902-2798-4c34-aaa8-9a55726b58af' // true, if `uuid.v4()` returned '15270902-2798-4c34-aaa8-9a55726b58af'
})
Returns Object an action of type guid
.
src/actions/universal/http.js:43-50
Creates a httpGet
action. yield
an httpGet
action to do an http GET request.
Parameters
url
string the url to GET.headers
Object? request headers. (optional, default{}
)options
Object? options forfetch
. (optional, default{}
)
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return a result from GET', testExample(() => {
return [
[{ url: 'http://www.example.com' }, actions.httpGet('http://www.example.com')],
[{ foo: 'bar' }, success({ foo: 'bar' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ url }) {
const result = yield actions.httpGet(url)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const url = 'https://www.example.com/api/v1/something'
run(handlers, example, { url }).then((result) => {
result.payload === { foo: 'bar' } // true, if a GET to `url` returned `{ foo: 'bar' }`
})
Returns Object an action of type httpGet
.
src/actions/universal/http.js:89-97
Creates a httpPost
action. yield
an httpPost
action to do an http POST request.
Parameters
url
string the url to POST.payload
Object? the payload to POST.headers
Object? request headers. (optional, default{}
)options
Object? options forfetch
. (optional, default{}
)
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should POST payload to url', testExample(() => {
const url = 'http://www.example.com/api/v1/user'
return [
[{ url }, actions.httpPost(url, { foo: 'bar' })],
[success(), success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example (payload) {
const result = yield actions.httpPost('http://www.example.com/api/v1/user', payload)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { foo: 'bar' }).then((result) => {
result.success === true // true, if a POST was successful
})
Returns Object an action of type httpPost
.
src/actions/universal/http.js:136-144
Creates a httpPut
action. yield
an httpPut
action to do an http PUT request.
Parameters
url
string the url to PUT.payload
Object? the payload to PUT.headers
Object? request headers. (optional, default{}
)options
Object? options forfetch
. (optional, default{}
)
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should PUT payload to url', testExample(() => {
const url = 'http://www.example.com/api/v1/user'
return [
[{ url }, actions.httpPut(url, { foo: 'bar' })],
[success(), success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example (payload) {
const result = yield actions.httpPut('http://www.example.com/api/v1/user', payload)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { foo: 'bar' }).then((result) => {
result.success === true // true, if a PUT was successful
})
Returns Object an action of type httpPut
.
src/actions/universal/http.js:181-188
Creates a httpDelete
action. yield
an httpDelete
action to do an http DELETE request.
Parameters
url
string the url to DELETE.headers
Object? request headers. (optional, default{}
)options
Object? options forfetch
. (optional, default{}
)
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return a result from DELETE', testExample(() => {
return [
[{ id: '32' }, actions.httpDelete('http://www.example.com/api/v1/user/32')],
[success(), success())]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ id }) {
const result = yield actions.httpDelete(`http://www.example.com/api/v1/user/${id}`)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { id: '123' }).then((result) => {
result.success === true // true, if a DELETE to http://www.example.com/api/v1/user/123 was successful
})
Returns Object an action of type httpDelete
.
src/actions/universal/json-parse.js:35-40
Creates a jsonParse
action. yield
a jsonParse
action to parse a JSON string. Why not just use JSON.parse()
inline? Although a successful parsing operation is deterministic, a failed parsing operation is not.
Parameters
payload
string the JSON string to parse.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should parse a JSON string', testExample(() => {
return [
[{ json: '{"foo": "bar"}' }, actions.jsonParse('{"foo": "bar"}')],
[{ foo: 'bar' }, success({ foo: 'bar' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ json }) {
const result = yield actions.jsonParse(json)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { json: '{"foo": "bar"}' }).then((result) => {
result.payload.foo === 'bar' // true
})
Returns Object an action of type jsonParse
.
src/actions/universal/log.js:35-40
Creates a logInfo
action. yield
a logInfo
action to log to the console using console.info
.
Parameters
payload
string? the payload to log.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should log a message', testExample(() => {
return [
[{ message: 'foo' }, actions.logInfo('foo')],
[null, success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ message }) {
const result = yield actions.logInfo(message)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { message: 'bar' }).then((result) => {
// "bar" should have been `console.info`ed
})
Returns Object an action of type logInfo
.
src/actions/universal/log.js:76-81
Creates a logError
action. yield
a logError
action to log to the console using console.error
.
Parameters
payload
string? the payload to log.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should log a message', testExample(() => {
return [
[{ message: 'foo' }, actions.logError('foo')],
[null, success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example ({ message }) {
const result = yield actions.logError(message)
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example, { message: 'bar' }).then((result) => {
// "bar" should have been `console.error`ed
})
Returns Object an action of type logError
.
src/actions/universal/now.js:33-37
Create an now
action. yield
a now
action to get the current timestamp from Date.now()
.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return the current timestamp', testExample(() => {
return [
[null, actions.now()],
[123456, success(123456)]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example () {
const timestamp = yield actions.now()
return timestamp
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example).then((timestamp) => {
timestamp.payload === 1490030160103 // true, if Date.now() returned 1490030160103
})
Returns Object an action of type now
.
src/actions/universal/random-number.js:33-37
Create an randomNumber
action. yield
a randomNumber
to get a random number using Math.random()
.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return the current timestamp', testExample(() => {
return [
[null, actions.randomNumber()],
[0.123, success(0.123)]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example () {
const n = yield actions.randomNumber()
return n
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example).then((n) => {
n.payload === 0.345 // true, if Math.random() returned 0.345
})
Returns Object an action of type randomNumber
.
src/actions/universal/state.js:34-39
Creates a getState
action. yield
a getState
to get application state.
Parameters
keys
Array an array of paths to sections of state. For example, ['user.firstName', 'settings.showBanner']
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should return user from application state', testExample(() => {
return [
[null, actions.getState(['user'])],
[{ id: '123', username: 'foo' }, success({ id: '123', username: 'foo' })]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example () {
const user = yield actions.getState(['user'])
return user
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
run(handlers, example).then((user) => {
user.id === 'abc' // true, if the user has an `id` of 'abc'
})
Returns Object an action of type getState
.
src/actions/universal/state.js:76-81
Creates a setState
action. yield
a setState
to set application state.
Parameters
payload
Object? An object that will bemerge
ed into the application state.
Examples
// Test It
const { testIt } = require('effects-as-data/test')
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const testExample = testIt(example)
describe('example()', () => {
it('should set a user on the application state', testExample(() => {
const user = { user: '123' }
return [
[user, actions.setState({ user })],
[null, success()]
]
}))
})
// Write It
const { actions } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
function * example (user) {
const result = yield actions.setState({ user })
return result
}
// Run It
const { handlers, run } = require('effects-as-data/universal') // also available in require('effects-as-data/node')
const user = { id: '123', username: 'foo' }
run(handlers, example, user).then((result) => {
result.success === true // true, and `user` should be available on the application state using `actions.getState(['user'])`
})
Returns Object an action of type setState
.